GIF 和 Animated WebP 是互联网上最主流的动图格式, 但是在 iOS 开发中, 原生的 UIImage 并不直接支持 GIF 以及 Animated WebP 的展示, 因此有了各种优秀的第三方开源方案, 例如 SDWebImage
以及 YYImage
等. 这篇文章将以 QQ 音乐 iOS 端优化动图的实践为基础, 来介绍不同方案的思路以及优劣, 并给出优化的方案.
长期以来, 部分机型浏览 Q 音的图文流时很容易闪退, 端内其他业务也存在不少动图相关的崩溃上报记录.
崩溃的原因是, 端内加载图片时会在异步线程提前解码, 短时间内解码大量动图帧会快速消耗掉可用内存, 在触发系统的 MemoryWarning 通知之前就直接导致 NSMallocException(Failed to grow buffer) 崩溃, 也很容易触发 OOM. 我们经过两个月灰度上线了动图的逐帧解码方案, 并封装为图片的通用加载组件 QMAnimatedImageView, 优化带来如下改善:
2. iOS 展示动图的方法
首先介绍几种常用的展示动图的方法:
如上面所说, UIImage 不直接支持展示动图, 直接用 [UIImage imageNamed:]
以及 [UIImage imageWithData:]
方法加载动图文件, 只会得到一张静态图. 使用原生 API 展示 GIF 需要使用 ImageIO.framework
来从 data 中解析出每一帧, 同时通过 UIImageView 的 animationImages
属性来达成动画的支持. 示例代码如下:
// 1. 产生 CGImageSourceRef CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
int count = CGImageSourceGetCount(source); NSMutableArray *images = [NSMutableArray array]; for (int i = 0; i < count; i++) { // 2. 遍历得到每一帧 CGImageRef image = CGImageSourceCreateImageAtIndex(source, i, NULL); [images addObject:[UIImage imageWithCGImage:image scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp]]; // 3. 注意释放, 可以在这儿加一个 autorelease CGImageRelease(image); } // CGImageSourceRef 也要释放 CFRelease(source);
UIImageView *imageView = [[UIImageView alloc] init]; // 4. 通过 animationImages 设置动画 imageView.animationImages = images; // 5. 获取每一帧的处理略复杂, 先按照每一帧 100ms 吧 imageView.animationDuration = 0.1 * count; // 6. 启动动画 [imageView startAnimating];
FLAnimatedImage 是 FlipBoard 早期开源的动图加载库, 实现思路是一个典型的消费者-生产者模型.
CGImageSourceCreateImageAtIndex
拿到帧并解码, 同时缓存帧数据; YYAnimatedImageView 的实现与 FLAnimatedImage 类似:
CADisplayLink
来做动画的展示, 同时添加帧解码任务.YYImageDecoder 的实现
YYImageDecoder 负责将图片或者动图帧解析为 YYImageFrame 对象, 支持 APNG 以及 WebP, 解码的简略流程如下:
[self _updateSourceImageIO]
解码.[self _updateSourceWebP]
解码, 依托于 WebP.framework 解析了相关信息.[self _updateSourceAPNG]
解码._updateSourceImageIO
构造第一帧,yy_png_info_create
方法从源文件中解析了 APNG 相关的参数._newUnblendedImageAtIndex:extendToCanvas:decoded:
获取帧的 CGImageRef, 这一步骤还是使用 CGImageSourceCreateImageAtIndex
获取对应帧,YYCGImageCreateDecodedCopy()
解码, 默认使用 CGContextDrawImage & CGBitmapContextCreateImage
这一组方法来解码.下面是 YYAnimatedImageView 解码并展示图片的流程:
YYAnimatedImageView 无法满足业务需求的地方
YYAnimatedImageView 在同时展示大量动图的场景无法满足业务需求:
上面说的两个第三方库都支持本地加载文件, 不直接支持在线加载, 其中 YYAnimatedImageView
配合 YYWebImage
可以简单实现在线加载, 但是使用体验不及 SDWebImage.
先用一张流程图简单介绍下 SDWebImage3 的图片加载流程, 后续版本基本延续了这个思路:
[UIImage animatedImageWithImages: duration:]
直接解析 GIF 为 _UIAnimatedImage (UIImage 的私有子类)SDAnimatedImageView
, 一如 SDWebImage 简洁的接口, 可以直接使用SDWebImageMatchAnimatedImageClass
options 来加载动图, 代码如下:SDAnimatedImageView *imageView = [SDAnimatedImageView new];[imageView sd_setImageWithURL:[NSURL URLWithString:url] placeholderImage:nil options:SDWebImageMatchAnimatedImageClass];
但是要注意的是, 通过上述方法, 图片被加载到了内存缓存, 那么图片的实例是一个SDAnimatedImage
对象, 用其他 UIImageView 加载该 url 命中内存缓存, 展示在页面上只是一张静态图.
SDAnimatedImageView
通过 SDAnimatedImagePlayer
来实现动图的展示.
setImage:
时会初始化新的 player.
SDDisplayLink
(对CADisplayLink 的封装, 同时支持 iOS/tvOS/macOS/watchOS) 来展示对应帧.NSOperationQueue
在背景线程进行解码, 然后存储在player
的frameBuffer
中作为缓存.
总结下来思路跟 YYAnimatedImageView 差不多.我们项目中图片加载是由早期的 SDWebImage 衍变而来, 后来随着业务不断发展, 加入了异步解码/下载统计/改用端内网络组件等逻辑.
使用这套方案加载动图有如下三个问题:
基于上述的问题, 应该将逐帧加载思路应用到端内, 在动图加载到内存时, 只从二进制数据中解码第一帧; 然后在 CADisplayLink
触发时解析当前需要展示的帧, 同时合理地使用帧缓存, 避免上述 YYAnimatedImageView 不断加载动图导致的问题.
Q 音 iOS 端的图片异步加载流程与上述 SDWebImage 加载流程相似, 解码流程会有一些不同, Q 音图片解码流程图如下:
下面针对存在的问题逐一优化:
怎么基于异步加载框架实现动图的逐帧加载呢?
目前的图片加载流程的主要痛点是, 动图直接遍历并解码了每一帧, 一瞬间占用大量 CPU 以及内存.
优化思路如下:
改造完之后, 需要验证逐帧加载方案是否会在首帧加载上有所改善.
根据线上统计数据, 对于优化前是否解码, 以及优化后的逐帧解码三个方案, 首帧加载平均数据如下:
相比于预先全部解码, 逐帧解码的首帧耗时降低了一半; 在灰度期间, 动图首帧加载平均耗时都在 25ms 上下波动, 逐帧解码对整体数据无明显影响.
4.2 动图失真的问题
由于 QMAnimatedImageView 是通过 CADisplayLink
来驱动帧的展示, 在距离上一帧时间间隔超过帧时长时候才会展示下一帧, 自然解决了动图失真的问题, 同时也能避免像 SDWebImage4 那样去算每一帧的最大公约数.
提前异步解码图片是常见的优化思路, 解码后的 CGRasterData 被缓存在内存中, 等到主线程渲染图片时不再解码, 以解决系统隐式解码导致的卡顿.
但是在动图场景, 连续解码动图会快速消耗内存, 内存不足导致动图缓存命中率降低, 新的动图触发解码又会进一步消耗 CPU, MemoryWarning 触发之前就发生了崩溃; CPU 和内存互相挤兑, 难以优化.
YYAnimatedImageView 只解码第一帧, 并保留动图的 NSData, 在背景线程解码帧. 但即使这样, 不断加载动图时, 低端机上依旧有性能问题. 在用户快速滑动或是数据刷新的场景, YYAnimatedImageView 会丢弃前一张图的所有帧数据, 下次展示这张图又会从头解码, 造成额外的 CPU 消耗, 在此继续做如下优化.
YYAnimatedImageView 采用 NSDictionary 来缓存解码帧, 但是 iOS 系统在内存紧张时会对 NSDictionary 做压缩, 从而产生额外的 CPU 消耗, 并且释放帧缓存依赖于 MemoryWarning 通知. 而 NSCache 更适合用于缓存开销较大的数据, 并且是线程安全的, 系统会自动根据内存使用情况以及cost
直接移除缓存, 在此次优化中, 解码帧使用 NSCache 来缓存.
YYAnimatedImageView 中帧缓存是直接被 View 持有的, 导致 View 切换图片时候, 之前的帧缓存都被释放掉, 在 Cell 复用的场景下, YYAnimatedImageView 会不断解析图片导致 CPU 消耗过高.
在此次优化中, QMAnimatedImageView 不直接持有帧缓存, 而是通过 QMAnimatedWebImage 存储帧缓存, 如果动图被 SDImageCache 从内存释放掉, QMAnimatedWebImage 也会清掉帧缓存, 在 Cell 复用场景, 帧缓存只要被解码过就不会重复执行解码, 动图只要不被从内存缓存释放, 帧缓存就不会被清空.
4.3.3 下采样, 时间换空间
在实际开发中, 经常会有图片尺寸远大于显示区域的情况, 动图中内存浪费非常恐怖. 这个情况可以使用下采样(降采样) Downsampling 技术来减少解码后的内存.
像下面这个挂件, 哪怕按照 3X 屏幕也只需要 175*105 的尺寸就够了, 但是下发的图片却是 500*300. 在通过线上监控排查这些 case 的同时, 开启下采样能即时解决内存消耗的问题.
下采样参照 WWDC Image and Graphics Best Practices[2] 提供的代码实现即可:
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage { let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)! let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)! return UIImage(cgImage: downsampledImage)}
QMAnimatedImageView 提供了下采样接口, 开启设置后, 如果能够省一半以上的内存, 动图帧就会被自动压缩为适应屏幕的尺寸.
4.3.4 在解码失败的时候尝试手动释放内存
在 App 运行中, 部分 API 如果无法申请到内存会发生 NSMallocException 崩溃, 崩溃描述为”Failed to grow buffer”. 图片一般是内存消耗的大户, 因此可以在图片解码失败时, 主动尝试释放图片内存缓存, 正在使用的图片不会被释放, 未被使用的图片先释放掉以腾出内存, 从而规避内存不足造成崩溃.
在快速滑动的场景, CPU 一般都是比较繁忙的, 因此可以在滑动时不生成帧解码任务从而降低 CPU 压力, QMAnimatedImageView 也提供了接口屏蔽这一功能.
SDImageCache 提供了最大缓存的选项maxMemoryCost
, 但是我们之前没有自行设置, SDWebImage 就会尽可能的去占用内存, 在 MemoryWarning 时释放内存缓存, 内存曲线会如同山峰一样变化, 在危险边缘不断试探.
而在此次优化中, 我将 maxMemoryCost
值设置成最大可用内存的 30%(线上 ABT 得出), 内存曲线就会很平缓, 能有效减少 OOM.
最大可用内存的计算代码如下:
// 获取进程可用内存 单位 (Byte)- (int64_t)memoryUsageLimitByByte{ int64_t memoryLimit = 0; // 获取当前内存使用数据 if (@available(iOS 13.0, *)) { task_vm_info_data_t vmInfo; mach_msg_type_number_t count = TASK_VM_INFO_COUNT; kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count); if (kr == KERN_SUCCESS) {
// 间接获取一下当前进程可用的最大内存上限 // iOS13+可以这样计算:当前进程占用内存+还可以使用的内存=上限值 int64_t memoryCanBeUse = (int64_t)(os_proc_available_memory()); if (memoryCanBeUse > 0) { int64_t memoryUsed = (int64_t)(vmInfo.phys_footprint); memoryLimit = memoryUsed + memoryCanBeUse; } } }
if (memoryLimit <= 0) { NSLog(@"获取可用内存失败, 使用物理内存作为进程可用内存"); int64_t deviceMemory = [NSProcessInfo processInfo].physicalMemory; memoryLimit = deviceMemory * 0.55; }
if (memoryLimit <= 0) { NSLog(@"获取物理内存失败, 使用可用内存作为进程可用内存"); // 这个值一般要小很多, 上面都获取不到才使用 mach_port_t host_port = mach_host_self(); mach_msg_type_number_t host_size = sizeof(vm_statistics_data_t) / sizeof(integer_t); vm_size_t page_size; vm_statistics_data_t vm_stat; kern_return_t kr;
kr = host_page_size(host_port, &page_size); if (kr == KERN_SUCCESS) { kr = host_statistics(host_port, HOST_VM_INFO, (host_info_t)&vm_stat, &host_size); if (kr == KERN_SUCCESS) { memoryLimit = vm_stat.free_count * page_size; } } } return memoryLimit;}
手动设置 maxMemoryCost
后有降低图片内存缓存命中率的风险, 我也做了相关统计, 随着灰度比例的提升以及更多业务切换到 QMAnimatedImageView ,内存缓存命中率实际是逐步上升的(同时 MemoryWarning 触发次数也在下降).
考虑到很多场景是静态图和动图混用的, 在下载完成之前, 程序并不知道 url 是不是动图, QMAnimatedImageView 做了下载后检查文件类型和帧数的逻辑, 根据图片的实际类型来开启逐帧加载, 同时支持 GIF/Animated WebP/APNG 三种动图格式, 在可能加载动图的场景均可直接使用.
改造完成后, 新的方案性能是不是要优于主流的方案呢?
我准备了一个较为极限的场景, 构造一个动图流, 每一个 Cell 包含三至九张动图, 同屏约有 20 个动图在展示, 总共使用的动图数量 200+, 测试动图流从上划到下后再上下来回滑动, 时长 2 分钟, 每个场景重复 3 次平均值作为结果.
考虑到线上崩溃主要是 3x 分辨率的 3G 内存机型, 使用 iPhone 7 Plus 作为测试设备.
数据采集使用 Instrument 工具查看内存占用, 使用 PerfDog 测试帧数以及卡顿, 同时对比 UIImageView
/ SDAnimatedImageView
/ YYAnimatedImageView
以及 QMAnimatedImageView 的表现, 结果如下:
指标 | UIImageView | SDAnimatedImageView | YYAnimatedImageView | QMAnimatedImageView |
---|---|---|---|---|
内存峰值 | 1.9G | 1.1G | 1.3G | 0.8G |
触发内存紧张次数/min | 12 | 0.67 | 0 | 0 |
fps 平均值 | 52 | 36 | 57 | 58.3 |
卡顿次数/10min | 38 | 107 | 23 | 0 |
严重卡顿次数/10min | 25 | 9.8 | 15 | 0 |
卡顿时长占比 | 12% | 1.9% | 0.9% | 0% |
App CPU 平均负载 | 48% | 43% | 81% | 27% |
是否崩溃 | 测试 5~40 秒崩溃 | 否 | 测试 1~2 分钟会崩溃 | 否 |
总结:
主要优化手段以及目的:
[1]. iOS Memory Deep Dive
https://developer.apple.com/videos/play/wwdc2018/416/
[2]. Image and Graphics Best Practices
https://developer.apple.com/videos/play/wwdc2018/219/
[3]. APP&游戏需要关注Jank卡顿及卡顿率吗
https://bbs.perfdog.qq.com/article-detail.html?id=6