捋一捋 App 性能测试中的几个重要概念

我们在使用各种 App 的时候基本会关注到:这款软件挺耗流量的?运行起来设备掉电有点快嘛?切换页面的时候还会有卡顿等现象?如果遇到有这些问题的 App 我们基本会将它请出我们的爱机。由此可见软件是否受欢迎除了提供必要的功能外,流畅性、流量/电池消耗也是很重要的指标。

今天就来从我们测试人员的角度,谈一谈 App 验收测试过程中需要关注到一些指标项目:

  • 内存占用
  • CPU 占用
  • 流量耗用
  • 电量耗用
  • 启动时间

因为最近就着腾讯 TMQ 团队出版的《移动 App 性能评测与优化》这本书在看 App 性能测试这一块的东西,看着看着发现有些名词或者概念不是很明白,所以在看这块东西的时候,还要一边去查询一些其他的点,现在将这些点记录下来,也算是一篇读书笔记了吧,下面针对每一个方面的一些重要知识点进行了整理。

一. 内存

1. 内存泄漏

说到内存方面,最经典的内存问题当数内存泄漏。百度上对内存泄漏的定义是这样的:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。通俗点讲,在大部分应用中,会有一类功能是需要加载附加资源的,比如显示从网络下载的文本或图片。这类功能往往需要在内存中存放要使用的资源对象,退出该功能后,就需要将这些资源对象清空。如果忘了清理,或者是代码原因造成的清理无效,就会形成内存泄漏。

2. 垃圾回收

说到了内存泄漏,又不得不提到垃圾回收(Garbage Collector,简称 GC),内存中的垃圾,主要指的是内存中已无效但又无法自动释放的空间,除非是重启系统不然永远也不会还给操作系统。这样以来,时间久了当程序运行的时候就会产生很多垃圾,一方面浪费了不少内存空间,另一方面如果同一个内存地址被删除两次的话,程序就会不稳定,甚至奔溃。

在 Java 程序运行过程中,一个垃圾回收器会不定时地被唤起检查是否有不再被使用的对象,并释放它们占用的内存空间。但垃圾回收器的回收是随机的,可能在程序的运行的过程中,一次也没有启动,也可能启动很多次,它并不会因为程序一产生垃圾,就马上被唤起而自动回收垃圾。所以垃圾回收也并不能完全避免内存泄漏的问题。

另一方面,垃圾回收也会给系统资源带来额外的负担和时空开销。它被启动的几率越小,带来的负担的几率就越小。

3. 内存指标

内存指标有 VSS、RSS、PSS、USS,他们的含义分别是:

VSS:Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)

RSS:Resident Set Size 实际使用物理内存(包含共享库占用的内存)

PSS:Proportional Set Size 实际使用的物理内存(按比例分配共享库占用的内存)

USS:Unique Set Size 进程独自占用的物理内存(不包含共享库占用的内存)

一般来说内存占用大小有如下规律:VSS >= RSS >= PSS >= USS,一般测试中关注的比较多的是 PSS 这个指标。

4. 监控与分析工具

以下是几种常见的内存分析工具,具体使用方法这里就不详述了。

4.1 Memory Monitor

该工具位于 Android Monitor 下面,Android Monitor 是 Android Studio 自带的一个强大的性能分析工具,里面一共包含 5 个模块:Logcat、Memory、CPU、Network 及 GPU。

image

Memory Monitor 可以实时查看 App 的内存分配情况,判断 App 是否由于 GC 操作造成卡顿以及判断 App 的 Crash 是否是因为超出了内存。

4.2 Heap Viewer

该内存检测工具位于 DDMS 下面,在 Android Studio 里面可以通过 Tools-Android-Android Device Monitor 打开,Heap Viewer 可以实时查看 App 分配的内存大小和空闲内存大小,并且发现 Memory Leaks。

image

4.3 MAT

MAT(Memory Analyzer Tool),是一个被老生常谈的 Android 内存分析工具,它可以清楚的获知整体内存使用情况。虽然是 Eclipse 的工具,但也可以单独运行,不需要安装 Eclipse。

image

二. CPU

1. 时间片

时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。

2. Jiffies

2.1 Jiffies的概念

要讲 Jiffies 需要先提到这两个概念:HZ 和 Tick

HZ:Linux 核心每隔固定周期会发出 timer interrupt (IRQ 0),HZ 是用来定义每一秒有几次 timer interrupts。例如 HZ 为 1000,就代表每秒有 1000 次 timer interrupts。

Tick:HZ 的倒数,Tick = 1/HZ,即 timer interrupt 每发生一次中断的时间。如 HZ 为 250 时,tick 为 4 毫秒(millisecond)。

而 Jiffies 为 Linux 核心变量,是一个 unsigned long 类型的变量,被用来记录系统自开机以来,已经过了多少 tick。每发生一次 timer interrupt,Jiffies 变数会被加 1。

2.2 查看 Jiffies 的方法

Linux 下使用命令cat /proc/stat,查看具体整机的 Jiffies,如图:

image

Linux 下使用命令cat /proc/<进程id>/stat,查看具体某个进程的 Jiffies:

