iOS 下的图片处理与性能优化

移动开发中我们经常和多媒体数据打交道,对这些数据的解析往往需要耗费大量资源,属于常见的性能瓶颈。

本文针对多媒体数据的一种———图片,介绍下图片的常见格式,它们如何在移动平台上被传输、存储和展示,以及优化图片显示性能的一种方法:强制子线程解码。

图片在计算机世界中怎样被存储和表示?

图片和其他所有资源一样,在内存中本质上都是0和1的二进制数据,计算机需要将这些原始内容渲染成人眼能观察的图片,反过来,也需要将图片以合适的形式保存在存储器或者在网络上传送。

下面是一张图片在硬盘中的原始十六进制表示:

一张图片在计算机中的原始数据

这种将图片以某种规则进行二进制编码的方式,就是图片的格式。

常见的图片格式

图片的格式有很多种,除了我们熟知的 JPG、PNG、GIF,还有Webp,BMP,TIFF,CDR 等等几十种,用于不同的场景或平台。

图片的常见格式

这些格式可以分为两大类:有损压缩无损压缩

有损压缩:相较于颜色,人眼对光线亮度信息更为敏感,基于此,通过合并图片中的颜色信息,保留亮度信息,可以在尽量不影响图片观感的前提下减少存储体积。顾名思义,这样压缩后的图片将会永久损失一些细节。最典型的有损压缩格式是 jpg。

不同压缩比例的jpg图片

无损压缩:和有损压缩不同,无损压缩不会损失图片细节。它降低图片体积的方式是通过索引,对图片中不同的颜色特征建立索引表,减少了重复的颜色数据,从而达到压缩的效果。常见的无损压缩格式是 png,gif。

除了上述提到的格式,有必要再简单介绍下 webpbitmap这两种格式:

Webp:jpg 作为主流的网络图片标准可以向上追溯到九十年代初期,已经十分古老了。所以谷歌公司推出了Webp标准意图替代陈旧的jpg,以加快网络图片的加载速度,提高图片压缩质量。

webp 同时支持有损和无损两种压缩方式,压缩率也很高,无损压缩后的 webp 比 png 少了45%的体积,相同质量的 webp 和 jpg,前者也能节省一半的流量。同时 webp 还支持动图,可谓图片压缩格式的集大成者。

webp的体积对比

webp 的缺点是浏览器和移动端支持还不是很完善,我们需要引入谷歌的 libwebp 框架,编解码也会消耗相对更多的资源。

bitmap:bitmap 又叫位图文件,它是一种非压缩的图片格式,所以体积非常大。所谓的非压缩,就是图片每个像素的原始信息在存储器中依次排列,一张典型的1920*1080像素的 bitmap 图片,每个像素由 RGBA 四个字节表示颜色,那么它的体积就是 1920 * 1080 * 4 = 1012.5kb。

由于 bitmap 简单顺序存储图片的像素信息,它可以不经过解码就直接被渲染到 UI 上。实际上,其它格式的图片一般都需要先被首先解码为 bitmap,然后才能渲染到界面上。

如何判断图片的格式?

在一些场景中,我们需要手动去判断图片数据的格式,以进行不同的处理。一般来说,只要拿到原始的二进制数据,根据不同压缩格式的编码特征,就可以进行简单的分类了。以下是一些图片框架的常用实现,可以复制使用:

+ (XRImageFormat)imageFormatForImageData:(nullable NSData *)data {
    if (!data) {
        return XRImageFormatUndefined;
    }

    uint8_t c;
    [data getBytes:&c length:1];
    switch (c) {
        case 0xFF:
            return XRImageFormatJPEG;
        case 0x89:
            return XRImageFormatPNG;
        case 0x47:
            return XRImageFormatGIF;
        case 0x49:
        case 0x4D:
            return XRImageFormatTIFF;
        case 0x52:

            if (data.length < 12) {
                return XRImageFormatUndefined;
            }

            NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
            if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
                return XRImageFormatWebP;
            }
    }
    return XRImageFormatUndefined;
}

UIImageView 的性能瓶颈

如上文所说,大部分格式的图片,都需要被首先解码为bitmap,然后才能渲染到UI上。

UIImageView 显示图片,也有类似的过程。实际上,一张图片从在文件系统中,到被显示到 UIImageView,会经历以下几个步骤:

  1. 分配内存缓冲区和其它资源。
  2. 从磁盘拷贝数据到内核缓冲区
  3. 从内核缓冲区复制数据到用户空间
  4. 生成UIImageView,把图像数据赋值给UIImageView
  5. 将压缩的图片数据,解码为位图数据(bitmap),如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
  6. CATransaction捕获到UIImageView layer树的变化,主线程Runloop提交CATransaction,开始进行图像渲染
  7. GPU处理位图数据,进行渲染。

由于 UIKit 的封装性,这些细节不会直接对开发者展示。实际上,当我们调用[UIImage imageNamed:@"xxx"]后,UIImage 中存储的是未解码的图片,而调用 [UIImageView setImage:image]后,会在主线程进行图片的解码工作并且将图片显示到 UI 上,这时候,UIImage 中存储的是解码后的 bitmap 数据。

而图片的解压缩是一个非常消耗 CPU 资源的工作,如果我们有大量的图片需要展示到列表中,将会大大拖慢系统的响应速度,降低运行帧率。这就是 UIImageView 的一个性能瓶颈。

解决性能瓶颈:强制解码

如果 UIImage 中存储的是已经解码后的数据,速度就会快很多,所以优化的思路就是:在子线程中对图片原始数据进行强制解码,再将解码后的图片抛回主线程继续使用,从而提高主线程的响应速度。

我们需要使用的工具是 Core Graphics 框架的 CGBitmapContextCreate 方法和相关的绘制函数。总体的步骤是:

A. 创建一个指定大小和格式的 bitmap context。
B. 将未解码图片写入到这个 context 中,这个过程包含了强制解码
C. 从这个 context 中创建新的 UIImage 对象,返回。

下面是 SDWebImage 实现的核心代码,编号对应的解析在下文中:

// 1.
CGImageRef imageRef = image.CGImage;

// 2.
CGColorSpaceRef colorspaceRef = [UIImage colorSpaceForImageRef:imageRef];

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// 3.
size_t bytesPerRow = 4 * width;

