小猪的博客

主流图片加载库所使用的预解码究竟干了什么

主流图片加载库所使用的预解码究竟干了什么

很多图片库,都会有一个类似叫做Force-Decode,Decode For Display之类的感念,很多人可能对这个过程到底是为了解决什么问题不清楚,这里写一个文章来说明它。

这里列举了各个图片库各自的说法,其实讲的都是完全相同的一个概念。

  • SDWebImage:使用了forceDecode, decompressImages的概念
  • YYWebImage:使用了decodeForDisplay的概念
  • Kingfisher:使用了backgroundDecode的概念

为什么需要这个过程,解决了什么问题

为了解释这个过程具体的解决问题,需要至少了解苹果的系统解码器的工作流程。

Image/IO和惰性解码

Image/IO库是苹果提供的,跨所有Apple平台的系统解码器,支持常见的各种图像格式(JPEG/PNG/TIFF/GIF/HEIF/BMP等)的编码和解码。同时,有丰富的接口来和诸如Core Graphics库协作。

常见的网络图像解码,由于拿到的是一个压缩格式,肯定需要想办法转换到对应的UIImage。UIImage可以分为CGImage-based和CIImage-based,后者相对开销大一些,主要是用作滤镜等处理,不推荐使用。所以基本上各种图片库解码,为了解码压缩格式,得到一个CGImage,都是用了Image/IO的这个API:

CGImageSourceCreateImageAtIndex

实际上,Image/IO,除了调用具体的解码器产生图像的Bitmap以外,为了和Core Graphics库协作,也直接以CGImage这个数据结构来传递,但是他采取了一种惰性解码的创建方式。因此这里首先要了解CGImage初始化的接口和对应的行为:

CGImageCreate

这里面其他参数都好理解,具体看一个provider参数,这里面需要传入一个CGDataProviderRef,它是一个关于描述怎么样去获取这个Bitmap Buffer二进制数据的结构。再来看看CGDataProvider的初始化方法,这时候发现它有多种初始化方式,决定了后面的行为。

这个方法,允许接受一个CGDataProviderCallbacks参数,看说明,可以知道,这个callbacks是一系列函数指针回调,目的是提供一个sequential-access的访问模式,同时Data Buffer会被copy出去。同时,由于传入的是callbacks,可以做到不立即提供Data Buffer,而是在未来需要的时候再触发。

这个方法,类似于CGDataProviderCreate,但是注明了这个callbacks生成的Data Buffer不会被Copy,Core Graphics只会直接访问返回的Data Buffer指针,需要自己管理好内存。

这个方法,需要提供一个CFData,同时也不会Copy这个CFData。在Release的同时由Core Graphics自动释放CFData的内存,开发者不需要管理内存。

剩余的具体初始化方法可以看文档说明,总而言之,CGDataProvider提供了各种各样的访问模式,如直接访问,拷贝访问,惰性访问等。而现在问题就来了,前面说到,Image/IO创建CGImage的时候,也需要提供一个DataProvider来指明图像的Bitmap Buffer数据从哪里获取,它是具体用了什么方式呢?

答案是使用了一个私有APICGImageCreateWithImageProvider,经过查看,这个方式实际类似CGDataProviderCreateDirect,也就是通过一组callbacks,提供了一个直接访问,允许惰性提供Data Buffer的方式。换句话说,这也就意味着,Image/IO,其实采取的是一种惰性解码方式。解码器只预先扫描一遍压缩格式的容器,提取元信息,但是不产生最终的Bitmap Buffer,而是通过惰性回调的方式,才生成Bitmap Buffer。

换句话说,通过所有CGImageSourceCreateImageAtIndex这种API生成的CGImage,其实它的backing store(就是Bitmap)还没有立即创建,他只是一个包含了一些元信息的空壳Image。这个CGImage,在最终需要获取它的Bitmap Buffer的时候(即,通过相应的API,如CGDataProviderCopyDataCGDataProviderRetainBytePtr),才会触发最后的Bitmap Buffer的创建和内存分配。

Image/IO和Force Decode

理解到上面Image/IO的惰性解码行为,理解了上面一点,现在说明Force Decode所解决的问题。

众所周知,iOS应用的渲染模式,是完全基于Core Animation和CALayer的(macOS上可选,另说)。因此,当一个UIImageView需要把图片呈现到设备的屏幕上时候,其实它的Pipeline是这样的:

  1. 一次Runloop完结 ->
  2. Core Animation提交渲染树CA::render::commit ->
  3. 遍历所有Layer的contents ->
  4. UIImageView的contents是CGImage ->
  5. 拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上 ->
  6. Surface(Metal或者OpenGL ES)渲染到硬件管线上