image

3. CPU 使用率

在 Linux 系统下,CPU 利用率分为用户态、系统态和空闲态,他们分别代表的含义为:用户态表示 CPU 处于用户态执行的时间,系统态表示系统内核执行的时间,空闲态表示空闲系统进程执行的时间。

而一个 App 的 CPU 使用率 = CPU 执行非系统空闲进程时间 / CPU 总的执行时间,也可以表示为 App 用户态 Jiffies + App 系统态 Jiffies / 手机总 Jiffies。

4. CPU 过高会带来的影响

可能会使整个手机无法响应,整体性能降低,引起 ANR,导致手机更耗电,降低用户体验等。

三. 流量

1. 定义

我们的手机通过运营商的网络访问 Internet,运营商替我们的手机转发数据报文,数据报文的总大小(字节数)即流量,数据报文是包含手机上下行的报文。

2. 常用流量测试方法

2.1 抓包测试法

主要是利用工具 Tcpdump 抓包,导出 pcap 文件,再在 wireshark 中打开进行分析。

2.2 统计测试法
2.2.1 读取 linux 流量统计文件

利用 Android 自身提供的 TCP 收发长度的统计功能,获取 App 的 tcp_snd 和 tcp_rcv 的值,测试一段时间后再分别统计一次,用 tcp_snd
两者的差值得到发送流量,用 tcp_rcv 两者的差值得到接受流量。

2.2.2 利用 Android 流量统计 API
  • TrafficStats

Android 2.2 版本开始加入 android.net.TrafficStats 类来实现对流量统计的操作。

部分方法如下:

static long getMobileRxBytes() //获取通过移动数据网络收到的字节总数 
static long getMobileTxBytes() //通过移动数据网发送的总字节数 
static long getTotalRxBytes() //获取设备总的接收字节数 
static long getTotalTxBytes() //获取设备总的发送字节数 
static long getUidRxBytes(int uid) //获取指定uid的接收字节数 
static long getUidTxBytes(int uid) //获取指定uid的发送字节数 

* NetworkStatsManager

Android 6.0 版本开始,为了打破了原本 TrafficStats 类的查询限制,官方又提供了 NetworkStatsManager 类,可以获取更精准的网络历史数据,也不再是设备重启以来的数据。部分方法如下:

 NetworkStats.Bucket querySummaryForDevice(int networkType, String subscriberId, long startTime, long endTime) // 查询指定网络类型在某时间间隔内的总的流量统计信息
 NetworkStats queryDetailsForUid(int networkType, String subscriberId, long startTime, long endTime, int uid) // 查询某uid在指定网络类型和时间间隔内的流量统计信息
 NetworkStats queryDetails(int networkType, String subscriberId, long startTime, long endTime) // 查询指定网络类型在某时间间隔内的详细的流量统计信息(包括每个uid)

四. 电量

1.耗电场景

  • 定位,尤其是调用 GPS 定位。
  • 网络传输,尤其是非 Wifi 环境。
  • 屏幕亮度
  • CPU 频率
  • 内存调度频度
  • wake_locker 时间和次数
  • 其他传感器

2.测试方法

2.1 通过系统文件获取电量记录

使用命令 adb shell dumpsys batterystats> batterystats.txt 可以打印出详细的耗电相关信息并保存统计的电量信息到 batterystats.txt 这个文件里。

image

2.2 通过导入 batterystats.txt 到 Google 开源工具 battery historian 进行分析

因为这个工具是 Go 语言开发,所以需要预装Go语言开发环境,当然如果你不想配置Go语言环境,官方还提供了一种更方便的方案,通过安装 docker 环境来使用这个工具。具体这个工具的配置安装和具体使用方法以及参数的代表含义,我会单独再写一篇文章记录,先抛砖引玉放一张这个工具的运行截图。

image

3.优化方法

3.1 CPU 时间片

当应用退到后台运行时,尽量减少应用的主动运行,当检测到 CPU 时间片消耗异常时,深入线程进行分析。

3.2 wake lock

前台运行时不要注册 wake lock。

后台运行时,在保证业务需要的前提下,应尽量减少注册 wake lock。

降低对系统的唤醒频率, 使用 partial wake lock 代替 wake lock。

3.3 传感器

合理设置 GPS 的使用时长和使用频率。

3.4 云省电策略

考虑到用户使用场景的多样性,导致很难定位用户异常耗电的根源,所以为了更深一层弄清楚这些问题,可以考虑定期上报灰度用户手机电量数据的方式来分析问题。

五. 启动时间

可使用命令 adb shell am start -W packagename/activity 查看 App 启动耗时,查看了一下我们自己的 App Android 版本的启动耗时如下:

image

注释:

WaitTime:总的耗时,包括前一个应用 Activity pause 的时间和新应用启动的时间

ThisTime:一连串启动 Activity 的最后一个 Activity 的启动耗时

TotalTime:新应用启动的耗时,包括新进程的启动和 Activity 的启动,但不包括前一个应用 Activity pause 的耗时

六. 总结

