Hulk' Den

in-depth thinking and keep moving.

类似逐帧动画或者tableView列表中有大量图片展示需求的场景我们可以做哪些优化?

背景

最近公司在调研后打算替换掉老的识别库,据说新库在识别率上做的更好一些。但是在集成后实际压测发现并没有什么提升。(由于分析过程可能牵扯到公司业务,所以此处省略分析发现问题的过程,见谅)

……….

那么思路捋到这里我们可以总结出来两个至关重要的优化点:

  1. 避免频繁的解压资源包操作,每次解压绝对是件耗费的操作。这个是比较低级但在俯瞰全局时容易忽略的点,本篇不做讨论。
  2. 逐帧动画的图片数量比较大质量比较高,能否把PNG/JPEG图片解码成位图后保存到本地,避免CPU进行重复频繁的解码图片操作?基于这一点我们开始本篇的探讨。

图片优化思路的来源和延伸

为了提高APP界面流畅度引出的优化方法有很多,在图片优化方面有一个思路,就是提前异步解码图片的思路而不是官方UIKit采用的在图片提交给GPU之前由主线程同步解码,这个思路的鼻祖应该就是Facebook的ASDK了,国内的ibireme大神的YYImage也借鉴了这个思路。(文末的附录1是我从他们这里学到的强制解码图片的思路)

但是面对逐帧动画这个变态的操作,我觉得提前异步解码并不能解决痛点,因为我们直播间的瓶颈在CPU,提前异步解码终归还是要每次解码,那能不能让解码的操作只做一次,把解码得到的位图缓存到本地,以后再用了就直接加载位图呢?在这之前,还是先来了解下iOS展示图片的过程吧。

iOS展示图片的过程

我们写代码时,用给定的图片初始化一个UIImage对象并赋值给UIImageView.image,就在屏幕上呈现出该图片了,那么这个展示过程的原理是怎样的呢?

  1. self.testImageView.image = [UIImage imageNamed:@“xxx.png”]; 这时系统会去应用的mainBundle下寻找xxx.png,并生成一个CGImageRef指针指向它。
  2. RunLoop的CATransaction捕捉到testImageView属性的变化,准备把数据提交给GPU来渲染。
  3. 在把模型图层树(modelLayer tree)的数据提交给渲染图层树(renderLayer tree)之前,CPU会加载PNG图片到内存,并把PNG解码成位图。
  4. 在OpenGL的驱动下,GPU把位图数据渲染到屏幕上。

Note 1: 第1步并不会加载图片到内存,更不会解码,我是通过以下代码验证的:

    NSMutableArray *array = [NSMutableArray array];
    for (NSInteger i = 0; i < 100; i++) {
        [array addObject:[UIImage imageNamed:[NSString stringWithFormat:@"xiongbao_%ld.png", (long)i]]];
    }
    self.imageView.animationImages = [array copy];

这段代码执行后通过Xcode没有观察到内存的变化,只有在真正要展示的时候(本例就是[self.imageView startAnimating];),才会加载并解码。

Note 2: 关于modelLayer tree、presentationLayer tree和renderLayer tree,Apple官方有文档解释,网上也有些解释。我的理解是我们平时通过UIView直接操作的就是modelLayer。在一个动画过程中真正起作用的是presentationLayer,这时modelLayer的属性值仅代表的是这个动画的终点值,所以在动画过程中我们通过CALayer.presentationLayer才能获得正在动画中的layer。而renderLayer是负责渲染的,不论是modelLayer还是presentationLayer在真正需要渲染展示的时候需要把数据提交给renderLayer。

PNG?位图?

首先我们需要搞清楚一个概念,所谓PNG/JPEG都是在描述一种特定的文件格式,比如PNG它有自己的表示方法和压缩方法,图片格式的出现应该就是为了便于传输。不讨论利用硬件解码,GPU可以看作是一个只会并行高速渲染像素的傻子,它可搞不懂什么PNG格式JPEG格式,所以传输给GPU的应该是像素化后的数据。

值得一提的是,一张在硬盘里100K大小的PNG图片,解码成位图后可能有1M多,位图的大小 = 图片分辨率的宽 x 图片分辨率的高 x 单个像素点所占的空间大小。

文件IO VS CPU解码

那么问题来了:

  • 使用PNG图片 = 从本地读取100K数据 + CPU解码这100K数据。
  • 使用位图 = 从本地读取1+M的数据

其实就是文件IO与CPU运算的一次平衡。

目前为止我并没有想到什么有效的方法可以精确测量iPhone手机应用读取1M数据的损耗和CPU解码100K数据的损耗,但是说到平衡就是在强调我们应用的瓶颈在哪里,目前我们的直播间在各种功能效果都打开的情况下CPU是瓶颈,CPU的高速运转带来了手机发烫和CPU主频下调,紧接着就是掉帧,所以能为CPU减负是当务之急。

ASDK之后的又一个惊喜

