iOS 开发 - 衡量图片加载及优化思路

iOS 开发 - 衡量图片加载及优化思路

原文地址

图片展示是移动端开发非常重要的功能, 从展示 App 内置的图片到 Instagram 这样的图片 Feed 流应用, 图片加载消耗了巨大的带宽, 占用手机大部分的内存, 也在指尖的滑动过程中不断消耗着 CPU 资源. 本文将以优秀的开源库 SDWebImage 为例, 从开发的角度来衡量图片加载这一功能, 并会涉及到缓存/解码/图片格式等问题的研究, 从而引出后续优化方案.

SDWebImage 加载图片的流程

SDWebImage 是基于 Objective-C 的异步加载图片框架, 以 UIImageView 分类的形式使用, 一行代码解决图片加载问题, 下面先简单介绍下 SDWebImage 的工作流程.

SDWebImageManager

SDWebImage 以 UIImageView / UIButton / UIView 等作为入口, 供开发者使用, 然后都会走到 SDWebImageManager 中进行后续逻辑处理, 比如针对 url 做多任务封装, 以避免多次调用同一个 url 产生资源浪费; 同步调用缓存以尽快展示图片, 异步调用下载模块下载等.

缓存 SDImageCache

SDImageCache 实现了内存/磁盘的二级缓存.加载图片时, 会优先从内存中取图片, 否则在对应磁盘目录下寻找图片.

内存中的缓存首先是使用了 NSCache 实现缓存区, NSCache 可以在系统内存不足时候自动释放缓存, 然后是使用 NSMapTable 实现对 UIImage 实例的弱引用. 当某张图片已经被 NSCache 缓存释放, 但是图片还被其他模块所持有时(被其他模块强引用, 所以还存在于内存中), 通过 NSMapTable 弱引用依旧可以访问到该图片, SDImageCache 的内存缓存依旧可以起到作用.
而磁盘缓存则是直接将源文件的二进制 NSData 存储到磁盘上, 当内存缓存未命中时, 从磁盘读取数据再解码. 此外当超出磁盘使用上限时, 会按时间顺序清除一半的磁盘缓存.

下载 SDWebImageDownloader

下载模块, 图片 url 如果没有有命中缓存, 会使用 SDWebImageDownloader 来管理下载, 每次下载会创建 SDWebImageDownloaderOperation 实例来执行单个下载任务, Downloader 会持有一个并发队列管理下载的 Operation, 因此可以支持多个下载任务同时进行.

解码 Decode

先解释一下"解码"这个概念, 当图片以 JPG 或者 PNG 等格式存储在磁盘中, 图片内的像素信息此时处在被压缩过的状态(Data Buffer), 然后我们通过 imageWithData: 等方法将图片文件转化为 UIImage 对象, 此时只是将图片的数据加载到了内存, 也不能直接展示; 可以直接展示在屏幕上的图片是以像素点组成, 每个像素点描述该点的颜色信息, 这样的数据才可以直接展示在屏幕上(Image Buffer). 上述图片的两个状态的转换, 即从 Data Buffer 转换为 Image Buffer 的过程称为解码.

根据 WWDC 2018 session 219 Image and Graphics Best Practices , 未解码的图片在从 UIImage 加载到 UIImageView 上时, 会触发隐式解码, CPU 需要将未解码的 UIImage 转化为位图(Image Buffer), 然后再转交给 GPU 渲染(Frame Buffer)并展示在屏幕上. 解码这个过程会大量消耗 CPU 进而导致卡顿. 因此在 SDWebImage 等第三方库中, 都会在下载或读取磁盘缓存后, 在背景线程提前解码图片, 最后再将解码后的 UIImage 交给 UIImageView 展示, 从而提高图片展示的流畅度. 下图即为图片展示的流程:

ImageDecode.png

通常我们解码的核心代码如下:

UIImage *image = [UIImage imageWithData:data];
//创建上下文
CGContextRef context = CGBitmapContextCreate(); 
// 在上下文中绘制原图
CGContextDrawImage(context, rect, image.CGImage); 
// 创建得到解码后的图片
CGImageRef newImageRef = CGBitmapContextCreateImage(context); 
// 释放上下文
CGContextRelease(context); 

或者使用 YYKit 提供的代码可以更快解码:

// 创建ImageSource
CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)data, 0); 
// 创建未解码的 CGImage
CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, NULL);

// 获取一些额外信息
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
CGColorSpaceRef space = CGImageGetColorSpace(imageRef);
size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);
size_t bitsPerPixel = CGImageGetBitsPerPixel(imageRef);
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);

// 获取图片的数据源
CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
// 从数据源获取直接解码的数据(主要耗时)
CFDataRef data = CGDataProviderCopyData(dataProvider);
CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(data);
CFRelease(data);
// 生成新的图片
CGImageRef newImageRef = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, space, bitmapInfo, newProvider, NULL, false, kCGRenderingIntentDefault);
UIImage *newImage = [[UIImage alloc] initWithCGImage:newImageRef];
CGImageRelease(newImageRef);
CFRelease(newProvider);
CFRelease(space);