App性能问题会直接影响产品体验:耗流量、掉电快、卡顿、崩溃等现象会给用户造成不良的体验和印象,不利于产品的活跃及用户留存。许多经验丰富的程序员也会经常忽视这些不起眼的性能问题,因此作为测试人员,在版本发布前及早关注并发现上述性能问题就显得尤其重要。但真正做起App性能测试才发现这条路并不容易走,抓取内存、CPU 等一些项目的指标容易,但对一些数据的敏感度和处理方式都是要靠经验的慢慢积累。总之,性能测试算是一项比较繁琐的工作,但难者不易,易者不难,希望已经行走在这条路上的或者准备踏上这条路的同行都能不断提高自身素养,坚持到底。

参考

0

Android 系统图片编辑的原理与实现——涂鸦与马赛克

图片编辑:涂鸦与马赛克

相信大家都用过微信的图片编辑功能,非常有用,例如发送图片前可以画上一些标记,或者把隐私信息涂上马赛克。最近在杏仁医生 APP 上,我们也添加了类似功能。今天就来讲讲其中的涂鸦和马赛克的原理与实现。下图就是我们最终的实现效果。

涂鸦&马赛克

基本概念

在讲具体的实现之前,先来看一下图片编辑功能中用到的一些基本概念。了解这些对后续一些复杂计算的理解有一定的帮助。

坐标系

  1. 触屏坐标系
    这里一般会使用 MotionEvent 中的 getX()getY(),这里的坐标值就是相对于当前控件显示区域的相对坐标,不论当前控件如何显示,如
    scrollXscrollY 的值发生变化后,也不会影响触屏坐标。不过有一点就是控件如果旋转(setRotation)或者平移(setTranslation)后,坐标原点位置也是会跟着变化的。

  2. 画布坐标系
    画布坐标可以简单计算得到,当前的坐标原点就是 (-scrollX, -scrollY),这样可以通过 View 的 scrollToscrollBy 方便地实现平移效果。

图片位置

图片的位置是基于画布坐标系的,并使用一个 RectF 对象表示(下面使用 mFrame 表示),它可以表示出图片的左上角和右下角坐标,因此 mFrame
不仅表示了图片的坐标位置,还表示的图片的缩放程度。关于图片展示,缩放以及平移等操作,可以参考我写过的一个大图预览的库:IntensifyImageView

// 当前图片矩形
RectF mFrame; 
// 原始图片矩形
RectF mOriginalFrame; 
// 当前图片的缩放值
float scale = mFrame.getWidth() / mOriginalFrame.getWidth();

绘制的时候只需要将Bitmap对象绘制到mFrame矩形中即可,对图像的缩放及平移操作全部转化到了对mFrame矩形的操作。

// 绘制图像
canvas.drawBitmap(bitmap, null, mFrame, null);

图像的缩放

图片缩放会涉及到两个坐标系,手势触摸得到缩放值及缩放中心,如 (focusX, focusY, factor),然后转换成画布坐标 (focusX + scrollX, focusY + scrollY, factor),再根据这个坐标及缩放值计算 mFrame,如下:

Matrix m = new Matrix();
m.setScale(factor, factor, newFocusX, newFocusY);
m.mapRect(mFrame);

如上可以看出,缩放会影响到图像的画布坐标。

图像的平移

平移可以使用两个接口:

scrollTo(x, y);

// 还是借助scrollTo实现的
scrollBy(dx, dy);

图像的平移不会影响图片的画布位置,当前控件的视图窗口会发生变化,也就是 scrollXscrollY 的值发生变化。

涂鸦与马赛克

实现要点

  • 涂鸦
    • 画笔颜色可变(几种常用颜色)
    • 画笔粗细始终保持一致
    • 路径过渡平滑及两端圆角
  • 马赛克
    • 画笔粗细在当前缩放状态下保持一致
    • 路径过渡平滑及两端圆角

原理分析

这两个功能点其实是有很大的重合部分的,即都是绘制出一个路径,涂鸦绘制纯色路径,而马赛克绘制处理后的图片路径。

手势路径这个很简单,Java 中的 Path 想必大家都很了解,大致绘制记录代码如下:

switch(event.getActionMasked()) {
    case MotionEvent.ACTION_DOWN:
        path.reset();
        path.moveTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_MOVE:
        path.lineTo(event.getX(), event.getY());
        break;
    case MotionEvent.ACTION_UP:
        // 添加到Path列表中
        break;  
}

但是涂鸦和马赛克的实现原理略有不同,下面我们来详细看下。

涂鸦实现

上面的分析看似很是简单,但是必须还要解决好以下问题:

  • 当前绘制的路径要以正确的尺寸及位置绘制到界面上(手势坐标系)
  • 已绘制的路径要随着图层滚动缩放等正确变化(画布坐标系)

那么为什么会有这两个问题呢?因为图片编辑从一开始的设计思路几乎是紧贴微信的,背景图层是可以随时缩放移动的(和其他图片编辑有些区别,很多是不可以在编辑时缩放移动的),因此绘制计算难度大增。

先看下 Path 和图层间的关系(Path 使用的是控件坐标,因此如果不经变化直接绘制就会出现如下情况):