世界这么大,我们能想到的,往往已经有先行者。在我尝试做这次优化时,机缘巧合碰到了FastImageCache,它带来的惊喜不亚于第一次碰到ASDK。所以接下来的优化思路会有借鉴FastImageCache的地方,膜拜学习吧。

Color Copied Images

这是Xcode提供的检测工具Instruments中Core Animation工具下的一个检测点,字如其意:复制图片。FastImageCache并没有涉及这项优化,但在我看它的README时自然想到了这点,什么意思呢?

上文中提到过,屏幕上的图片展示最终还是要靠GPU去渲染,具体如何去渲染牵扯到我们iOS框架的底层封装以及专门的图像渲染领域我们不做深究。 iOS渲染图像是通过OpenGL驱动GPU来做的,OpenGL有它所支持的颜色空间Color Space如下:

For color formats, there are more possibilities. GL_RED, GL_GREEN, and GL_BLUE represent transferring data for those specific components (GL_ALPHA cannot be used). GL_RG represents two components, R and G, in that order. GL_RGB and GL_BGR represent those three components, with GL_BGR being in reverse order. GL_RGBA and GL_BGRA represent those components; the latter reverses the order of the first three components. These are the only color formats supported (note that there are ways around that).
摘自OpenGL Wiki: https://www.khronos.org/opengl/wiki/Pixel_Transfer

可见OpenGL ES支持RGB BGR RGBA BGRA等颜色空间,那么当遇到它不支持的颜色空间时,就需要麻烦CPU先行转换成它所支持的颜色空间(转换过程必然带来开辟空间并写入数据的操作,也就是复制操作),就给CPU带来额外的负担,也就是Color Copied Images所在意的关键点。

Note 1: 在探究这一点时,我去检测了我们的APP,发现项目中的一些图标的颜色空间采用了Gray即灰度图像 - 维基百科,自由的百科全书,导致产生了复制图片的操作。这点我跟我们的设计同学确认过,他的回复是这种图片可能是早期用PS生成的,现在Sketch生成的图片都是RGB的,即使是黑白的图片。我觉得这个说法没有说服力,从原理上推测应该是为了控制图片的大小,因为灰度图像每个像素只占8bits,图片会小很多。

Note 2: 我们一直在说OpenGL,其实严格的说在移动端设备上使用的应该是OpenGL ES,即OpenGL for Embedded Systems。OpenGL的嵌入式版本,算是OpenGL的一个子集。

像素对齐

这是FastImageCache关注的一个点(其实在Instruments中也有体现:Color Misaligned Images),大意就是比如CPU每次读取固定的8 bytes的数据,现在有一个41 bytes大小的文件,那么CPU就需要读取6次,并且第6次读取的8 bytes还需要做多余的处理(只取第1个byte),那么自然会给CPU带来了额外的负担。解决的方法就是把这个文件填充到48 bytes,同时填充位不能影响原文件内容。

接下来我努力以相对专业的角度解释下,

  1. 为什么CPU要每次读取8 bytes的数据呢?这个牵扯到CPU的cache line size,这篇Data structure alignment - Wikipedia可以解决部分疑惑,也可以自行google。通过FastImageCache源码发现它针对iPhone给出的对齐值是64,在Apple官方我并没有找到A系列处理器的cache line size的信息,但看到过有讨论说是A9的cache line size为64。下面贴上FastImageCache这块的源码:
  2. 位图数据用矩阵来表示,以二维数组的形式存储在内存中。注意看附录中那段强制解码图片的代码,CGBitmapContextCreate(…)方法有个参数就是bytesPerRow,即每行的字节数。FastImageCache做到了bytesPerRow的大小是64的整数倍,具体一点用它的原文来说应该是:A properly aligned bytes-per-row value must be a multiple of 8 pixels × bytes per pixel. ,不仅是8的整数倍,还是bytesPerPixel的整数倍,可以推理这么做的目的是可以保证每行都存放了完整的像素,不会让一个像素数据跨行存储,也是优化的一个点。
  3. 可能大家也会有个疑问,为什么是以图片的每行(即bytesPerRow)为单位要求对齐,而不是图片数据的整体呢?那是因为GPU渲染图片是以行为单位进行的,并且是多行并行渲染。

MMAP

除了像素对齐,MMAPMMAP - Wikipedia也是FastImageCache关注的一个点。

这个优化点是什么意思呢?通常系统加载一张图片(或者别的文件),会先把图片从硬盘拷贝到内存中的内核空间,某个进程需要时再拷贝到该进程的用户空间,也就是进程加载一张图片的过程中发生了两次拷贝,并且在另一个进程也需要访问该图片时还会从内核空间再拷贝一份到它的用户空间(没有进程间共享内存)。

而MMAP会把硬盘中的图片地址直接映射到进程的用户空间中,在进程访问图片数据时触发缺页中断Demand paging - Wikipedia,才会把硬盘中的图片数据直接拷贝到内存中并更新页表,这样就只进行了一次拷贝,并且在另一个进程也需要访问该图片触发缺页中断时,可以加载内存中已经更新的页表,实现内存共享,同时可以想到的是内存共享带来了写入时需要加锁处理。