这个流程看起来没有什么问题,但是注意,Core Animation库自身,虽然支持异步线程渲染(在macOS上可以手动开启),但是UIKit的这套内建的pipeline,全部都是发生在主线程的。

因此,当一个CGImage,是采取了惰性解码(通过Image/IO生成出来的),那么将会在主线程触发先前提到的惰性解码callback(实际上Core Animation的调用,触发了一个CGDataProviderRetainBytePtr),这时候Image/IO的具体解码器,会根据先前的图像元信息,去分配内存,创建Bitmap Buffer,这一步骤也发生在主线程。

屏幕快照 2019-01-18 下午1.44.45

这个流程带来的问题在于,主线程过多的频繁操作,会造成渲染帧率的下降。实验可以看出,通过原生这一套流程,对于一个1000*1000的PNG图片,第一次滚动帧率大概会降低5-6帧(iPhone 5S上当年有人的测试)。后续帧率不受影响,因为是惰性解码,解码完成后的Bitmap Buffer会复用。

所以,最早不知是哪个团队的人(可能是FastImageCache,不确定)发现,并提出了另一种方案:通过预先调用获取Bitmap,强制Image/IO产生的CGImage解码,这样到最终渲染的时候,主线程就不会触发任何额外操作,带来明显的帧率提升。后面的一系列图片库,都互相效仿,来解决这个问题。

具体到解决方案上,目前主流的方式,是通过CGContext开一个额外的画布,然后通过CGContextDrawImage来画一遍原始的空壳CGImage,由于在CGContextDrawImage的执行中,会触发到CGDataProviderRetainBytePtr,因此这时候Image/IO就会立即解码并分配Bitmap内存。得到的产物用来真正产出一个CGImage-based的UIImage,交由UIImageView渲染。

ForceDecode的优缺点

上面解释了ForceDecode具体解决的问题,当然,这个方案肯定存在一定的问题,不然苹果研发团队早已经改变了这套Pipeline流程了

优点

  • 可以提升,图像第一次渲染到屏幕上时候的性能和滚动帧率

缺点

  • 提前解码会立即分配Bitmap Buffer的内存,增加了内存压力。举例子对于一张大图(2048*2048像素,32位色)来说,就会立即分配32MB的内存。

由此可见,这是一个拿空间换时间的策略。但是实际上,iOS设备早期的内存都是非常有限的,UIKit整套渲染机制很多地方采取的都是时间换空间,因此最终苹果没有使用这套Pipeline,而是依赖于高性能的硬件解码器+其他优化,来保证内存开销稳定。当然,作为图片库和开发者,这就属于仁者见仁的策略了。如大量小图渲染的时候,开启Force Decode能明显提升帧率,同时内存开销也比较稳定。

WebP和软件解码

当我们说完Image/IO系统库和Force Decode关系后,再来看看另一种情形。近些年来,一些新兴的图像压缩格式,如WebP,得益于开源,高压缩率,更好的动图支持,得到了很多开发者青睐。

然而,这些图像格式,并没有被iOS系统解码器所支持,也没有对应的硬件解码。因此,现有的图片库在支持新图像格式的时候,都采取了使用CPU进行软件解码来处理。这些软件解码器,大部分是为了跨平台而实用的,因此,一般都有一个接口直接产出一个Bitmap Buffer来用于渲染。如WebP的官方解码器libwebp,就有这样一个接口:

1
WEBP_EXTERN VP8StatusCode WebPDecode(const uint8_t* data, size_t data_size, WebPDecoderConfig* config);

上面我们知道CGImage和CGDataProvider的不同初始化方式,开发者面临这样的接口,有两个选择:

  1. 使用CGDataProviderCreateWithData,直接把产出的Bitmap buffer存储到CGImage中
  2. 参考Image/IO,使用CGDataProviderCreateDirect,使用惰性解码

当然,为了最大程度的利用苹果系统的那套Pipeline和现有代码流程,第一直觉的使用方式当然是方案2。然而,理想是丰满的,现实是骨感的。之所以Image/IO能够采取惰性解码这一套流程,最大的原因在于Image/IO的原生图像格式都是硬件解码,且解码速度足够快