涂鸦

当前 Path 绘制到屏幕上需要缩放当前画笔粗细度,反向旋转当前画布(旋转角度为负的当前旋转度),再平移到当前的滚动偏移值:

mDoodlePaint.setColor(mPen.getColor());
mDoodlePaint.setStrokeWidth(IMGPath.BASE_DOODLE_WIDTH * mImage.getScale());

canvas.save();
RectF frame = mImage.getClipFrame();
canvas.rotate(-mImage.getRotate(), frame.centerX(), frame.centerY());
canvas.translate(mView.getScrollX(), mView.getScrollY());
canvas.drawPath(mPen.getPath(), mDoodlePaint);
canvas.restore();

这里是唯一不需要的就是缩放,因为当前情况我需要绘制的就是这么大小的尺寸。

那么已经绘制的路径为何处理方式不同呢?

应为绘制完成后我可以对图层进行缩放操作,而已经绘制过的路径是需要与绘制时的图层保持相对位置大小不变的,也就是跟着缩放。

对路径缩放的方法有如下:

Matrix matrix = new Matrix();
matrix.setScale(sx, sy, px, py);
path.transform(matrix);

这样可以,但是效率受到了极大的影响,是因为我每次手势缩放过程中的几十次连续的缩放值的变化都要对已经加入的路径进行如上缩放操作,而且每个路径的实际缩放值还是不同的。

解决办法是将已完成绘制的路径缩放平移旋转到原始坐标中,这样每次绘制时都是统一缩放平移旋转一次画布,如此一来所需要的代价仅仅是绘制一个路径。

具体效果图如下:

涂鸦

public void addPath(IMGPath path, float sx, float sy) {
    if (path == null) return;

    float scale = 1f / getScale();

    M.setTranslate(sx, sy);
    M.postRotate(-getRotate(), mClipFrame.centerX(), mClipFrame.centerY());
    M.postTranslate(-mFrame.left, -mFrame.top);
    M.postScale(scale, scale);
    path.transform(M);

    switch (path.getMode()) {
        case DOODLE:
            mDoodles.add(path);
            break;
        case MOSAIC:
            path.setWidth(path.getWidth() * scale);
            mMosaics.add(path);
            break;
    }
}

当需要绘制这一系列路径时如下(旋转操作在绘制最初已设置):

public void onDrawDoodles(Canvas canvas) {
    if (!isDoodleEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mDoodles) {
            path.onDrawDoodle(canvas, mPaint);
        }
        canvas.restore();
    }
}

马赛克也是类似的路径绘制问题,下面再具体分析并实现。

马赛克实现

马赛克路径的确定和涂鸦基本一致,不同的在于马赛克的路径宽度是不同的,当前绘制时的马赛克宽度永远是一个值,因此缩放后再绘制所形成的马赛克路径粗细各不相同,所以每个马赛克路径需要额外记录一下路径宽度(涂鸦需要记录颜色值)。

那么每个路径画出的马赛克是如和形成的呢?其实很简单,马赛克就是将整个区域的颜色变成一个颜色值,如将 10x10 区域内的颜色变成其中的一个颜色值,所以我们将一张图片缩放到一个较小的尺寸,然后再放大到原始尺寸去显示,这个图片就很模糊了,然后关闭 Paint 的滤波功能
paint.setFilterBitmap(false),这样就得到了一个图片的马赛克效果,如下:

马赛克

所以为什么要将一整张图变成马赛克呢?可以用一张图简单表示如下:

马赛克

将马赛克路径图层与马赛克图层合并显示即可。也就是 Paint 的如下功能:
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

关键代码如下:

private void makeMosaicBitmap() {
    if (mMosaicImage != null || mImage == null) {
        return;
    }

    if (mMode == IMGMode.MOSAIC) {

        int w = Math.round(mImage.getWidth() / 64f);
        int h = Math.round(mImage.getHeight() / 64f);

        w = Math.max(w, 8);
        h = Math.max(h, 8);

        // 马赛克画刷
        if (mMosaicPaint == null) {
            mMosaicPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mMosaicPaint.setFilterBitmap(false);
            mMosaicPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        }

        mMosaicImage = Bitmap.createScaledBitmap(mImage, w, h, false);
    }
}

从原图创建一个马赛克位图,绘制时分两段,分别是绘制马赛克路径图层和绘制马赛克图层(当前马赛克路径和涂鸦的当前路径绘制逻辑基本一致,这里就不放出了):

public int onDrawMosaicsPath(Canvas canvas) {
    int layerCount = canvas.saveLayer(mFrame, null, Canvas.ALL_SAVE_FLAG);

    if (!isMosaicEmpty()) {
        canvas.save();
        float scale = getScale();
        canvas.translate(mFrame.left, mFrame.top);
        canvas.scale(scale, scale);
        for (IMGPath path : mMosaics) {
            path.onDrawMosaic(canvas, mPaint);
        }
        canvas.restore();
    }

    return layerCount;
}

public void onDrawMosaic(Canvas canvas, int layerCount) {
    canvas.drawBitmap(mMosaicImage, null, mFrame, mMosaicPaint);
    canvas.restoreToCount(layerCount);
}