// 4.
CGContextRef context = CGBitmapContextCreate(NULL,
                                             width,
                                             height,
                                             kBitsPerComponent,
                                             bytesPerRow,
                                             colorspaceRef,
                                             kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
if (context == NULL) {
    return image;
}

// 5.
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 6.
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 7.
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
                                        scale:image.scale
                                  orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

return newImage;

对上述代码的解析:

1、从 UIImage 对象中获取 CGImageRef 的引用。这两个结构是苹果在不同层级上对图片的表示方式,UIImage 属于 UIKit,是 UI 层级图片的抽象,用于图片的展示;CGImageRef 是 QuartzCore 中的一个结构体指针,用C语言编写,用来创建像素位图,可以通过操作存储的像素位来编辑图片。这两种结构可以方便的互转:

// CGImageRef 转换成 UIImage
CGImageRef imageRef = CGBitmapContextCreateImage(context);
UIImage *image = [UIImage imageWithCGImage:imageRef];

// UIImage 转换成 CGImageRef
UIImage *image=[UIImage imageNamed:@"xxx"];
CGImageRef imageRef=loadImage.CGImage;

2、调用 UIImage 的 +colorSpaceForImageRef: 方法来获取原始图片的颜色空间参数。

什么叫颜色空间呢,就是对相同颜色数值的解释方式,比如说一个像素的数据是(FF0000FF),在 RGBA 颜色空间中,会被解释为红色,而在 BGRA 颜色空间中,则会被解释为蓝色。所以我们需要提取出这个参数,保证解码前后的图片颜色空间一致。

颜色空间的对比

CoreGraphic中支持的颜色空间类型:

颜色空间

3、计算图片解码后每行需要的比特数,由两个参数相乘得到:每行的像素数 width,和存储一个像素需要的比特数4。

这里的4,其实是由每张图片的像素格式像素组合来决定的,下表是苹果平台支持的像素组合方式。

像素组合

表中的bpp,表示每个像素需要多少位;bpc表示颜色的每个分量,需要多少位。具体的解释方式,可以看下面这张图:

像素组合

我们解码后的图片,默认采用 kCGImageAlphaNoneSkipLast RGB 的像素组合,没有 alpha 通道,每个像素32位4个字节,前三个字节代表红绿蓝三个通道,最后一个字节废弃不被解释。

4、最关键的函数:调用 CGBitmapContextCreate() 方法,生成一个空白的图片绘制上下文,我们传入了上述的一些参数,指定了图片的大小、颜色空间、像素排列等等属性。

5、调用 CGContextDrawImage() 方法,将未解码的 imageRef 指针内容,写入到我们创建的上下文中,这个步骤,完成了隐式的解码工作。

6、从 context 上下文中创建一个新的 imageRef,这是解码后的图片了。

7、从 imageRef 生成供UI层使用的 UIImage 对象,同时指定图片的 scaleorientation 两个参数。

scale 指的是图片被渲染时需要被压缩的倍数,为什么会存在这个参数呢,因为苹果为了节省安装包体积,允许开发者为同一张图片上传不同分辨率的版本,也就是我们熟悉的@2x,@3x后缀图片。不同屏幕素质的设备,会获取到对应的资源。为了绘制图片时统一,这些图片会被set自己的scale属性,比如@2x图片,scale 值就是2,虽然和1x图片的绘制宽高一样,但是实际的长是width * scale

orientation 很好理解,就是图片的旋转属性,告诉设备,以哪个方向作为图片的默认方向来渲染。

通过以上的步骤,我们成功在子线程中对图片进行了强制转码,回调给主线程使用,从而大大提高了图片的渲染效率。这也是现在主流 App 和大量三方库的最佳实践。

总结

总结一下本文内容:

  • 图片在计算机世界中被按照不同的封装格式进行压缩,以便存储和传输。
  • 手机会在主线程中将压缩的图片解压为可以进行渲染的位图格式,这个过程会消耗大量资源,影响App性能。
  • 我们使用 Core Graphics 的绘制方法,强制在子线程中先对 UIImage 进行转码工作,减少主线程的负担,从而提升App的响应速度。

和 UIImageView 类似,UIKit 隐藏了很多技术细节,降低开发者的学习门槛,但另一方面,却也限制了我们对一些底层技术的探究。文中提到的强制解码方法,其实也是 CGBitmapContextCreate 方法的一个『副作用』,属于比较hack方式,这也是iOS平台的一个局限:苹果过于封闭了。

用户对软件性能(帧率、响应速度、闪退率等等)其实非常敏感,作为开发者,必须不断探究性能瓶颈背后的原理,并且尝试解决,移动端开发的性能优化永无止境。

0

iOS 照片涂鸦功能的实现

上个版本项目图片编辑需求中有一项涂鸦涂鸦就是选取颜色后根据手指滑动路径在图片上生成对应路径,并最终生成一张新图片。接到项目,Google、Github 搜索一番,看到不少实现思路和 Demo,心想:so easy!然而,具体实现的时候却发现了这些 Demo 存在严重问题...

初版实现

gif_Memory03

实现了我们要的功能,是不是很完美?看效果是 OK,在讨论遇到的问题前,我们先来看看核心代码的实现。

初版核心代码

  1. 开始绘制的时候,获取初始坐标信息写入 CGPath 对象:
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);
}
  1. 随着手指的移动,触发 touchesMoved 方法,添加路径信息到 CGPath 对象,主动调用 setNeedsDisplay 使 view 得以执行 drawRect
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    //移动->重新绘图
    [self setNeedsDisplay];
}
  1. 按步撤销:
(void)revoke
{
    [_pathArray removeLastObject];
    [self setNeedsDisplay];
}
  1. 最后就是绘制核心方法:
(void)drawRect:(CGRect)rect 
{
    for (PathModal *modal in _pathArray)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        [modal.color setStroke];
        CGContextSetLineWidth(context, modal.width);
        CGContextAddPath(context, modal.path);
        CGContextDrawPath(context, kCGPathStroke);
    }

    if (_path != nil)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextAddPath(context, _path);
        [[XRPhotoEditTheme themeManager].color setStroke];
        CGContextSetLineWidth(context, _drawWidth);
        CGContextDrawPath(context, kCGPathStroke);  
    }    
}

性能问题?

上面的代码,虽然实现了需求,但是从代码里也能看到,不断的进行数组操作、调用 drawRect,连撤销操作都不是移除最后一条路径而是先清空页面绘制将余下的路径重新绘制。看到这里,就算对 drawRect 使用场景及注意事项不甚了解你也会疑问:这操作会有性能问题吧?