那么可以得出结论,MMAP相较于传统的文件读取,减少了数据拷贝的次数,减小了内存消耗。尤其对于展示图片这种只读不写的场景尤为适用。

总结

回到开始,对于有展示大量图片需求的场景,有几个优化点:

  1. 直接使用位图。
  2. 避免使用OpenGL不支持的颜色空间。
  3. 保证图片数据内存对齐。
  4. 用MMAP代替传统的文件读取。

最后还是要膜拜一下FastImageCache的库作者,感叹作为一名计算机工程师所拥有的知识面和基础功底的重要性,决定了我们在写代码时会去关注哪个层面的东西。

共勉!


附录1

为了膜拜这些领路人,我先贴一段从他们那里学来的iOS强制解码图片的思路,就是利用Core Graphics把图片绘制到一块开辟好的context上并保存成位图:

- (void)createBitmapWithImage:(UIImage*)image {
    CGFloat scaleWidth = image.size.width * image.scale;
    CGFloat scaleHeight = image.size.height * image.scale;
    CGColorSpaceRef colorSpace = CGImageGetColorSpace(image.CGImage);
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(image.CGImage);
    //decode
    CGContextRef context = CGBitmapContextCreate(NULL, scaleWidth, scaleHeight, 8, scaleWidth * 4, colorSpace, alphaInfo);
    CGContextDrawImage(context, CGRectMake(0, 0, scaleWidth, scaleHeight), image.CGImage);
    CGImageRef bitmapImageRef = CGBitmapContextCreateImage(context);
    //release
    CGColorSpaceRelease(colorSpace);
    CGContextRelease(context);
    CGImageRelease(bitmapImageRef);
}

2018.6.12更新:在最近的一次项目实践中,对附录1这块内容有了新的理解,因此补充一下。

Update附录1

除了附录1中的方法,还有一种方式可以对图片进行(提前)解码,即通过CGImageSourceCreateImageAtIndex(…)方法生成图片,此方法的参数可以设置立即缓存图片解码后的数据,如下:

- (void)createBitmapWithImage:(UIImage*)image {
    NSData *data = UIImagePNGRepresentation(image);
    CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
    CGImageRef bitmapImageRef = CGImageSourceCreateImageAtIndex(source, 0, (__bridge CFDictionaryRef)@{(id)kCGImageSourceShouldCacheImmediately: (id)kCFBooleanTrue});
    }

上述两种方式都是直接在内存中对图片进行解码,还有第三种方式,它是在把图片保存到本地时,以位(kUTTypeBMP)的形式保存,看代码:

- (void)saveBitmapWithImage:(UIImage*)image {
    NSString *localPath = [NSString stringWithFormat:@"%@testimage.bitmap", NSTemporaryDirectory()];
    CFURLRef url = CFURLCreateWithFileSystemPath(kCFAllocatorDefault,  (__bridge CFStringRef)localPath, kCFURLPOSIXPathStyle, false);
    CGImageDestinationRef destination = CGImageDestinationCreateWithURL(url, kUTTypeBMP, 1, 0);
    CGImageDestinationAddImage(destination, image.CGImage, 0);

以上。


参考

iOS图片加载速度极限优化—FastImageCache解析 « bang’s blog

http://www.cairuitao.com/fastimagecache源码分析/

http://blog.csdn.net/mg0832058/article/details/5890688

linux进程间通信之共享内存篇 - Gordon0918 - 博客园

osx - CGImageRef width doesn’t agree with bytes-per-row - Stack Overflow

最近的文章

对各模块间的互斥关系管理的小思考

背景最近在项目中遇到这样一个场景,在直播间的主播端有个功能区,里面是一些插件,这些插件之间在业务上存在互斥关系,也就是A处于开启状态时,B、C、D、E甚至是甲乙丙丁都不能打开。随着这块业务的增多,由于没有及时重构,导致互斥逻辑写的很是让人抓狂。假设有A、B、C、D四个互斥模块,当A要启动时,需要if (B is running) { Log("B is running"); return;}if (C is running) { Log("C is running"); ...…

重构 | iOS继续阅读
更早的文章

iOS优化界面渲染实践中的几点经验

一、圆角切割离屏渲染这个词已经老生常谈了,常说的圆角(切割)、遮罩mask、阴影shadow都会导致GPU离屏渲染,好在apple在iOS9以后优化了圆角切割,不再会导致离屏渲染了,但事实并不是这样的。场景比如我们直播间上方的观众列表中的圆形头像,在iOS10.3.2系统上用Instruments查看,还是有用黄色阴影标记的离屏渲染,而且由于直播间这种特殊场景,背景视频始终在渲染,这些圆角也就始终在离屏渲染,让人捉急。我各种猜想及google均无果(有人说设置imageView的backg...…

iOS继续阅读