马赛克路径的加入和涂鸦也是一致的,都统一在上面提到的 addPath 方法中完成了,也是需要缩放旋转平移到原始坐标中去的。

至此涂鸦和马赛克核心编码思想已经全部出现出,部分细节在与贴片和裁剪等功能结合时可能略微调整。

结语

我已经把本文提到的图片编辑功能抽取成独立的类库并开源,大家如果有什么建议,欢迎和我讨论,一起来优化它。Github 的地址是:https://github.com/kareluo/Imaging

0

聊聊移动端跨平台数据库 Realm

开发杏仁 App 的过程中,我们在相对独立的模块试水了当前非常流行的移动端数据库:Realm,有挑战也有惊喜。下面以 iOS(Object-C) 平台为例,简单介绍下 Realm 的基本使用,并且总结下心得。

什么是Realm

Realm

Realm 是一个针对移动端开发的、跨平台、跨语言数据存储方案。它上手方便,性能强大,功能丰富而且还在不断更新。Realm 在语言上支持 JavaJS.NETSwiftOC,基本覆盖了当前移动端的所有场景。

目前,Realm 已经完全开源,并且有很多三方的插件可以使用,生态已经相对比较成熟了。

配置

Realm 的配置比较简单,升级和数据迁移都很直观,不过需要注意:

  • 每次对数据库表有更新都必须手动增加版本号,不然会闪退。
  • 升级表(增加、删除字段或表)不需要手写迁移代码;如果有数据迁移、修改字段名、合并字段、数据升级等高级操作,则在 block 中写相关代码,具体可参照文档。
  • 一般来说要写全递归升级的所有版本分支,做好每个版本的清理工作,以免发生意外(比如版本2比版本1删除了字段A,版本3又添加回来,用户如果直接从版本1升级到版本3,则会有脏数据)。

启动Realm的基本流程:

    // 1.配置数据库文件地址
    NSString *filePath = path;

    RLMRealmConfiguration *config = [RLMRealmConfiguration defaultConfiguration];
    config.fileURL = [[[NSURL URLWithString:filePath] URLByAppendingPathComponent:@"YF"] URLByAppendingPathExtension:@"realm"];

    // 2. 加密
    config.encryptionKey = [self getKey];

    // 3. 设置版本号(每次发布都应该增加版本号)
    config.schemaVersion = 1;

    // 4. 数据迁移
    config.migrationBlock = ^(RLMMigration *migration, uint64_t oldSchemaVersion) {
        if (oldSchemaVersion < 1) {
            // do something
        }
    };

    // 5. 设置配置选项
    [RLMRealmConfiguration setDefaultConfiguration:config];

    // 6. 启动数据库,完成相关的配置
    [RLMRealm defaultRealm];

建表

直接继承 RLMObject 的类将会自动创建一张表,表项为这个类的属性。需要注意几点:

  • Number,BOOL 等类型要用 Realm 指定的格式。
  • Array 要用 RLMArray 容器,来表示一对多的关系。
  • 没有入库前,可以像正常 Object 一样操作;但是入库后或者是 query 出来的对象,则要按照 Realm 的规范处理。这点是刚上手时需要特别注意的。

一个典型的 Realm 对象:

@interface YFRecommendHistoryObject : RLMObject

@property (nonatomic) NSNumber<RLMInt> *recommendId;
@property (nonatomic) NSNumber<RLMInt> *patientId;

@property (nonatomic) NSString *patientName;
@property (nonatomic) NSNumber<RLMInt> *created;

@property (nonatomic) NSString *createdTimeString; // yyyy-MM-dd hh:mm


@property (nonatomic) NSNumber<RLMInt> *count;
@property (nonatomic) NSNumber<RLMInt> *totalPrice; ///< 总价,分


@property (nonatomic) NSString *totalPriceString; /// ¥xxxx.xx
@property (nonatomic) RLMArray<YFRecommendDrugItem> *items;

@end

所有被 Realm 管理的对象都是线程不安全的,绝对不可以跨线程访问对象,也不要将 Realm 对象作为参数传递。推荐的做法是每个线程甚至是每次需要访问对象的时候都重新 query。

PS:最新版本的 Realm 提供了一些跨线程传递对象的途径(RLMThreadSafeReference)。

增删查改

入库:

// 创建对象
Person *author = [[Person alloc] init];
author.name    = @"David Foster Wallace";

// 获取到Realm对象
RLMRealm *realm = [RLMRealm defaultRealm];

// 入库
[realm beginWriteTransaction];
[realm addObject:author];
[realm commitWriteTransaction];

另外对一对一关系或者一对多关系的赋值也相当于入库。

对被管理对象的所有赋值,删除等操作都必须放到 begin-end 对当中,否则就是非法操作,直接闪退。

// 删除对象
[realm beginWriteTransaction];
[realm deleteObject:cheeseBook];
[realm commitWriteTransaction];

需要注意的是如果一个对象从数据库中被删除了,那么它就是非法对象了,对其的任何访问都会导致异常。

