iOS 动图优化实践
GIF 和 Animated WebP 是互联网上最主流的动图格式, 但是在 iOS 开发中, 原生的 UIImage 并不直接支持 GIF 以及 Animated WebP 的展示, 因此有了各种优秀的第三方开源方案, 例如 SDWebImage
以及 YYImage
等. 这篇文章将以我在开发中优化动图的实践为基础, 来介绍不同方案的思路以及优劣, 并给出优化的方案.
1. 目前动图展示的问题以及优化结果
目前项目采用 SDWebImage
加载动图, 在异步线程提前解码图片, 但是这个方案有棘手的内存问题, 解码动图所有帧在低端机容易直接 OOM, 图文流滑动过程中内存一直在增长, 在触发系统的 MemoryWarning
通知之前就直接触发了 NSMallocException
(Failed to grow buffer) 的崩溃, 因此不得不针对低端机做只展示尾帧的处理.
我经过两个月灰度逐步上线了动图的逐帧解码方案, 并封装了图片的通用加载组件, 优化带来如下改善:
- 解决低端机展示动图频繁崩溃的问题, 包括
OOM
NSMallocException
CPU 负载过高
等. - 在每一帧都解码情况下, 优化前后首帧加载时长持平.
- 图片内存缓存命中率由 65% 上升至 80%.
- 相比主流开源方案 YYAnimatedImageView 以及 SDAnimatedImageView, CPU 占用更少, 内存使用更少, 并且有更好的流畅度.
- 封装成通用图片加载方案, 支持动图静态图复用, 支持 GIF/Animated WebP/APNG 三种动图格式.
2. iOS 展示动图的方法
首先介绍几种常用的展示动图的方法:
2.1 使用 ImageIO.framework 来展示动图
如上面所说, UIImage 不直接支持展示动图, 直接用 [UIImage imageNamed:]
以及 [UIImage imageWithData:]
方法直接加载动图文件, 只会得到一张静态图. 使用原生 API 展示 GIF 需要使用 ImageIO.framework
从 data 中解析出每一帧, 同时通过 UIImageView 的 animationImages
属性来达成动画的支持. 示例代码如下:
1 | // 产生 CGImageSourceRef |
2.2 FLAnimatedImage
FLAnimatedImage 是 FlipBoard 早期开源的动图加载库, 实现思路是一个典型的消费者-生产者模型. FLAnimatedImageView 消费者, 通过 CADisplayerLink 定时展示当前帧, FLAnimatedImage 是生产者, 在异步线程通过 CGImageSourceCreateImageAtIndex
拿到对应的帧, 并通过一系列缓存策略来调整内存的占用, 并提前做好解码, 在 CADisplayerLink
触发时展示对应的帧即可.
但是这个库毕竟太老了, 性能表现不及 YYAnimatedImageView
, 先不做参考.
2.3 YYAnimatedImageView 的实现与不足
YYAnimatedImageView 的实现与 FLAnimatedImage 类似, 只是 YYAnimatedImageView 结合了 YYImageCoder 来做动图帧的解析, 因此支持多种格式, 在 YYAnimatedImageView 中使用信号量优化了帧的读取和异步解码, 然后在 YYAnimatedImageView 中使用 NSDictionary 做了帧缓存, 最后用CADisplayLink
来做动画的展示, 同时添加帧解码任务.
YYImageDecoder 的实现
YYImageDecoder 是 YYImage 的核心类, 负责将图片或者动图的文件解析为 YYImageFrame 对象, 同时支持对 APNG 以及 WebP 的解码. 首先 YYImageDecoder 初始化时会判断图片的类型, WebP 使用[self _updateSourceWebP]
解码, PNG 使用[self _updateSourceAPNG]
解码, 其余图片使用[self _updateSourceImageIO]
解码._updateSourceImageIO
会初始化CGImageSourceRed, 读出 frameCount, 最后遍历每一帧, 获取帧宽高/时长/方向等信息._updateSourceAPNG
使用_updateSourceImageIO
构造了 PNG 信息后, 又调用了yy_png_info_create
方法从源文件中解析了 APNG 相关的参数. 而_updateSourceWebP
依托于 WebP.framework 解析了相关信息.
初始化完成后就可以使用frameAtIndex:decodeForDisplay:
获取某一帧. 首先是使用_newUnblendedImageAtIndex:extendToCanvas:decoded:
获取帧的 CGImageRef, 这一步骤还是使用CGImageSourceCreateImageAtIndex
获取对应帧, 然后使用YYCGImageCreateDecodedCopy()
解码, 默认使用CGContextDrawImage & CGBitmapContextCreateImage
这一组方法来解码.
下面是 YYAnimatedImageView
解码流程:
在流程图左边, 我们拿到动图的二进制数据后, 首先会解码第一帧, 同时启动一个背景线程去解码后续帧并缓存, 并且有一个慢启动机制, 避免同时解码多个动图造成瞬间负载过高; 在流程图最上方, ImageView 因为换图了会清空帧缓存, 展示第一帧后会启动一个动画定时器, 在该展示下一帧的时候找帧缓存要解码后的帧. 这样循环后动图就动起来了.
但是 YYAnimatedImageView 也不是完美的, 譬如使用 NSDictionary 来管理帧缓存, iOS 系统在内存紧张时会对 NSDictionary 做压缩, 从而产生额外的 CPU 消耗, 根据WWDC iOS Memory Deep Dive所述, 应尽量使用 NSCache 来做缓存; 其次 View 直接绑定帧缓存, 在快速滑动场景, View 不断加载新的动图并释放已解码的帧, 并解码新图片的每一帧, 导致 CPU 负载过高, 在低端机会被 WatchDog 杀掉.
2.4 SDWebImage 各版本的使用简介
上面说的两个第三方库都支持本地加载文件, 不直接支持在线加载, 其中 YYAnimatedImageView
配合 YYWebImage
可以简单实现在线加载, 但是使用体验远不及 SDWebImage
.
首先用一张流程图简单介绍下 SDWebImage3 的图片加载流程, 后续版本基本延续了这个思路:
SDWebImage 早期也集成了 FLAnimatedImageView 用于在线加载 GIF, 后来通过[UIImage animatedImageWithImages:images duration:duration];
直接解析 GIF 为 _UIAnimatedImage
(UIImage 的私有子类), SDWebImage4
加入解析各帧时长的最大公约数来批量增加同一帧, 以还原帧时长.
SDWebImage5 引入SDAnimatedImageView
, 一如 SDWebImage 简洁的接口, 可以直接使用SDWebImageMatchAnimatedImageClass
options 来加载动图, 代码如下:
1 | SDAnimatedImageView *imageView = [SDAnimatedImageView new]; |
但是要注意的是, 通过上述方法, url 被加载到了内存缓存, 那么图片的实例是一个SDAnimatedImage
对象, 用其他 UIImageView 加载该 url 命中内存缓存, 展示在页面上只是一张静态图.
SDAnimatedImageView
通过 SDAnimatedImagePlayer
来实现动图的展示, 调用setImage:
时会初始化新的 player
, 使用 SDDisplayLink
(对CADisplayLink 的封装, 同时支持 iOS/tvOS/macOS/watchOS) 来展示对应帧, 使用NSOperationQueue
在背景线程进行解码, 然后存储在player
的frameBuffer
中作为缓存. 总结下来思路跟 YYAnimatedImageView
差不多.
3. 我们项目目前加载动图的思路以及问题
在我们的项目中, 沿用主流的 SDWebImage
方案来加载图片, 代码是早期的 SDWebImage
修改而来, 加入了”异步解码/下载统计/改用端内网络组件下载”等逻辑.
这一套方案加载动图有如下三个问题(也是 SDWebImage 的问题):
- 当且仅当所有帧图片都加载完毕时,才能够显示, 特别是在做异步解码的时候, 会导致图片首帧加载时长较长.
- 不同帧的展示时长一样,使得动图失真. (这个在 SDWebImage4 版本解决了, 即上面所说的帧时长最大公约数方案)
- 在背景线程解析出所有帧图片, 此时如果对帧不做解码会造成卡顿, 但是做异步解码, 小内存的机型会直接内存暴涨导致崩溃.
基于上述的问题, 应该将 YYAnimatedImageView
的逐帧加载思路应用到端内, 在动图加载到内存时, 只从二进制数据中解码第一帧, 然后使用 CADisplayLink
在触发时解析当前需要展示的帧, 同时合理的使用帧缓存, 以避免上述 YYAnimatedImageView
问题.
4. 动图加载优化实践
下面针对存在的问题逐一优化:
4.1 解码每一帧导致首帧加载太慢
如果引入动图逐帧加载方案, 那么就要考虑怎么基于 SDWebImage 实现动图的逐帧加载.
4.1.1 动图逐帧加载方案
基于上面简述的图片加载流程, 解码步骤主要优化点是动图直接遍历并解码了每一帧, 一瞬间占用大量 CPU 以及内存, 优化思路是在解码之前封装动图为一个 AnimatedWebImage(UIImage 子类)并只解码第一帧, 交给 NewSDWebImageView(UIImageView 的子类)直接展示, 然后在 NewSDWebImageView 中添加 CADisplayLink 定时展示对应帧, 同时启动一个任务队列, 异步解码即将展示的帧, 放在 NewSDWebImageView 的帧缓存中, 这样就可以完成一个既支持 SDWebImage 在线加载又能逐帧解码动图的组件(模仿 YYAnimatedImageView的实现).
4.1.2 首帧耗时
改造完之后, 需要验证逐帧加载方案是否会在首帧加载上有所改善, 根据线上统计数据, 对于未优化是否解码以及优化后的逐帧解码三个方案, 首帧加载平均数据如下:
数据完全符合预期, 逐帧解码首帧耗时少于全部解码, 并多于不解码的首帧耗时.
并且在灰度至全量期间, 动图首帧加载耗时都在 25ms 上下波动, 逐帧解码对整体数据无明显影响.
4.2 动图失真的问题
由于新的方案是通过 CADisplayLink
来驱动帧的展示, 在距离上一帧时间间隔超过帧时长时候才会展示下一帧, 自然同时解决了这个动图失真的问题, 同时也能避免像 SDWebImage4 那样去算每一帧的最大公约数.
4.3 解码帧导致内存暴涨的问题
图片加载优化思路一般都是提前异步解码, 解码后的 CGRasterData
已经在内存中, 等到主线程渲染时候不再解码, 以解决系统隐式解码带来的卡顿; 然而在动图中, 如果提前全部解码, 内存和 CPU 都有扛不住的可能, YYAnimatedImageView
就只解码第一帧, 然后保留动图的 NSData, 到需要展示的时候做逐帧加载, 通过多线程的调度来做优化. 但是即使这样, 在低端机上依旧有性能问题. 在用户快速滑动或是数据刷新的场景, YYAnimatedImageView
会丢弃前一张图的所有帧数据, 造成额外的 CPU 消耗, 在此继续做如下优化.
4.3.1 NSDictionary 缓存帧改为 NSCache
YYAnimatedImageView
采用 NSDictionary 来缓存解码帧, 但是根据WWDC iOS Memory Deep Dive所述, iOS 系统在内存紧张时会对 NSDictionary 做压缩, 从而产生额外的 CPU 消耗, 应尽量使用 NSCache 来做缓存. NSCache 适合用于缓存开销较大的数据, 并且是线程安全的, 系统会自动根据内存使用情况以及cost
来移除已缓存的数据, 在此次优化中, 解码帧使用 NSCache 来缓存.
SDWebImage 是 iOS 开发中常用的第三方图片缓存库,它会将使用过的图片缓存在内存中,以供后续快速复用,同时在内存紧张的时候会释放掉缓存。有一个细节是,SDWebImage 早期是将缓存放在 NSMutableDictionary 中,这会使得部分图片缓存在一段时间不用后就被系统压缩了。当内存峰值来临时,系统会发送一个内存警告,SDWebImage 在收到警告的时候会选择释放掉缓存。还记得吗?释放之前要先解压,才能释放。在解压的一瞬间,内存峰值被推得更高,于是系统就杀掉进程,制造了一次经典的 OOM。后来 SDWebImage 采用了系统提供的 NSCache 来做缓存,NSCache 有专门针对内存压缩做优化,才解决了此问题。
4.3.2 解绑 View 与帧缓存, 优化快速滑动场景的 CPU 高负载问题
YYAnimatedImageView
中帧缓存是直接被 View 持有的, 导致 View 切换图片时候, 之前的帧缓存都被释放掉, 在 Cell 复用的场景下, YYAnimatedImageView
会不断解析图片导致 CPU 消耗过高. 在此次优化中, NewSDWebImageView
不直接持有帧缓存, 而是通过 AnimatedWebImage
存储帧缓存, 如果动图被 SDImageCache
从内存释放掉, AnimatedWebImage
也会清掉帧缓存, 在 Cell 复用场景, 帧缓存只要被解码过就不会重复执行解码, 动图只要不被从内存缓存释放, 帧缓存就不会被清空.
4.3.3 下采样, 时间换空间
在实际开发中, 经常会有图片尺寸远大于显示区域的情况, 造成大量内存浪费, 由于各种原因我们很难去推动下发的图片改为合适的尺寸, 在动图中内存浪费更为恐怖.
比如原图是 120 帧, 宽高 500 * 300 像素, 但是屏幕上显示区域只有 58 * 35, 哪怕按照 pro max 机型的 3x 屏幕来算:
原图: 500*300*4*120=69M
实际显示: 175*105*4*120=8.4M
这个情况使用下采样(降采样) Downsampling 技术, 可以减少大量内存消耗, 参照 WWDC 截图实现即可:
NewSDWebImageView 提供了开启下采样的接口, 开启设置后, 如果能够省一半以上的内存, 动图帧就会被压缩为适应屏幕的尺寸, 但是相应的下采样会导致 CPU 占用稍微升高.
4.3.4 在解码失败的时候尝试手动释放内存
在 App 运行中, 部分 API 如果无法申请到内存会发生 NSMallocException 崩溃, 崩溃描述为”Failed to grow buffer”. 图片一般是内存消耗的大户, 因此可以在图片解码失败时, 主动尝试释放图片内存缓存, 正在使用的图片不会被释放, 未被使用的图片先释放掉以腾出内存, 从而规避内存不足造成崩溃.
4.4 其他优化措施
4.4.1 滑动场景下可以不执行解码任务, 降低 CPU 负载
在快速滑动的场景, CPU 一般都是比较繁忙的, 因此可以在滑动时不生成帧解码任务从而降低 CPU 压力, NewSDWebImageView
也提供了接口屏蔽这一功能.
4.4.2 三种解码方案的选择, 线上解码方案有崩溃
iOS 开发通常有三种解码方法.
第一种最常用的解码 CGContextDrawImage, 核心代码如下:
1
2
3
4
5
6
7
8
9UIImage *image = [UIImage imageWithData:data];
//创建上下文
CGContextRef context = CGBitmapContextCreate();
// 在上下文中绘制原图
CGContextDrawImage(context, rect, image.CGImage);
// 创建得到解码后的图片
CGImageRef newImageRef = CGBitmapContextCreateImage(context);
// 释放上下文
CGContextRelease(context);YYKit 提供的解码思路可以更快解码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26// 创建ImageSource
CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)data, 0);
// 创建未解码的 CGImage
CGImageRef imageRef = 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);
CGImageRelease(imageRef);是 iOS15 提供的解码方法, 由于新版本才发布, 覆盖面不全, 暂不考虑
1 | /// Asynchronously prepares this image for displaying on the specified screen. |
其中第二种解码方法在内存紧张时更容易产生崩溃, 统计崩溃的机型不难发现, 主要是 3x 屏幕 3G 内存机型发生这个崩溃:
设备型号 | 机型 | 崩溃占比 | 启动占比 | 内存 |
---|---|---|---|---|
iPhone10,3 | iPhone X | 22.25% | 7.86% | 3G |
iPhone11,8 | iPhone XR | 19.87% | 7.98% | 3G |
iPhone10,2 | iPhone 8 Plus | 17.20% | 5.77% | 3G |
iPhone9,2 | iPhone 7 Plus | 11.08% | 3.62% | 3G |
iPhone8,2 | iPhone 6s Plus | 4.23% | 1.96% | 2G |
合计 | 74.62% | 21.48% |
在部分业务换为 NewSDWebImageView
后, 该崩溃由排名第 9 降至 15, 日崩溃次数从 400+ 下降至 200 次, 后续其他业务都接入 NewSDWebImageView 后基本消除了此崩溃.
4.4.3 SDImageCache 设置
SDImageCache
提供了最大缓存的选项maxMemoryCost
, 但是我们之前没有自行设置, SDWebImage
就会尽可能的去占用内存, 在系统 MemoryWarning 时候释放内存缓存, 内存曲线会如同山峰一样变化, 在危险边缘不断试探.
而在此次优化中, 我将 maxMemoryCost
值设置成最大可用内存的 30%, 内存曲线就会平缓, 能有效减少 OOM 的可能.
最大可用内存的计算代码如下:
1 | // 获取进程可用内存 单位 (Byte) |
手动设置 maxMemoryCost
后有降低图片内存缓存命中率的风险, 我也做了相关统计, 随着灰度比例的提升以及更多业务切换到 NewSDWebImageView
,内存缓存命中率实际是逐步上升的, 在最新的版本内存缓存命中率稳定在了 80% 以上.
4.4.3 做成图片通用加载方案
考虑到很多场景是静态图和动图混用的, 在下载完成之前, 程序并不知道 url 是不是动图, 因此我对 NewSDWebImageView
做了下载后检查文件类型和帧数的逻辑, 根据图片的实际类型来开启逐帧加载, 同时支持 GIF/Animated WebP/APNG 三种动图格式, 在可能加载动图的场景均可直接使用该组件就可以了.
5. 对比各种开源方案
改造完成后, 新的方案性能是不是要优于主流的方案呢?
我准备了一个较为极限的场景, 构造一个动图流, 每一个 Cell 包含一个头像挂件/一个装饰/一至九张动图, 同屏约有 20 多个动图在展示, 总共使用的动图数量 200+, 测试动图流从上划到下后再上下来回滑动, 时长 2 分钟, 每个场景重复 3 次平均值作为结果.
考虑到线上崩溃主要是 3x 分辨率的 3G 内存机型, 使用 iPhone 7 Plus 作为测试设备.
数据采集使用 Instrument 工具查看内存占用, 使用 PerfDog 测试帧数以及卡顿, 同时对比 UIImageView
/ SDAnimatedImageView
/ YYAnimatedImageView
以及 NewSDWebImageView
的表现, 结果如下:
指标 | UIImageView | SDAnimatedImageView | YYAnimatedImageView | NewSDWebImageView |
---|---|---|---|---|
内存峰值 | 1.94G | 1.144G | 1.33G | 0.82G |
触发内存紧张次数/min | 12 | 0.67 | 0 | 0 |
fps 平均值 | 52.3 | 36.3 | 57.3 | 58.3 |
卡顿次数/10min | 37.7 | 106.6 | 23.3 | 0 |
严重卡顿次数/10min | 25.1 | 9.8 | 15.46 | 0 |
卡顿时长占比 | 12.34% | 1.87% | 0.85% | 0% |
App CPU 平均负载 | 47.6% | 42.6% | 81.4% | 26.7% |
是否崩溃 | 测试 5~40 秒崩溃 | 否 | 测试 1~2 分钟会崩溃 | 否 |
总结:
- 直接用 UIImageView: 几乎是不可用的, 虽然帧数还不错, 但是非常卡, 滑动一会儿就崩溃了, 没有完成过 2 分钟的测试.
- SDAnimatedImageView: CPU 占用相对比较低, 但是长时间帧数都只维持在 40 帧上下, 虽然严重卡顿次数较少, 但是体验下来还是很卡.
- YYAnimatedImageView 的内存以及 CPU 占用都是比较高的, 在使用一分钟后容易触发崩溃, 滑动过程中也有少量卡顿, 另外由于 YYImageCache 的调度非常保守, 导致动图加载速度明显比 SDWebImage 慢.
- NewSDWebImageView 全程无卡顿, CPU 占用一直维持在较低水平, 内存达到设置上限后便不再增长, 在资源调度上达到更好的平衡点, 并且全程无崩溃.
6. 优化思路总结
主要优化手段以及目的:
- 使用动图逐帧加载的方案, 避免在动图展示之前就全部解码消耗太多内存, 并提升首帧耗时.
- 使用 Image 绑定帧缓存, 避免 YYAnimatedImageView 方案在来回滑动场景中不断加载新的动图并清空缓存导致一直在做解码, 从而引起 CPU 负载过高导致崩溃.
- 设置 SDImageCache 的最大内存缓存阈值, 避免 CPU 负载较高时 MemoryWarning 不起作用导致 MallocException 崩溃.
- 使用 NSCache 代替 NSDictionary 做帧缓存, 避免系统压缩内存时带来额外 CPU 消耗, 并由系统控制自动释放帧缓存.
- 在内存不足导致解码失败时主动释放 SDImageCache 的 memoryCache, 避免其他功能申请不到内存从而导致 MallocException 崩溃.
- 在加载动图分辨率明确大于 UI 展示区域的场景, 可以设置开启下采样, 以合理使用内存.
- 在主线程滑动时, 暂停解码新的帧, 避免快速滑动场景浪费 CPU 资源.
- 完成图片通用加载组件, 在动图静态图复用的场景可以直接使用 NewSDWebImageView, 不用考虑组件造成额外性能消耗.
7. 参考文章
https://juejin.cn/post/6847902216540389390