解码之后的图片会占用较高的内存, 比如一张 1080 * 1920 的图片, 每个像素需要 RGBA 共 4 Byte 来描述, 消耗的内存为

1080 * 1920 * 4 = 8,294,400 Byte = 7.91 MB

即一张普通 1080 * 1920 图片解码之后会占用 7.91M 内存, 在 iOS 系统中, 占用内存会间接使部分内存被压缩, 导致额外的 CPU 的消耗. 这个情况可以使用下采样(降采样) Downsamplinng 技术来减少解码后的内存, 参照 WWDC 截图实现即可:


ImageDownsmaple.png

制定图片的加载指标

上面我们以 SDWebImage 为例, 介绍了图片展示分为 下载/读取磁盘缓存->加载到内存&解码->展示 这个几个步骤, 现在我们需要建立一套指标来衡量这整个流程, 并为以后优化用户浏览图片的体验提供改进方向以及数据支撑.

目前我拟定了三个指标来衡量图片的展示:

  1. 加载速度. 这个指标是用户体验最直接的体现.
  2. 解码速度. 主要是针对动图制定的指标, 比较大的 Gif 或者 Animated WebP 如果预先解码每一帧的话, 解码时耗时可能达到 100ms 以上, 需要针对此场景优化.
  3. 缓存命中率. SDWebImage 的默认缓存策略是触发 Memory Warning 才会一次性释内存缓存, 虽然简单粗暴, 但是的确存在优化空间.

指标 1: 加载速度

图片的加载速度是用户浏览图片的第一感觉, 图片加载的够快就是好的体验, 图加载的慢用户就会觉得 App 卡顿. 衡量图片的加载速度, 实际是衡量图片的加载耗时, 而不是单纯的下载速度.

图片一般有三种来源: 内存缓存/磁盘缓存/下载. 内存缓存一般从加载到展示, 耗时不超过 1ms, 没有统计的意义; 磁盘缓存耗时一般低于 10ms, 主要耗时集中在磁盘IO 以及解码上, 可以与解码指标一同处理; 下载的图片耗时主要集中在下载这一步, 小部分消耗在解码过程. 因此我们可以简单处理加载耗时指标: 统计下载开始到解码完成的时间即可. 命中缓存的情况不用统计.
整体的流程如下图所示:

SDWebImage流程图.jpg

PS. 由于每天加载图片次数很多, 最终数据巨大, 一定要控制数据上报的灰度比例, 比如百分之一或者千分之一的用户才做上报, 比例可以自行决定.

一秒加载率/三秒加载率

但是如果只是简单使用下载耗时作为衡量指标, 无法直观的描述用户的体验, 1s 的下载耗时, 和 2s 耗时, 到底有多大区别呢? 更何况每天的平均下载耗时会有一定波动, 不太适合作为衡量指标. 因此为了更直观描述用户的浏览体验, 在此引入 "一秒加载率/三秒加载率" 这两个指标. 一秒加载率即图片秒开的比例, 用户在信息流中滑动时, 一秒加载基本能确保用户看到了这张图. 三秒加载率即三秒内图片加载完成的比例, 是用户在界面稍作停留就能看到图片的比例. 有了这两个指标作为参考, 后续优化会有明显的针对方向.

下载速度的优化思路

使用更高压缩率的格式

在此继续探讨后续加载速度优化的思路, 首先我们可以使用更高压缩率的格式, 比如 JPG 由于使用有损压缩, 剔除了部分细节信息因此有比较好的压缩率, 会比无损压缩的 PNG 加载更快. 使用谷歌的 Guetzli 算法可以对 JPG 进一步压缩, 但是这个压缩耗时很久, 不适合在客户端处理.

考虑第三方库支持我们可以使用压缩率更高的 WebP 来取代 JPG/PNG, 使用 Animated WebP 取代 Gif 动图, 这样也能获得更快的加载速度.

HEIF (High Efficiency Image Format,高效率图片编码)

考虑到未来的话, 支持 HEIF 格式也是一个优化方向.


HEIF格式.jpg

我们知道 PNG 以位图的形式来存储图片, 然后使用 DEFLATE 算法来做无损压缩, 最终文件压缩率很低; JPG 将图片划分为 8 * 8 的小块, 每一个小块内的像素数据通过离散余弦变换(DCT 变换) 保留轮廓等低频信息, 剔除细节纹理等高频信息(可以根据压缩参数控制过滤的程度), 以达到较高的压缩率, 同时 JPG 很适合用来展示生动的图(因为像素关联较强), 而不适合展示 Logo / 海报等有着明显边缘的图(明显的边缘往往会被模糊掉). WebP 引入了帧内预测, 在每一个小块的八个方向上做了优化, 号称压缩效率相比 JPG 提升40%(实际提升并没有这么大 Orz..), WebP 同时也支持无损压缩以及图片透明度, 在无损的情况下文件大小要比 PNG 小26%.

而基于 HEVC/H.265 编码的 HEIF 格式能支持更灵活的分块, 从 64 * 64 一直到 8 * 8 均可支持, 帧内预测编码也增大到了 33 个方向, 相对于 JPG 进一步压缩了冗余,提升了压缩率.