查询的话语法和 NSPredicate类似。

// 使用 query string 来查询
RLMResults<Dog *> *tanDogs = [Dog objectsWhere:@"color = 'tan' AND name BEGINSWITH 'B'"];

// 使用 Cocoa 的 NSPredicate 对象来查询
NSPredicate *pred = [NSPredicate predicateWithFormat:@"color = %@ AND name BEGINSWITH %@",
                                                     @"tan", @"B"];
tanDogs = [Dog objectsWithPredicate:pred];

通知

不要手动去管理数据库对象更新的通知,Realm 会自动在 Runloop 中同步各个线程的数据,但是同步的时机是无法预料的,应该使用 Realm 框架自带的通知系统。

对集合对象的通知:

self.notificationToken = [[Person objectsWhere:@"age > 5"] addNotificationBlock:^(RLMResults<Person *> *results, RLMCollectionChange *changes, NSError *error) {
    if (error) {
      NSLog(@"Failed to open Realm on background worker: %@", error);
      return;
    }

    UITableView *tableView = weakSelf.tableView;

    // 在回调中更新相关UI
    [tableView beginUpdates];
    [tableView deleteRowsAtIndexPaths:[changes deletionsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView insertRowsAtIndexPaths:[changes insertionsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView reloadRowsAtIndexPaths:[changes modificationsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView endUpdates];
  }];

Realm 在 OC 中实现响应式主要靠这种方式。在其它语言平台上(JS,JAVA,Swift),Realm 对响应式编程的支持要更好一些。

对普通 object 的通知:

RLMStepCounter *counter = [[RLMStepCounter alloc] init];
counter.steps = 0;
RLMRealm *realm = [RLMRealm defaultRealm];
[realm beginWriteTransaction];
[realm addObject:counter];
[realm commitWriteTransaction];

__block RLMNotificationToken *token = [counter addNotificationBlock:^(BOOL deleted,
                                                                      NSArray<RLMPropertyChange *> *changes,
                                                                      NSError *error) {
    if (deleted) {
        NSLog(@"The object was deleted.");
    } else if (error) {
        NSLog(@"An error occurred: %@", error);
    } else {
        for (RLMPropertyChange *property in changes) {
            if ([property.name isEqualToString:@"steps"] && [property.value integerValue] > 1000) {
                NSLog(@"Congratulations, you've exceeded 1000 steps.");
                [token stop];
                token = nil;
            }
        }

    }
}];

关于通知的使用,有两点需要注意:

  • 必须保持对 token 的引用,token 被释放,则通知失效(在被释放前最好按规范 stop 掉 token)。
  • object 通知并不监听得到 object 的 RLMArray 等集合属性成员的变化,需要另外处理。

一些总结

Realm 真的好用吗?相对于陈旧的 sqlite 和学习曲线陡峭的 CoreData,Realm 还是一个不错的选择。但是就目前的版本来说,还存在一些局限性。

先说说优点:

  1. 简单:光速上手(然后光速踩坑,APP 狂闪不止)。这个确实是小团队福音,不需要学习曲线陡峭的 CoreData,甚至不用写 sql,大家简单阅读下文档,就可以在实际项目中开用了。升级、迁移等都有非常成熟的接口。

  2. 性能优秀:简单看过原理,相比于传统数据库链接 - 查询 - 命中 - 内存拷贝 - 对象序列化的复杂过程,Realm 采用基于内存映射的 Zero-Copy 技术,速度快一个数量级。而且内部采用了类似 git 的对象版本管理机制,并发的性能和安全性也不错。

  3. 线程安全:Realm 拒绝跨线程访问对象,同时,在不同线程中进行增删查改都是绝对安全的。安全的代价是代码上的不便,下面会讲。

  4. 跨平台:iOS 和安卓两边可以共用一套存储方案,Realm 数据库则方便地在不同平台中进行迁移。对于 RN 开发来说,Realm更是数据库方案的首选,真正做到 write once run everywhere。

  5. 响应式:Realm 的查询结果是随数据库变化实时更新的(要求对象在 Run Loop 线程中),配合KVO或者Realm 自带的 ObjectNotification,可以轻松构建即时反映数据变化的响应式 UI,这点和目前主流的响应式框架(ReactNative,ReactiveCocoa)应该是天作之和了。但是要求使用者改变思路,不然会出现很多诡异的 bug。

  6. ORM:虽然 Realm 自己号称是『为移动开发者定制的全功能数据库』,但是其中确实包含 ORM 的很多特性,不用手动写中间层了,取出来就是新鲜活泼的对象,everyone is happy。

再说说坑:

  1. 无法多线程共享数据库对象:这是 Realm 设计的要求,跨线程访问的话,只能自己重新 query 出来。前面说的上手快,踩坑也快,就是因为这个:异步的 Block、网络接口的回调、从不同线程发出的通知都会触发这个访问异常。ORM 出来的是对象最关键的还是思维方式的转换,虽然是 ORM,但毕竟还是是个数据库,该 query 的地方,还是不要偷懒。

  2. 数据库对象管理:这里有很多坑,比如访问一个被删除的对象时,会直接异常;数据库被 close 后,所有查询出的 object 都无法使用;修改被管理对象属性,必须在指定 block 或者数据库事物集中完成,相当于入库。而 Realm 对象和普通对象是没有任何区别的,所以使用 Realm 的一个重要原则是:不要将数据库对象作为参数传递

  3. 对代码的侵蚀严重:所有的的数据库对象要继承指定的类(没法继承自己的基类了),增删改查,对查询结果处理都有特殊的语法要求,这使得在旧项目中引入 Realm 或者放弃使用将 Realm 从项目中剥离都面临很大的成本。

  4. 静态库大、版本尚未稳定:引入这样一个三方静态库会增加 App 体积,目测大了 1M 至少了。另外 Realm 目前还不是很稳定,之前测试新出的 ObjectNotification 功能,居然会出现偶尔拿不到回调的 bug,相比于成熟的 sqlite 方案,还不是很放心。

以上介绍了下 Realm Database 的基本情况,主要场景是移动端的本地存储。其实 Realm 还有提供 Realm Platform 产品,提供移动响应式后台解决方案,有兴趣的团队可以了解下。

总而言之,Realm 还是一个值得尝试的存储方案。如果你追求快速部署、优秀前卫的特性、以及跨平台,Realm 是你的首选;如果你追求稳定,而已有的项目庞大成熟,可以选择暂时观望技术的新进展,谨慎选择。

3+

喜欢该文章的用户:

  • avatar

Web 与 App 数据交互原理和实现

背景

点击图片查看大图已经成了用户浏览页面时的一种习惯,原生 App 往往都实现了这些图片处理功能(点击查看、缩放、保存、滑动浏览等)。对于 Web 页面,为了更好的体验,一般开发者都会自己实现或是使用三方的图片处理框架。但如果一个 Web 页面能在一些特定的原生 App 中打开,那完全可以让 App 去代理处理这些图片。毕竟原生 App 的体验会更好,而且同一个 App 内点击原生图片和 Web 里面的图片,体验应该是一致的。

所以需求很简单,就是Web 页能直接调用原生的图片显示功能嘛!

交互原理

这个需求背后要解决的问题,实际上是要通过 Web 与原生的交互,让 Web 把图片资源交给原生应用去处理。

iOS7 之后苹果推出 JSCore,通过获取 Web 上下文环境,实现了 App 可以直接调用 JS 方法,或者将 block 赋值给 JS 方法来实现 Web 调用 App 并传值。所以在不考虑安卓的情况下,可以通过 JSCore 实现 Web 与 App 交互。

JSCore

App 端

// 获取JS上下文
 JSContext *jsContext;
-(void)WebViewDidFinishLoad:(UIWebView *)webView {
  jsContext = [_webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
}

// App 调 Web insertText 事件
JSValue *funcValue = jsContext[@"insertText"];
[funcValue callWithArguments:@[@"hello web!"]];

// Web 调 App,App注册事件名为callNative
jsContext[@"callNative"] = ^(NSString*str){
  NSLog(str);
};

Web 端

// 接收 App 调用
function insertText(text) {
  ...
}

// 调用 App
btnNode.onclick = () => {
  callNative('hello app!');
}

这种方式使用简单,但大多数情况需要兼容 iOS 与安卓,所以需要找到更合适的方式。

传统方式

苹果推出 JSCore 以前,App 调用 Web 只能通过 WebView 执行 JSString 来实现。而 Web 没法直接调用 App,只能触发特定链接,让 App 在 WebView 代理方法中捕获到这特定链接,从而执行相应操作,间接实现 Web 调 App。这种传统方式也适用于安卓端的的实现。

App 调用 Web

[_webView stringByEvaluatingJavaScriptFromString:jsString];

这里的 JSString 是一个 JS 方法的调用,这个方法能给 Web 传值的前提是在 Web 端定义一个全局方法如:

function handlerMessageFromApp(data) {
  ...
}

那 App 端需要去拼接出这个 JSString 然后再调用 Webview 的 stringByEvaluatingJavaScriptFromString 方法:

NSString* jsString = [NSString stringWithFormat:@"handlerMessageFromApp('%@');", messageJSON];

[_webView stringByEvaluatingJavaScriptFromString:jsString];

App 调用 Web 就是利用这种 Webview 去执行一个 JS 方法的方式去实现。JS 方法可以直接返回值给 App,但通常情况下 Web 端收到 App 的消息会去进行一些异步操作,这样在 handlerMessageFromApp 中直接返回就不太合适了。此时需要有另外一个流程去保证 Web 端把消息传递给 App,也就是下面会说的 Web 调 App。

Web 调用 App

Web 调 App 则需要双方事先沟通定好协议。比如如果要点击 Web 页面链接跳转到 App 主页,那可以将协议的名称定为 xr://home,HTML 内容为 <a href="xr://home">查看主页>></a>,点击链接就会发生 url 的改变,同时原生 WebView 的代理方法也会被触发。通过在代理方法中监听 url 的变化实现约定的协议。

// 在 webview 代理方法中处理
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    NSURL *url = [request URL];
    if  (url == "xr://home") {...}
}

如果基于这种方式让 Web 给 App 传值可以有两种方式:
1. 直接在协议后面拼接参数如 xr://message?name=carson,这种方式适合于简单的值传递,但对于复杂结构的数据传递这种方式不合适。
2. Web 端 与 App 端定好消息协议如:xr://message,Web 端要发送消息给 App 端时触发该协议,同时将要传递的值放在页面上,App 端监听到该协议变化时从 Web 页面上取值。这种方式适合传递复杂数据。

现实开发中的传值往往都是复杂结构的,所以我们选择第2种方式去完成 Web 端调用 App 端。

function getMessageFromWeb() {
  return messageObject;
}

基于前面的经验在 Web 端实现方法 getMessageFromWeb,这个方法负责返回要传递给 App 的值。

id messages = [_webView stringByEvaluatingJavaScriptFromString:@"getMessageFromWeb()"];

当特定的消息协议被触发时 App 通过 Webview 执行带有返回值的 JS 方法拿到数据,这就间接实现 Web 调用 App 并传值的原理。

但这种方式 Web 端每次调用时都需要触发特定协议,同时将全局变量 messageObject 赋值。我们往往希望有一个抽象层来做这些事情,每当调用的时候作为调用方最好是能一个方法传递消息名称与消息内容就行了,接收方也只需要按消息名称接收消息内容。Web 端与 App 端都应该具备这样一个抽象层,这样具体的两端交互就简化成了一端只管调用另一端只管接收。有了这样一个抽象层再来看 Web 端调 App 端就好比:

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。

这样一个单向的 W 把消息发送给 A 就完成了,原理还是基于前面 Web 调用 App 的原理。因为有 B 的存在此时 W 要做的事情少了很多。

完整的调用

上面的过程只是最简单的情况,是一个单向的,正常情况下 W 给 A 送了包糖或许还想知道 A 什么时候吃完,吃完了 W 可以再去买。

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。
  4. A 吃完糖后再通知到 B 说他吃完了。
  5. B 最后通知到 W,W 再去买糖。

步骤4与步骤5代表 App 回调 Web。W 最后再买糖相当于回调函数的实现,这是在 W 送糖的时候就已经决定的事情。 因为整个一去一回的过程并非同步,所以这个地方就需要处理好这样一个映射:消息 —> 回调处理函数。也就是说每条发送的消息对应上各自的回调处理函数,这就需要 B 去维护这样一个映射关系。B 给要发送的消息加上 callbackId,同时以 { callbackId: callbackHandler } 的方式将回调处理函数存储起来。A 收到了有 callbackId 的消息再响应时又回继续带上这个 callbackId,最后 B 按照 callbackId 找到回调处理函数去执行。这样才是一个完整的带有回调处理的 Web 调用 App 并传值。

所以关键任务是去实现 B 这样一个抽象层,B 的任务很重,它是一个 Web 端与 App 端交互的桥梁,它要具备发送消息提示、存储消息内容、存储消息回调、 接收消息、执行消息回调等功能。

实现这样一个抽象层并不算太难,但重复造轮子的事就不做了,前面描述的过程也正是参照第三方库 WebViewJavascriptBridge 的实现,B 也正是这个框架的核心 bridge,其整个实现基于观察者模式,在最基本的原理上加上了一些封装,抽象出一层 bridge 负责两端的交互,最终暴露给开发者的只有简单的 API(初始化、注册、调用)。

正如 bridge 这个名字一样,它起着桥梁作用,实现了两端的数据交互。这两个 bridge 基本实现了相同的功能,唯一的区别在于 Web 端这边的 bridge 没法直接发送消息内容,只能告诉 App 端有消息。

应用

回到最开始的需求,还是在 WebViewJavascriptBridge 的基础上去实现让 App 去处理 Web 里的图片。做过图片浏览的应该都知道,实现图片浏览需要提供所有图片的数组 array 以及当前点击图片的 index。所以给 img 标签绑定点击事件,再获取所有图片的数组,最后利用 JSBridge 传给 App 即可。至于第三方库的接入可以去查看官方文档。

App 端注册 handler,用于处理接收图片:

[self.bridge registerHandler:@"previewImage" handler:^(id data, WVJBResponseCallback responseCallback) {
    // 处理图片 data
}]

相应的 Web 端要去 callHandler:

// setup 去触发 App 将 bridge 注入 window
setupWebViewJavascriptBridge(function(bridge) { 
  bridge.callHandler('previewImage', { urls: [ /* ... */ ], index: 0 });
}

就这么简单的一端管接收另一端管调用就完成了 Web 将图片传给原生。
熟悉微信网页开发的应该知道微信也提供了相关功能,微信中的网页可以利用微信的 JSSDK 使用原生提供的一整套图片处理功能:相册、相机、裁剪、上传、下载、图片浏览...


App 与 Web 交互的场景还有很多,比如 Web 页自定义分享、控制 App 页面跳转,随着小程序的出现 Web 端与 App 的交互也是更加有了深度。总之原理不难,掌握了原理才能做出更好更多的事情。

1+