光靠代码逻辑的复杂程度毙掉此方案,略失严谨。但是通过真机体验可以感受到路径多了后,开始卡顿,然后收到 didReceiveMemoryWarning,之后大概率的会崩溃。通过 XCode 运行信息也可以看到内存的暴涨:

gif_Memory02

为了确认内存暴涨是因为不断的对象初始化操作还是 drawRect 绘制的,首先我们只保留了 setNeedsDisplay 调用和 drawRect 绘制所必需得代码,结果随着不断的绘制内存还是不断升高。

随后只保留手势、对象操作,注释掉绘制逻辑。在注释掉 drawRect 之后内存立刻恢复正常,我们终于抓到了消耗内存的元凶:不断的执行 drawRect 方法。

最终我们将内存暴增的原因找了出来,那为什么不断的调用 drawRect 绘制会收到内存警告而崩溃?有没有更好的解决方案呢?

重写

在 iOS 系统中所有显示的视图都是从基类 UIView 继承而来的,同时 UIView 负责接收用户交互。

但是实际上你所看到的视图内容,包括图形等,都是由 UIView 的一个实例图层属性来绘制和渲染的,那就是 CALayer,可以说
CALayer 是 UIView 的内部实现。而且基于 UIViewdrawRect 绘制方式存在内存暴涨的风险,我们综合研究后认为画板这种需要实时反馈在UI上的绘制需求应直接用专有图层 CAShapeLayer。至于为甚么是 CAShapeLayer,我们稍后讲。

  1. 初始化路径
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);

    CAShapeLayer * slayer = [CAShapeLayer layer];
    slayer.path = _path;
    // 略:设置 slyer 参数
    [self.layer addSublayer:slayer];
    _shapeLayer = slayer;

    [_pathArray addObject:slayer];
   }
  1. 路径绘制
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    //略:point有效值判断

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    _shapeLayer.path = _path;
}
  1. 按步撤销
(void)revoke
{
    [_pathArray.lastObject removeFromSuperlayer];
    [_pathArray removeLastObject];
}
  1. 检验

gif_Memory01

以上代码用 CAShapeLayer 实现了路径的绘制,当然还有优化的空间:比如公用一个 CAShapeLayer 对象来实现;但不是本文的重点。
毕竟现在的代码运行起来发现内存和 CPU 的波动微乎其微,内存消耗也维持在较低的水平。

反思

重写 drawRect 方法为何会导致内存大量上涨?

要弄明白这个问题,我们先来回顾一下 iOS 中图像显示原理:

上文已经讲到 CALayerUIView 的内部实现,在每一个 UIView 实例当中,都有一个默认的图层 CALayerUIView 负责创建并且管理这个图层。而实际上这个 CALayer 图层并不是真正用来在屏幕上显示的。但为什么我们能看到 CALayer 的内容呢?是因为 CALayer 内部有一个 contents 属性。contents 默认可以传一个 id 类型的对象,但是只有你传 CGImage 对象的时候,它才能够正常显示在屏幕上。 所以最终图形渲染落脚点落在了 contents 上面。

contentsdrawRect 又有什么关系呢?

查阅文档发现 drawRect 方法没有默认的实现,因为对 UIView 来说,寄宿图并不是必须的,UIView 不关心绘制的内容。但如果 UIView 检测到 drawRect 方法被调用了,它就会为视图分配一个 contents (寄宿图)。这个寄宿图就是我们问题的关键:寄宿图的像素尺寸等于视图大小乘以 contentsScale (这个属性与屏幕分辨率有关,我们的画板程序在不同模拟器下呈现的内存用量不同也是因为它)的值。

回到我们的涂鸦程序,当画板从屏幕上出现的时候,因为重写了 drawRect 方法,drawRect 方法就会自动调用。 生成一张寄宿图 后,绘制方法里面的代码利用 Core Graphics 去绘制路径,然后内容会缓存起来,等待下次调用 setNeedsDisplay 时再次调用 drawRect 方法更新到寄宿图。

理解到这里,我们可以知道:调用一次 drawRect 方法,图层就创建了一个绘制寄宿图上下文,而这个上下文所需要的内存可从这个公式得出:图层宽*图层高*4 字节,宽高的单位均为像素。那么一张 4000*3001 的高清图创建一个上下文占用多少内存呢:4000*3001*4,相当于 46MB 内存。而涂鸦要求随着手指的移动立马在图层上展示出路径,使用 drawRect 方法重绘则必须不停的去 setNeedsDisplay 从而频繁触发 drawRect不断的创建寄宿图。

这就是我们画板程序内存暴增的真正原因

为什么是 CAShapeLayer

CAShapeLayer 是一个通过矢量图形而不是 bitmap 来绘制的图层子类。用 CGPath 来定义想要绘制的图形,CAShapeLayer 会自动渲染,而且它采用了硬件加速处理速度非常快而且内存占用更低。它可以完美替代我们的直接使用 Core Graphics 绘制 layer

总结

最终我们决定放弃 drawRect 而选用图层 CAShapeLayerCAShapeLayer 不仅在功能上满足了我们的需求,对比之下 CAShapeLayer 在性能方面表现也非常出色。

另外,因为模拟器内存是分配的 Mac 系统的内存可用内存远高于真机,这种性能问题很难在使用模拟器的开发阶段被关注到,所以开发过程中就尽量使用真机来测试吧。

参考

http://www.cnblogs.com/yaokang/archive/2012/06/02/2532257.html
https://www.cnblogs.com/chrisbin/p/6391933.html?utm_source=itdadao&utm_medium=referral

0

捋一捋 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

Objective-C 里的语法糖

语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。

——维基百科

需要声明的是“语法糖”这个词绝非贬义词,它可以给我带来方便,是一种便捷的写法,编译器会帮我们做转换;而且可以提高开发编码的效率。
通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会,本文在简单的介绍 OC 语法糖的同时也会跟大家分享下我们使用过程中发现的'新'问题。

语法糖的自白

先举个生活中的🌰:
1. 老班:为了传达教育局和学校的教育精神我来讲几句。
2. 老班:下面我要讲啦啊。
3. 老班:我要讲的是教育局和学校刚传达的规定。
4. 老班:最近校长发现迟到学生越来越多。
5. 老班:为了维护教学秩序,学校制定了新校规。
6. 老班:.....
7. 小明:说人话!
8. 老班:从明天起7点钟准时到校上课!

