一、图像从文件到屏幕过程
首先明确两个概念:水平同步信号、垂直同步信号。
CRT 的电子枪按照上图中的方式,从上到下一行一行的扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次的扫描。当电子枪切换到新的一行准备扫描时,显示器会发送一个水平同步信号(Horizonal Synchronization),简称HSync;完成一帧画面绘制后,电子枪会回到原位,显示器会发送一个垂直同步信号(Vertical Synchronization),简称VSync。
CPU/GPU 等在这样一次渲染过程中的具体分工:
CPU:计算视图 frame、图片解码、需要绘制纹理图片通过数据总线交给 GPU
GPU:纹理混合、顶点变换与计算、像素点的填充计算、渲染到帧缓冲区
时钟信号:垂直同步信号 V-Sync / 水平同步信号 H-Sync
iOS 设备双缓冲机制:显示系统通常会引入两个帧缓冲区,双缓冲机制
CUP 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,之后视频控制器按照 VSync 信号逐行读取帧缓冲区中的数据,最后经过各种数模转换传递给显示器显示。通常计算机显示图片到屏幕是 CPU 与 GPU 协同合作完成一次渲染。
对应用来说,图片是最占用手机内存的资源,将一张图片从磁盘中加载出来,并最终显示到屏幕上,中间经过了一系列复杂的处理过程。
二、图片加载的工作流程
假设使用 +imageWithContentsOfFile: 方法从磁盘中加载一张图片,这个时候的图片并没有解压缩;
然后将生成的 UIImage 赋值给 UIImageView;
接着一个隐式的 CATransaction 捕获到了 UIImageView 图层树的变化;
在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:
- 分配内存缓冲区用于管理文件 IO 和解压缩操作;
- 将文件数据从磁盘读到内存中;
- 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;
- 最后 Core Animation 中 CALayer 使用未压缩的位图数据渲染 UIImageView 的图层。
- CPU 计算好图片的 Frame,对图片解压之后就会交给 GPU 来做图片渲染
渲染流程
- GPU 获取获取图片的坐标
- 将坐标交给顶点着色器(顶点计算)
- 将图片光栅化(获取图片对应屏幕上的像素点)
- 片元着色器计算(计算每个像素点的最终显示的颜色值)
- 从帧缓存区中渲染到屏幕上
图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。
三、为什么要解压缩图片
既然图片的解压缩需要消耗大量的 CPU 时间,那么是否可以不经过解压缩,而直接将图片显示到屏幕上呢?答案是否定的。
位图就是一个像素数组,数组中的每个像素就代表着图片中的一个点。我们在应用中经常用到的 JPEG 和 PNG 图片就是位图。
NSString * filePath = [[NSBundle mainBundle] pathForResource:@"Large" ofType:@"png"];
UIImage * image = [UIImage imageWithContentsOfFile:filePath];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));
<00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00007c54 540a540a 540a540a 540a540a 7c000000 00000000 250a5454 0a540a54 0a540a0f 00000000 2554540a 540a540a 540a540a 540a5459 0000258c 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f2f2f2f 2f8c0101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010170 13131313 13131313 13131313 13131313 13131313 13131301 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 16161616 16161616 16161670 70701616 70137070 70707070 70131313 13131313 13131313 13131370 70161616 01010101 01010101 01010101 01010101 01010101 01010101 01010101 01010101 010101a2 03032525 00007c54 540a540a 540a540a 543f0000 00000000 00000000 00000000 00000000 5254540a 540a540a 540a540a 547c0000 00000000 00000000 0000000a 54540a54 0a540a54 0a520000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 0000283d 48074848
事实上,不管是 JPEG 还是 PNG 图片,都是一种压缩的位图图形格式。只不过 PNG 图片是无损压缩,并且支持 alpha 通道,而 JPEG 图片则是有损压缩,可以指定 0-100% 的压缩比。在苹果的 SDK 中专门提供了两个函数用来生成 PNG 和 JPEG 图片:
// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);
// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);
在磁盘中的图片渲染到屏幕之前,必须先要得到图片的原始像素数据,才能执行后续的绘制操作,这就是为什么要对图片解压缩。
四、解压缩原理
既然图片的解压缩不可避免,又不想让它在主线程执行,影响应用的响应性,那么是否有比较好的解决方案呢?
前面已经提到了,当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在子线程提前对图片进行强制解压缩。
强制解压缩的原理就是对图片进行重新绘制,得到一张新的解压缩后的位图。其中,用到的最核心的函数是:
/**
* data : 如果不为 NULL,它应该指向一块大小至少为 bytesPerRow * height 字节的内存;如果为 NULL,系统会自动分配和释放所需的内存
*
* width : 位图的宽度。传入图片的像素宽度即可
* height : 位图的高度。传入图片的像素高度即可
*
* bitsPerComponent : 像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可
*
* bytesPerRow : 位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。
当指定 0/NULL 时,系统不仅会自动计算,而且还会进行 cache line alignment 的优化。
*
* space : 颜色空间,一般使用 RGB
* bitmapInfo : 位图的布局信息。kCGImageAlphaPremultipliedFirst
*/
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate( void * __nullable data,
size_t width,
size_t height,
size_t bitsPerComponent,
size_t bytesPerRow,
CGColorSpaceRef cg_nullable space,
uint32_t bitmapInfo) CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);
五、YYImageSDWebImage 开源框架实现
YYImage 用于解压缩图片的函数 YYCGImageCreateDecodedCopy 存在于 YYImageCoder 类中,核心代码:
CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay)
{
...
if (decodeForDisplay) { // decode with redraw (may lose some precision)
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// BGRA8888 (premultiplied) or BGRX8888
// same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate( NULL,
width,
height,
8,
0,
YYCGColorSpaceGetDeviceRGB(),
bitmapInfo);
if (!context) return NULL;
// decode
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
return newImage;
}
else {
...
}
}
它接受一个原始的位图参数 imageRef ,最终返回一个解压缩后的位图 newImage ,中间主要经过了以下三个步骤:
- 使用 CGBitmapContextCreate 函数创建一个位图上下文;
- 使用 CGContextDrawImage 函数将原始位图绘制到上下文中;
- 使用 CGBitmapContextCreateImage 函数创建一张新的解压缩后的位图。
事实上,SDWebImage 中对图片的解压缩过程与上述完全一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差别。
性能对比:
- 解压 PNG 图片 SDWebImage > YYImage
- 解压 JPEG 图片 SDWebImage < YYImage
六、总结
图片文件只有在确认要显示时 CPU 才会对其进行解压缩,因为解压非常消耗性能。解压过的图片就不会重复解压,会缓存。
图片渲染到屏幕的过程:
读取文件 -> 计算 Frame -> CPU 图片解码 -> 解码后纹理图片位图数据通过数据总线交给 GPU -> GPU 获取图片 Frame -> 顶点变换计算 -> 光栅化 -> 根据纹理坐标获取每个像素点的颜色值(如果出现透明值需要将每个像素点的颜色*透明度值) -> 渲染到帧缓存区 -> 渲染到屏幕
细究离屏渲染和渲染中的细节处理,就需要掌握 OpenGL ES/Metal 这 2 个图形处理 API。