根据HEIF白皮书(JCTVC-V0072),在标注测试集上, JPG 体积平均是 HEIF 的2.39倍(即增加139%)。HEIF 在 iOS 上从 iPhone7/iOS 11 开始便有支持, 可以直接用系统层面的编解码器.

提升下载速度 - QUIC(Quick UDP Internet Connections)协议

QUIC 协议是一种全新的基于 UDP 的 web 协议, 同时具有 TCP 的可靠性/拥塞控制/流量控制等特性, 并且在 TCP 协议的基础上做了一些改进,比如避免了队首阻塞, 另外 QUIC 协议具有 TLS 的安全传输特性,实现了 TLS 的保密功能,同时又使用更少的RTT建立安全的会话.

参考文章QUIC协议初探-iOS实践,

QUIC协议下载的总耗时比 Http2 要小,相对于 Http2,wifi下,QUIC在下载总耗时上提升了 14% 左右,4G 下提升18%左右.

后续如果需要进一步优化图片加载速度, 可以考虑使用 QUIC 协议来进行下载.

指标 2: 解码耗时

在一般的流程中, 图片从被压缩的数据转换为展示到屏幕上的像素点会经历解码. 内存缓存中的图片一般已经解码过(如果释放掉解码后的 CGImage 以节省内存, 再次使用前也应先解码, 这个情况暂不讨论), 因此不用统计解码耗时; 而下载以及磁盘缓存的图片一般没有被解码(如果将解码后的位图数据缓存到磁盘, 这一步就没有解码, 如FastImageCache, 这个情况也先不讨论), 使用之前会经历解码, 因此我们只需要统计并上报来源于下载或磁盘的图片的解码时间.

解码耗时的优化思路

PNG/JPG 解码速度一般小于 1ms, 优化意义不大; WebP 以及 Gif 解码速度比较慢, 是此指标优化的重点. Gif 以及 Animated WebP 可以使用逐帧加载的方案, 只解码第一帧, 然后保留图片 Data, 在需要展示其他帧的时候再解码对应帧, 可以大幅优化解码时间.

指标 3: 缓存命中率

缓存(Cache)这个概念源于计算机早期, CPU 需要直接去主存读取数据, 然后再做 CPU 运算, 但是由于 CPU 内部运算速度比主存读取速度快太多, 导致拖慢了整体的运算速度. 80386 芯片引入了缓存机制, 直接将数据暂存在芯片中, 有效减少了访问主内存的次数, 从而提高 CPU 的运算能力. 此后在这些存在明显速度差的场景比如 CPU->内存->磁盘->网络访问, 这四个场景彼此之间的处理速度都有数千倍的差距, 缓存机制有着广泛的应用.
以下是维基百科对缓存概念的描述:

如今缓存的概念已被扩充,不仅在CPU和主内存之间有Cache,而且在内存和磁盘之间也有Cache(磁盘缓存),乃至在磁盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache。

在 SDWebImage 中, 设计了内存/磁盘二级缓存, 内存默认会持续增长, 触发MemoryWarning 才会释放内存, 虽说内存就是拿来用的, 但是这样的策略会使得 App 的内存指标相当糟糕, 大量使用内存会触发 Compressed Memory, 消耗 CPU 资源, 当内存吃紧时, 大内存应用会被优先杀死等等; 而磁盘缓存则是在超过最大尺寸后, 会释放一半较早访问过的图片.

我们如果想优化这个缓存机制, 就需要一个指标来评判我们的改动是真的有效, 使用缓存命中率可以衡量新的缓存机制. 内存缓存命中率是主要的评判标准, 我们不能为了节省内存就一味地少使用内存, 也要考虑到部分场景批量请求图片导致缓存直接被耗尽(即缓存污染). 磁盘缓存命中率可以对内存缓存机制做有效的支撑, 毕竟可用磁盘空间一般比可用内存空间多得多.

缓存淘汰算法

LRU 和 LFU 算法是最基础的两个缓存淘汰算法, LRU 是优先缓存最近使用过的资源, 淘汰最近没有使用过的资源, 但是 LRU 在应对批量临时资源时候性能会很差, 比如进入页面, 大量请求图片然后直接退出, 根据最近使用原则, 这些临时资源会直接排在缓存队列的最前面, 然后才能被被慢慢淘汰掉. LFU 是优先缓存使用最多的资源, 每个资源需要记录使用次数, 相对于 LRU 的实现会更为复杂一些.

在后续的缓存改进中, 我们可以使用 LRU-K 算法, 以解决 LRU 算法“缓存污染”的问题, 提升缓存的命中率以及缓存逻辑的性能. 关于 LRU-K 算法可以参考缓存淘汰算法 - LRU文章.

参考文章

  1. WWDC 2018 session 219 Image and Graphics Best Practices
  2. iOS 高性能图片架构与设计
  3. 缓存淘汰算法 - LRU
  4. 来瞧瞧webp图像强大的预测算法
  5. QUIC协议初探-iOS实践

你可能感兴趣的:(iOS 开发 - 衡量图片加载及优化思路)