老班巴拉巴拉讲了一堆,其实只是要表达从明天起 7 点钟准时到校上课!虽然最终能表达出效果,但是老班说的累小明们听着也烦。举这个例子可能比较极端,至少我没有遇到这么啰嗦的老师。但在编程语言中的确有不少让程序员感到罗嗦的语法,让人不能忍,这个时候语法糖就派上了用场。

比如OC取数组元素:

id element = [array1 objectAtIndex:0];

OC 语法糖:你看,我是不是写起来很方便?

id element = array1[0];

OC 语法糖:往下看,我还能做更多呢。

OC语法糖

@[]@{}

NSArray

一般数组的初始化和访问数组元素是这样的:

// NSArray 的 alloc 初始化
NSArray *array1 = [[NSArray alloc] initWithObjects:@"a", @"b", @"c", nil];
// NSArray 的便捷构造
NSArray *array2 = [NSArray arrayWithObjects:@"1", @"2", @"3", nil];

获取数组中的元素可以这样的:

// 获取相应索引的元素
id element = [array1 objectAtIndex:0];
NSLog(@"array1_count = %d, array[0] = %@",[array1 count], element);

如果使用语法糖,可以这样写:

// NSArray的定义
NSArray *array = @[@"lu", @"da", @"shi", @YES, @123];
int count = (int)[array count];
for (int i = 0; i < count; i++)
{
   NSLog(@"%@", array[i]);
}
NSDictionary

字典的初始化一般是这样的:

NSDictionary *dictionay = [NSDictionary dictionaryWithObjectsAndKeys:@"value1", @"key1", @"value2", @"key2", nil];
id value = [dictionay objectForKey:@"key1"];
NSLog(@"key1 => %@", value);

我们还可以这样简化:

NSDictionary *dictionary = @{
                             @"key0" : @"value0",
                             @"key1" : @"value1",
                             @"key2" : @"value2"
                             };
NSLog(@"key2 => %@", dictionary[@"key2"]);

事实上 [ ]{ } 在JSON数据格式中最常见了,[ ] 一般封装一个数组,{ } 一般封装一个整体对象。

NSNumber

一般写法是这样的:

NSNumber *intNumber = [NSNumber numberWithInt:123];
NSNumber *floatNumber = [NSNumber numberWithFloat:12.3];
NSNumber *charNumber = [NSNumber numberWithChar:@('a')];

语法糖简化写法:

NSNumber *a = @123;
NSNumber *b = @12.3;
NSNumber *c = @('a');
NSLog(@"a = %@, b = %@, c = %@", a, b, c);

. 点语法

再用数组 NSArray *array = @[@"lu", @"da", @"shi", @YES, @123]; 举例。想要获取数组中有多少个元素,我们平时都是怎么做的?
[array count] 还是 array.count?

老司机们思索片刻后说到:好像都用过,但是...我们知道在 OC 中 []. 分别代表调用方法和属性,看申明明明是属性呀,怎么可以用 . 方法?

从 OC 2.0 开始只要符合系统默认 settergetter 书写格式的方法都可以使用 . 点语法,属性是一对 gettersetter 方法,点语法是属性的另一种调用格式,就是语法糖方法。这么做的目的只有一个,就是增加可读性、兼容常见用法减少代码报错!

OC语法糖带来的'坑'

一般我们认为语法糖带来了方便,特别是对于字典的初始化 直接是key:value的赋值方式比 dictionaryWithObjectsAndKeys 这种反人类的方式友好的多。

但真的没有其他什么问题吗?看下下面两个定义及运行结果:
屏幕快照_2017-11-20_上午10.08.23

屏幕快照_2017-11-20_上午10.08.39

可见 dictionaryWithObjectsAndKeys 如果遇到 value 为 nil 的情况,后续 key-value 不会入库直接当做末尾 nil 结束初始化;
而语法糖的方式就直接崩溃了,对崩溃了。

所以使用语法糖还需要注意数据合法性问题,nil 会造成意外的崩溃哦!

基本原理

语法糖就是语言中的一个构件,当去掉该构件后并不影响语言的功能和表达能力。而使用语法糖语言的处理器,包括编译器,静态分析器等,会在处理之前把语法糖构件转换成加糖之前的构件,这个过程通常被称为:desugaring。说白了,语法糖就是对现有语法的一个封装,编译运行的时候再脱掉这层糖衣变为老的语法实现。

写在最后

  1. 之所以叫「语法」糖,不只是因为加糖后的代码功能与加糖前保持一致,更重要的是糖在不改变其所在位置的语法结构的前提下,实现了运行时等价。可以简单理解为,加糖后的代码编译后跟加糖前一摸一样。
  2. 之所以叫语法「糖」,是因为加糖后的代码写起来很爽,
    包括但不限于:代码更简洁流畅,代码更语义自然,写得爽,看着爽!
  3. 「糖」也可能有毒,使用需谨慎。
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

苹果在医疗健康领域的三个 Kit

Apple Watch Series 2 发布时,苹果对它进行了重新定位,聚焦在 Fitness 领域。这个转型显然获得了成功,虽然没有公布过官方的销量数据,但根据外界的普遍预测,Apple Watch 今年的出货量已经达到了 1500-2000 万之间。

其实,苹果对于健康领域的兴趣,不仅仅停留在硬件层面上。在软件层面上,近年来也动作颇多。iOS 8 发布时,系统内置了一个名为「健康」的原生应用,并同时发布了面向开发者的 HealthKit;2015 年春季发布会,苹果发布了 ResearchKit;而在去年的 2016 春节发布会上,苹果不仅进一步加强了 ResearchKit,还推出了全新的 CareKit。

三个 Kit,不再傻傻分不清楚

Artboard

HealthKit、ResearchKit、CareKit,这三个 Kit 面向的对象是开发者,它们的存在,大大降低了开发者的门槛。现在,医院里的医生、大学里的医学教授、国际医学中心的科研小组,拥有丰富的临床知识和经验却不一定会编程的他们,现在也有可能做出一个自己的 app 了。三者作为一个有机的整体,相辅相成,不断扩展着健康管理的方式和边界

那么,具体到每一个 Kit,它们是如何各司其职的呢?

HealthKit:提供应用间的健康数据分享标准

