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

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