同样的方式,套用到WebP上,反而会带来更大的问题。首先,WebP格式自身的压缩算法采取了VP8,比起JPEG/GIF的压缩算法要复杂的多,开销大。第二,libwebp只有软件解码的实现,无法利用硬件来加快解码速度。

注:YY的作者有专门跑过测试,对于iPhone 6上,同样压缩比的有损JPEG和WebP相比,解码速度慢大概50%-100%,无损的PNG和WebP相比比较接近。参考:https://blog.ibireme.com/wp-content/uploads/2015/11/image_benchmark.xlsx

所以,主流图片库最终的选择方式,都是方案1,即立即生成了一个含有Bitmap Buffer的CGImage。这样,到最终UIImageView渲染的时候,也不会有额外的主线程解码的开销,除了需要提前分配内存以外别的还好。

WebP软件解码和Force Decode

前面说到,对于WebP等非硬件解码器支持的图片压缩格式,大多数图片库采取了方案1。但是现有的一些图片库(如SDWebImage/YYWebImage),仍然对这个非空壳的CGImage,执行了Force Decode的过程,按理论上说已经有了Bitmap Buffer,不会触发主线程解码,这又是为什么?

这个原因,是源于先前的Force Decode的实现机制,利用到了CGContextDrawImage这个接口。

CGContextDrawImage,内部实现非常复杂,因为对于一个CGImage来说,他只是Bitmap Buffer+图像元信息的合集,但是一个CGContext,是有一个固定的ColorSpace,渲染模式等等信息,是和具体的上下文相关的。

因此,当通过这个API画在一个画布上时,会触发很多细节的逻辑,这里举几个比较有影响的。

  1. 首先会根据CGImage的ColorSpace转换到CGContext的ColorSpace(比如说CGImage使用了sRGB,CGContext用了P3+宽色域),需要去对Bitmap的每个像素做转换;如果Bitmap排列(如CGImage采取RGB888,CGContext采取BGRA8888)不同,也会以CGContext为准进行转换。
  2. CGContext如果有Blend Mode,也会在此流程中做Alpha合成。
  3. 如果CGContext和大小和CGImage不同,会触发对应的重采样过程,开发者可以控制重采样的质量高低
  4. 还有一个关于内存管理的,由于CGContext目标就是为了做渲染层,因此它依赖这个假设,当你调用CGContextDrawImage的时候,会直接把取到的Bitmap Buffer,立马提交到render server进程上(通过mmap),这样最后在渲染Pipeline(前文提到)中,就可以省去第5步(拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上)。见下:

其实对于大部分图片库的Force Decode来说,因为都开的是一个和CGImage同大小的空白画布,这里主要是第1和第4项会影响到性能。一些图片库,因此依旧保留了Force Decode的流程,也有各种各样的具体缘由。

WebP软件解码进行Force Decode的优缺点

了解了为什么对于WebP等软件解码,依然使用Force Decode的缘由,再来看看这种Case下的优缺点

优点

  1. 能够提前把Bitmap Buffer转移到渲染进程上,减少了未来渲染时的内存拷贝操作(虽然比起解码来说,这部分时间相当的小)
  2. 如果原始解码出来的Bitmap Buffer,iOS硬件屏幕不直接支持(如RGB888,CMYK),会提前转换好,避免渲染时主线程的转换
  3. (?)可以从Xcode视觉上看起来App占用内存变小,因为Bitmap Buffer提前拷贝到render进程了

缺点

  1. 在已经有Bitmap Buffer的情况下,再开一个画布,并触发Draw,大图会出现一个临时的内存峰值(约250%~300%原始Bitmap Buffer的占用)

可以看出,这也是一个类似空间换时间的策略。当然,这个策略的优势没有Image/IO那样大,因为实际上转换和拷贝内存的性能开销,比起解码和创建Bitmap Buffer都是非常低的。但是一些图片库把这个选择权利交给了用户,而自己不做这个策略选择。

PS小轶闻:SDWebImage其实最早只有对Image/IO的那个ForceDecode流程,后来在4.0加入WebP支持的时候,也不清楚这个流程影响,顺便就一块使用这套流程了。可以说是所谓的误打误撞。

总结

这篇文章基本介绍了Image/IO的惰性解码流程,以及Force Decode这套流程它所解决的问题,以及优缺点。无论对图片库作者,还是图片库进阶使用者,都解释了相关的疑问。希望对图片编解码方向有兴趣的同学可以多多学习交流。