HealthKit 作为最早推出的开发套件,实际上也是苹果整个医疗健康产业的基石。试着去打开你手机上的「健康」应用,你可以看到各类健康相关的指标,从基本的健康数据,到生殖健康,再到化验结果、营养摄入……大大小小总共涵盖了数十种指标和体征数据。之所以说 HealthKit 是后面一系列事情的基石,正在于它定义了数据的类型和互相通信的标准。现在,你可以通过将各类第三方健康应用,如 Keep、乐动力、Runtastic,甚至微信运动,它们都可以通过 HealthKit 互相交换、读取、写入健康相关的数据。

image

乍一看这好像也不算什么特别大的事情,实际上如果对医疗行业有稍微深入的认识,你就可以揣测出苹果未来的伟大愿景。现在你去医院看病的时候,基本上所有的病历、开药都已经完全电子化了,没错,医疗健康行业现在可以说是一个信息化的行业,但是,却是一个高度碎片信息化的行业。

如何理解高度碎片化?非常简单,你在一个医院做的检查,得到的化验结果,跑到另一家医院,他们的系统里就没法调取你的化验数据,很多时候你需要重新再来一次。所以,国内外的一些医疗信息化标准的最高等级目前都是七级,都要求实现区域化的医疗信息共享。的确在部分地区形成了区域互通的医疗信息共享,然而,如果想进一步将北京和天津两地的医疗数据打通,那么又是一件麻烦事。每一家医院存储和记录医疗数据的方式、格式都非常不同,仅仅将这些数据形成一套统一可交换的规范协议,就需要花费不少的时间去改造。何况,这里提到的碎片化还只是不同机构间的数据无法互通,另一个更大的问题在于,由于患者的健康数据无法连贯保存,无法形成完整的数据轨迹。

显然,这种方式的变革太慢,苹果正是希望通过 iOS 在全球的影响力和普及程度,从数据的角度入手,首先解决医疗中的老大难问题:形成一套可互通互换的数据格式协议。现在,或许你看到的只是应用和应用间的数据共享,但未来呢,应用和可穿戴硬件之间,和医疗器械之间,和医疗信息系统之间……苹果的 HealthKit,就是连接这一切的标准中间件

ResearchKit:面向科研人员的数据搜集与疾病研究

在 HealthKit 打下数据互通的基本上,进一步的就是应用层面的问题了。有了这些健康数据,我们可以怎么使用它们?ResearchKit 是苹果最先想到的一个大规模应用场景。

在谈 ResearchKit 之前,或许你有必要了解一下临床科研项目的一些基本背景。事实上,世界上目前有许多罕见病和慢性病,我们并没有找到其发病的根本原因,对发病的机理也一无所知,甚至有治疗手段的有效性,也有待考证。没错,临床科研高度依赖试验,只有通过大量的患者入组参与临床科研,并通过有效的实验设计,收集大量的数据反馈,才能最终验证治疗方案的有效性和可靠性。

researchkit

然而,在现实情况里,有许多非常现实的问题:上哪找到足够多的愿意入组的患者?患者入组后,如何高效合理地开展试验?要知道,临床科研的合规性要求非常之高,哪怕只是错过一次治疗或者数据反馈,这一组「样本」可能就失去可靠性了。所以,临床科研项目往往面临着找患者难、找到患者之后实施更难的窘境。一次有效的临床科研项目,往往需要投入巨大的时间成本,耗费大量的财物跟踪患者的治疗和数据收集,而我们谈论的,可能只是几百个患者样本的试验而已。

ResearchKit 就是希望帮助临床科研人员解决这个问题。患者可以通过 iPhone 随时参与某一项罕见病或慢性病的科研项目,期间只要定期完成一些数据反馈和问卷调查,科研人员手中就能收获大量的数据。整个科研项目的流程都被简化了,现在患者只需要下载一个 App,首次进入时通过电子签名的方式确认参与项目并同意数据的匿名分享,之后定时打开 App,不管是输入自己的指标,还是回答问卷,或者完成一些小的测试,都可以随时随地在手机上完成。

apple-researchkit-1104340-TwoByOne

CareKit:以患者为中心的医疗干预和随访

ResearchKit 只是数据应用的一个案例,显然苹果并不满足于此。在科研人员之外,去年推出的 CareKit 则是面向患者个体的健康服务框架。和科研数据收集的导向不同,CareKit 的整体是以患者为中心的。通过 CareKit,患者可以随时与自己的医生共享自己的健康数据,并一对一地收到医生的反馈建议和治疗方案的调整,从而实现远程的医疗干预和随访。

Slice_1

不要小看了医疗干预和随访这件事情。虽然现代医疗越来越强调以患者为中心这个概念,然而很多并没有落到实处。许多人在看完《北京遇上西雅图》之后会开玩笑地说,中国医生的水平比国外医生高多了,因为中国医生一天看的患者数量,可能是国外医生一年的患者量。这句话其实并不全对,看病并不仅仅只是五分钟的面诊,而是一个动态调整的过程。实际上门诊和医生见面,仅仅只是治疗的开始,医生在通过面对面接触,得到化验结果的前提下,给你做出的治疗方案和开具的处方药物,并不是一个结果,而是需要后续根据病情的发展和治疗的效果,随时做出动态的调整。

对于医生来说,如果患者做完手术后出院,再也不和医生联系了,其实医生也并不清楚患者的术后情况和治疗效果,由于缺少信息的正向反馈,对自身临床水平的提高也不利。患者端则更好理解,如果医生能够持续地跟进治疗,患者的依从性和健康状况,也会有更好的改善。然而,我国由于医患数量的严重不对等,导致很多医生没有时间和精力来做好随访这件事情,而很多医院本身也不重视随访工作,或者工作方式还非常原始——不要怀疑,很多医院甚至还靠向患者写信,来收集患者出院的康复情况。

那么 CareKit 想做的事情就很好理解了,和 ResearchKit 的核心一致,本质上都是简化数据流通的过程,现在患者可以通过手机将健康数据实时共享给自己的医生,从而收获医生的建议和反馈,动态地调整治疗方案,对于医患双方来说,都大有禆益

ResearchKit 已取得的成果

前面提到了 ResearchKit 是面向科研人员的大数据收集工具,也是苹果在健康数据应用层面的第一次尝试,自从前年推出以来也有两年的时间了,这两年里成果如何呢?

Research-Kit-Apps-640x360

mPower 为例,它是由罗彻斯特大学、赛智生物网络共同推出的一款 App,主要研究的疾病是帕金森症,评估患者的情况并不难,只需要一些简单的小测试,让患者动动手脚,回答一些简单的问题,就能了解治疗对患者病情的改善程度。目前已经有超过 10000 人入组参与,其中 93% 的人从来没有参与过任何临床科研项目,不要小看了这一万人,这已经是帕金森症的临床研究中,有史以来规模最大的一次。在传统的研究中,一次试验能有几百个入组患者,已经是相当不错的成果了。通过 iPhone 的陀螺仪等功能,mPower 收集了患者一系列的数据,包括灵巧性、平衡能力、走路姿态和记忆水平,从而分析睡眠、运动和心情对疾病的正负面影响。

不仅如此,iPhone 的参与,还让许多临床项目融入了科技的元素。举例来说,小儿自闭症一直是另人困扰的问题,而许多家长往往在小孩已经好几岁时才发现,这时候治疗已经有些迟了。事实上已经有大量的研究证明表明,如果能尽早介入对自闭症儿童的干预治疗,儿童在成年后会有更好的智商和社交能力。Autism & Beyond 就可以使用 iPhone 的前置摄像头,通过对人脸的智能识别,甚至可以识别 18 个月大的婴幼儿的情绪反应,从而尽可能早地筛选出小儿自闭症患者,并进行干预治疗。利用 ResearchKit,App 仅仅推出一个月的时间,就比这个项目之前九个月通过各种渠道募集到的入组实验儿童都要多

Slice_2

苹果一度说的 There is an app for that,现在对于各类罕见病和慢性病来说,都是 There is an app for the disease。从之前提到的帕金森症和小儿自闭症,到癫痫、哮喘、脑震荡、慢性阻塞性肺病、糖尿病、乙肝、黑色素瘤、产后抑郁、睡眠健康等等,你都可以找到一款 App,主动共享你的健康数据,从而帮助临床科研人员更好地推动疾病的治疗发展。

这些疾病或许对你我来说并不陌生,但在科研人员眼里,还有太多他们想了解的谜团没有解决,例如产后抑郁和基因到底有没有关联,脑震荡长期来看对生活质量的影响,哮喘的个体化治疗方案等等,我们贡献的可能只是一点点匿名的数据,但所有人加起来,科研人员一下子能得到的数量据是以前的几十倍,甚至在糖尿病方面,几乎已经确认存在着数类 II 型糖尿病的亚型,而运动对这些亚型的治疗有着显著的影响。

而最棒的是,不仅仅只是那些患者,我们每一个普通人,都可以参与到推动医疗健康发展的进程中来。即使你一切健康,也可以下载诸如 mPower 这样的 App,并以一名健康者的身份共享自己的数据,从而帮助科研人员获取健康情况下的对照组

Researchkit__1_

CareKit 能做什么

前面提到了,CareKit 是在数据应用层面上,面向患者个体和医疗服务者之间的通道,是一套以患者为中心的医疗干预和随访。通过 CareKit,你可以随时与自己的医生分享自己的健康数据,医生在远程能够实时地给出建议和治疗方案调整。实际上,它主要有四个模块:

  • 健康卡:帮助患者执行和监控自己的治疗方案,例如通知你吃药的时间和剂量,该做什么理疗和运动,一些相关的运动数据还可以直接通过 Apple Watch 或者 iPhone 的传感器收集后直接上传给你的医生;
  • 症状和测量追踪器:患者可以记录自己不同时间段的情况,如睡眠质量、血糖血压情况,或者可以定时填写打分量表评估自己的心情或是疼痛水平,或者拍照记录伤口的愈合情况等等;
  • 透视仪表盘:通过图表的形式,直观地展示不同治疗方案的有效性和数据变动情况,以让患者直接地了解该疗程的效果是否达到;
  • 连接模块:患者可以直接与自己的医生在线沟通、共享数据。

image1458596369787

CareKit 以患者为中心正体现在:通过外界力量督促患者执行治疗方案、以患者可以理解的方式呈现数据和进展、针对患者的个体情况提供个性化的指导和建议、对接医生实时提供干预和调整。从前,你很可能需要随身携带笨重的设备全天候地记录数据,然后定期地回到医院、找到医生,现在,你只需要一部手机,就可以随时记录情况、分享数据、联系医生。

一个可以肯定的事实是,CareKit 相比 ResearchKit,会更快更大范围地普及,除了原有的一些 ResearchKit 项目会推出相对应的面向个人的基于 CareKit 的医疗服务外,德州休斯顿医学中心也正在研发一款基于 CareKit 的医患沟通 App;贝斯以色列女执事医疗中心则会面向慢性病患者,推出一套基于 CareKit 的家庭监控方案;One Drop 则正在开发一款糖尿病管理工具,不仅可以记录在不同血糖情况下的疼痛、晕眩和饥饿感,还可以实时地将这些数据共享和你的医生和家人。

苹果在健康产业的下一步

看到这里,你应该已经有了一个大概的认识:HealthKit 是一切的基石,它负责打通数据层面的对接和互通,ResearchKit 和 CareKit 则是两个数据应用层面的具体场景和案例,前者面向科研人员的大数据收集和分析,后者则是面向患者个人的健康管理和个体化治疗

那么,苹果在健康产业的下一步会是什么?

从目前来看,苹果的整个切入点还是在数据层面,的确在医疗体系里,数据标准化是一个很复杂的话题,即使是苹果目前做的,也仅仅只是部分最简单的数据的标准化,如化验结果、生理指标等等,但是苹果显然不会止步于此,毕竟要想在更多健康应用的场景里扩展可能性,就需要接入和统一更多的数据,包括患者的用药情况、手术情况、基因分子情况等等,这些数据的标准和结构化则更加棘手和复杂,显然苹果也在谨慎地调研。

此外,在数据层面来看,除了扩展数据的丰富程度之外,健康数据的安全性也是一个不容忽视的话题。如果我们的手机上存储了我们每个人最为重要的健康指标和数据,这些数据的隐私和保密就显得尤为重要。

在数据标准化的前提下,苹果当然是希望接入更多的健康场景和应用领域。未来,一定会有更多的可穿戴设备和医疗器械和 HealthKit 对接,随着检测仪器的小型化和便携化,未来化验和检查中心,甚至也可能完全消失,人们直接通过便携式的设备,即可通过标准的 HealthKit 协议互通数据,足不出户也能完全数据的采集。而这些数据,除了目前的临床科研和随访之外,在区域卫生预警、持续化健康管理、个体化治疗方案等等领域内,都有着广阔的前景和想像空间。

从某种程度上,健康领域的这些野心,体现的也是苹果所特有的一种文化:即希望赋予普通人以工具和能力,以积小成多的方式,发挥出协作和团体的力量。它或许没有神秘的实验室,没有令人眼前一亮的未来感,也没有太多的精英式的先导者,但正是凭借着这种文化和理念,苹果在设备和硬件之外,如此变革了多媒体内容产业、软件生态产业和教育产业,在不远的未来,医疗健康产业或许亦如是。

0

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+

iOS 屏幕适配浅谈

前端开发的屏幕适配其实算是基本功,每个码农在长期实践中都有自己的总结。

在 iOS 平台上,苹果爸爸对适配的支持个人感觉很不人性化,提供了 autoLayout、sizeClass 等技术,感觉没有前端类似 flexBox 这样的技术来得灵活。像是点歪了技能树,过于重视使用 xib 配置 UI,但很多码农还是习惯纯代码编程。Cocoa 没有 css 这样的纯布局文件,导致很多时候我们将布局、UI 和逻辑写在一起,十分混乱、冗长。

下面简单介绍下在实践中适配屏幕的方向思路,抛砖引玉。

从设计到代码:沟通与标准

App 的 UI 界面是由设计人员(产品,UI)绘制的,然后由开发实现,双方要有良好的沟通,并且把设计内容标准化、文档化。

对设计方来说,适配的规则总是在设计师心中的,是按比例的缩放,还是固定的间距,是公用一套规则,还是在大屏下有特殊的布局,都需要有明确方式传达给耿直的码农们。

良好的设计文档是沟通第一步

一般常见的布局方式有:

  • 固定间距:在不同尺寸下,间距总是固定。
  • 流式布局:文字,图片等在不同屏幕下流式排布,比如大屏下一行显示四张图片,小屏一行三张,图片尺寸固定。
  • 比例放大:间距,文字大小,图片大小等比例放大。
  • 保持比值:两个UI元素或者图片的长宽等属性保持一定的比值。
  • 对齐:元素间按某个方向对齐。

设计师需要将这些布局规则标注清楚,有利沟通,也方便日后追溯。

对于一些通用 UI 组件,要进行标准化,设计上有利于 app 风格统一,实现上也方便开发进行封装。

平面设计要标准化

UI的搭建:xib VS 纯代码

苹果一直用xib来标榜他们家 App 开发简单易上手:将各种你需要的东西往屏幕上一拖一放,一个UI界面就搞定了,这很 cool 不是嘛!

xib 的优点显而易见:

  • 易上手、可视化,所见即所得
  • 减少代码量
  • 快,适合小 app 快速开发

但是在我们的实际项目中,是不推荐使用 xib 的。

首先,xib 本身过于笨拙,只能搭建一些简单的 UI,动态性很差,难以满足app复杂的UI交互需求。

其次,做过性能优化的同学都知道,xib(or StoryBoard)的性能是很差的,相对于用纯代码 alloc 的组件来说,xib 加载慢,而且会占用 app 包的体积。不仅仅是app的性能,使用老 mac 打开较大的 xib 文件,有时候会卡的你怀疑人生,严重影响开发效率(心情)。

除此以外,对于团队协作来说,xib 也不是一个好选项:阅读困难,无法在 git 上查看历史改动,容易造成冲突,造成冲突后难以解决,元素通过 outlets 与代码的链接难以维护,容易在改动中造成错漏等等。

另外,对于我这种中途转到前端的工程师来说,对一切在 IDE 界面上配置的东西都有种迷之不信任,感觉不如一行行黑底白字的代码来的靠谱。

当然我们不是完全禁用了 xib,用代码码 UI 的缺点也很明显:繁琐,代码量大。因此对一些元素较多,又比较固定的 UI 组件,我们可以用 xib 来减少代码量:

固定的UI组件可以使用xib

针对UI代码繁琐,重复编码多的情况,我们可以通过适当封装(UI 工厂类),组织结构(MVC,分离 UI 代码)等手段,清晰逻辑。

// label 工厂方法
+ (UILabel *)labelWithFont:(UIFont *)font
                     color:(UIColor *)
                      text:(NSString *)text
             attributeText:(NSAttributeString *)attributeText
                 alignment:(NSTextAlignment)alignment;

布局:返璞归真

从 iOS7 开始苹果在 Cocoa 平台引入 AutoLayout 进行 UI 的基本布局。但是 AutoLayout 非常反人类,不仅代码繁琐而且使用不灵活限制很多。

比如我想要把三个元素等间距地展示在屏幕上,用 AutoLayout 写完基本蛋都碎了,更别说动态地在两套布局间切换这种高级需求。

后来苹果推出 sizeClass,试图解决多套布局的问题,但是仍然没有触及到码农的痛点,而且依赖 xib 使它泛用性不好。

看起来很美好的sizeClass

一段典型的AutoLayout代码如下所示:

    _topViewTopPositionConstraint = [NSLayoutConstraint
                                     constraintWithItem:_topInfoView
                                     attribute:NSLayoutAttributeTop
                                     relatedBy:NSLayoutRelationEqual
                                     toItem:self.view
                                     attribute:NSLayoutAttributeTop
                                     multiplier:1.0
                                     constant:self.navigationController.navigationBar.frame.size.height + self.navigationController.navigationBar.frame.origin.y];

    [self.view addConstraint:topViewLeftPositionConstraint];

    (这里省略上述类似结构*4)

上面省略了很多代码,实际上一页都放不下。它干了什么呢,只是将一个元素紧贴屏幕上边缘放置。项目中我们会使用三方 autoLayout 的封装:PureLayout ,简化代码,也有其它实用功能,。

AutoLayout 比较适合:

  • 基本的对齐(上下左右对齐,居中对齐等)
  • 固定的布局,固定的间距,动态性不高的页面
  • 简单且数量较少的 UI 元素

不擅长:

  • 比例布局
  • 动态性较强的页面局部
  • 不同屏幕大小比例的适配
  • 复杂的UI

另外有一点,autoLayout 对性能是有损耗的,所以对性能有要求的场景,比如列表中的 cell,我们会用代码计算 frame,提高滑动帧率。

所以在实际工程中,需要来选择布局方式。

下面是 app 中首页新闻 Feeds 的布局代码片段:

- (void)layoutSubviews {

    [super layoutSubviews];

    CGFloat cellWidth = CGRectGetWidth(self.bounds);
    CGFloat currentY = 0.f;

    // 0.content
    CGFloat cellHeight = CGRectGetHeight(self.bounds);
    CGFloat contentHeigth = cellHeight - kCellPaddingHeight;
    _mainContentView.frame = CGRectMake(0, 0, cellWidth, contentHeigth);

    // 1. topic
    CGFloat topicLabelWidth = [_topicLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.width;

    CGFloat topicLabelHeight = [@"测高度" boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.height;

    CGFloat topicLogoLeftPadding = 3.f;
    CGFloat topicLogoWidth = 10.f;
    CGFloat topicLeftPadding = 13.f;

    _topicView.frame = CGRectMake(topicLeftPadding, currentY + kTopicUpPadding, topicLogoWidth + topicLogoLeftPadding + topicLabelWidth, topicLabelHeight);
    _topicLogo.frame = CGRectMake(topicLabelWidth + topicLogoLeftPadding, CGRectGetHeight(_topicView.frame) / 2.0 - topicLogoWidth / 2.0, topicLogoWidth, topicLogoWidth);
    _topicLabel.frame = CGRectMake(0, 0, topicLabelWidth, topicLabelHeight);

    (省略大量代码……)

    // 10._sourceLabel
    CGSize sourceSize = [_sourceLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_sourceLabel.font} context:nil].size;

    _sourceLabel.frame = CGRectMake(kEdgeHorizontalPadding, currentY + kLeadingUpPading, sourceSize.width, sourceSize.height);
}

可以看到,为了确定每个元素的位置,我们需要进行大量的计算,代码可读性也不好,繁琐难读。如果引入动态性,比如不同屏幕字体大小改变,元素大小按比例扩大等,则计算量又要上一个数量级。

动态布局:清晰独立

UI界面是动态的,在不同状态,不同尺寸或者手机的横竖屏情况下,我们往往需要在多套布局方案中切换,或者对布局进行微调。如果使用xib布局的话,可以使用 SizeClass + AutoLayout 的方案;如果是代码实现的页面,则没有官方提供的工具,只能用逻辑去判断。

一般来说,我们写复杂的UI页面,需要遵循两个原则:

  • UI 布局代码,要清晰:这是最重要的,要一眼就知道在调整那一块,怎么调整,如果不能,适当拆分,优化命名。
  • 布局代码要和业务逻辑独立:在一些常用设计模式下,我们会将 UI 和数据模型解耦,在 UI 内部,同样要将交互,配置这些逻辑和布局解耦,独立出类似前端 css 这样的纯布局文件。

将布局代码提炼出来,在不同尺寸下调用不同的实现:

    if (IS_IPHONE_6){  
        self.layout = [MyLayout iPhone6Layout];
    }else if (IS_IPHONE_6_PLUS){  
        self.layout = [MyLayout iPhone6PlusLayout]; 
    }

    // 实现小屏幕布局
    + (MyLayout *)iPhone6Layout {...}
    // 实现大屏幕布局
    + (MyLayout *)iPhone6PlusLayout {...}

字体适配:字体集

在开发中我们经常会遇到需要动态设置字体的情况:

  • 不同屏幕尺寸,或者横竖屏,需要展示不同的字体大小。
  • 为用户提供了文章调节字体选项。
  • App 的不同语言版本,需要显示的字体不一样。

字体大小调节

较为简单的做法是用宏或者枚举定义字体参数,针对不同尺寸的屏幕,我们拿到不同的值:

#ifdef IPHONE6
#define kChatFontSize 16.f
#else IPHONE6Plus
#define kChatFontSize 18.f
#endif

在对一些旧代码做字体适配扩展的时候,直接修改源码改动太多,容易混乱,可以采用 runTime 方法 hack Label 等控件的展示,替换原有的 setFont 方法:

+ (void)load{  

    Method newMethod = class_getClassMethod([self class], @selector(mySystemFontOfSize:));  
    Method method = class_getClassMethod([self class], @selector(systemFontOfSize:));  
    method_exchangeImplementations(newMethod, method);  
}  

+ (UIFont *)mySystemFontOfSize:(CGFloat)fontSize{  
    UIFont *newFont=nil;  
    if (IS_IPHONE_6){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6_INCREMENT];  
    }else if (IS_IPHONE_6_PLUS){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6PLUS_INCREMENT];  
    }else{  
        newFont = [UIFont adjustFont:fontSize];  
    }  
    return newFont;  
}  

以上套路缺点显而易见:不够灵活,将逻辑分散,不便于维护,扩展性也不好。

一种比较好的实践是引入字体集(Font Collection)的概念,什么是字体集呢,我们在用 Keynote 或者 Office 的时候,软件会提供一些段落样式,定义了段落、标题、说明等文字的字体,我们可以在不同的段落样式中切换,来直接改变整个文章的字体风格。

Keynote中的字体集

听上去和我们的需求是不是很像呢,我们在代码中也是做类似的事情,将不同场景下的字体定义到一个 Font Collection 中:

@protocol XRFontCollectionProtocol <NSObject>

- (UIFont *)bodyFont; // 文章
- (UIFont *)chatFont; // 聊天
- (UIFont *)titleFont; // 标题
- (UIFont *)noteFont; // 说明
......
@end

不同的场景,灵活选择不同的字体集:

+ (id<XRFontCollectionProtocol>)currentFontCollection {

#ifdef IS_IPhone6
    return [self collectionForIPhone6];
#elif IS_IPhone6p
    return [self collectionForIPhone6Plus];
#endif
    return nil;
}

// set font
titleLabel.font = [[XRFontManager currentFontCollection] titleFont];

适配新的屏幕或者场景,我们只需要简单地增加一套字体集就好了,可以很方便的管理 app 中的字体样式,做动态切换也很简单。


总结来说,用代码在一个尺寸实现设计稿是比较简单的,但是要在各种尺寸下忠实反应设计的想法需要合理的代码设计以及一定的代码量。

UI 的还原其实也是大前端开发非常重要的部分,作为程序员,往往重视代码的稳定,业务的正常使用而忽略软件界面这个同样重要的用户体验因素。设身处地地想,如果设计看到自己精心调配的比例、字体、色号在不同尺寸手机上显示得歪七倒八,一定会气的要死吧。

1+