前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >iOS 客户端动图优化实践

iOS 客户端动图优化实践

作者头像
QQ音乐技术团队
发布2023-05-12 20:40:08
4.3K3
发布2023-05-12 20:40:08
举报

GIF 和 Animated WebP 是互联网上最主流的动图格式, 但是在 iOS 开发中, 原生的 UIImage 并不直接支持 GIF 以及 Animated WebP 的展示, 因此有了各种优秀的第三方开源方案, 例如 SDWebImage 以及 YYImage 等. 这篇文章将以 QQ 音乐 iOS 端优化动图的实践为基础, 来介绍不同方案的思路以及优劣, 并给出优化的方案.

1. 端内动图展示的问题以及优化结果

长期以来, 部分机型浏览 Q 音的图文流时很容易闪退, 端内其他业务也存在不少动图相关的崩溃上报记录.

崩溃的原因是, 端内加载图片时会在异步线程提前解码, 短时间内解码大量动图帧会快速消耗掉可用内存, 在触发系统的 MemoryWarning 通知之前就直接导致 NSMallocException(Failed to grow buffer) 崩溃, 也很容易触发 OOM. 我们经过两个月灰度上线了动图的逐帧解码方案, 并封装为图片的通用加载组件 QMAnimatedImageView, 优化带来如下改善:

  1. 解决展示动图频繁崩溃的问题, 包括 OOM / NSMallocException / CPU 负载过高等.
  2. 在每一帧都解码情况下, 优化前后首帧加载时长持平.
  3. 图片内存命中率由 65% 上升至 76%.
  4. 相比主流开源方案 YYAnimatedImageView 以及 SDAnimatedImageView,  CPU 占用更少, 内存使用更少, 并且有更好的流畅度.
  5. 封装成通用图片加载方案, 支持动图静态图复用, 支持 GIF/Animated WebP/APNG 动图格式.

2. iOS 展示动图的方法

首先介绍几种常用的展示动图的方法:

2.1 使用 ImageIO.framework 来展示动图

如上面所说, UIImage 不直接支持展示动图, 直接用 [UIImage imageNamed:] 以及 [UIImage imageWithData:] 方法加载动图文件, 只会得到一张静态图. 使用原生 API 展示 GIF 需要使用 ImageIO.framework 来从 data 中解析出每一帧, 同时通过 UIImageView 的 animationImages 属性来达成动画的支持. 示例代码如下:

代码语言:javascript
复制
    // 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];

2.2 FLAnimatedImage

FLAnimatedImage 是 FlipBoard 早期开源的动图加载库, 实现思路是一个典型的消费者-生产者模型. 

  • FLAnimatedImageView 是消费者, 通过 CADisplayerLink 定时展示当前帧;
  • FLAnimatedImage 是生产者, 在异步线程通过 CGImageSourceCreateImageAtIndex 拿到帧并解码, 同时缓存帧数据; 
  • 在 CADisplayerLink 触发时展示对应的帧即可. 但是这个库毕竟太老了, 性能表现不及 YYAnimatedImageView, 先不做参考.

2.3 YYAnimatedImageView 的实现与不足

YYAnimatedImageView 的实现与 FLAnimatedImage 类似: 

  • 使用 YYImageCoder 来做动图帧的解析, 支持多种格式;
  • 在 YYAnimatedImageView 中使用信号量优化帧的读取和异步解码, 并使用 NSDictionary 做帧缓存,
  • CADisplayLink 来做动画的展示, 同时添加帧解码任务.

YYImageDecoder 的实现

YYImageDecoder 负责将图片或者动图帧解析为 YYImageFrame 对象, 支持 APNG 以及 WebP, 解码的简略流程如下: 

  1. YYImageDecoder 初始化时会判断图片的类型, 并生成每一帧的信息.
    1. 一般图片使用 [self _updateSourceImageIO] 解码.
      1. 初始化 CGImageSourceRef, 读出 frameCount,
      2. 遍历每一帧, 获取帧宽高/时长/方向等信息.
    2. WebP 使用 [self _updateSourceWebP]解码, 依托于 WebP.framework  解析了相关信息.
    3. APNG 使用 [self _updateSourceAPNG] 解码.
      1. 使用 _updateSourceImageIO 构造第一帧,
      2. 又调用了 yy_png_info_create 方法从源文件中解析了 APNG 相关的参数.
  2. 使用 frameAtIndex:decodeForDisplay: 获取对应帧.
    1. 首先使用_newUnblendedImageAtIndex:extendToCanvas:decoded:获取帧的 CGImageRef, 这一步骤还是使用 CGImageSourceCreateImageAtIndex 获取对应帧,
    2. 然后使用 YYCGImageCreateDecodedCopy()解码, 默认使用 CGContextDrawImage & CGBitmapContextCreateImage 这一组方法来解码.

下面是 YYAnimatedImageView 解码并展示图片的流程:

YYAnimatedImageView 无法满足业务需求的地方

YYAnimatedImageView 在同时展示大量动图的场景无法满足业务需求:

  1. YYAnimatedImageView 使用 NSDictionary 来管理帧缓存, iOS 系统在内存紧张时会对 NSDictionary 做压缩, 从而产生额外的 CPU 消耗, 根据 WWDC iOS Memory Deep Dive[1]所述, 应尽量使用 NSCache 来做缓存; 
  2. View 直接绑定帧缓存, 在快速滑动场景, View 不断加载新的动图, 会直接释放已解码的帧, 重新解码新图片的每一帧, 导致 CPU 负载过高, 在图文流中快速滑动或者来回滑动很容易崩溃.

2.4 SDWebImage 各版本的使用简介

上面说的两个第三方库都支持本地加载文件, 不直接支持在线加载, 其中 YYAnimatedImageView 配合 YYWebImage 可以简单实现在线加载, 但是使用体验不及 SDWebImage. 先用一张流程图简单介绍下 SDWebImage3 的图片加载流程, 后续版本基本延续了这个思路:

  • SDWebImage 早期也集成了 FLAnimatedImageView 用于在线加载 GIF, 后来通过[UIImage animatedImageWithImages: duration:] 直接解析 GIF 为 _UIAnimatedImage (UIImage 的私有子类)
  • SDWebImage4 计算了帧时长的最大公约数来批量增加同一帧, 以实现不同帧展示不同的时长, 解决动图展示失真的问题.
  • SDWebImage5 引入SDAnimatedImageView, 一如 SDWebImage 简洁的接口, 可以直接使用SDWebImageMatchAnimatedImageClass options 来加载动图, 代码如下:
代码语言:javascript
复制
SDAnimatedImageView *imageView = [SDAnimatedImageView new];[imageView sd_setImageWithURL:[NSURL URLWithString:url]             placeholderImage:nil                      options:SDWebImageMatchAnimatedImageClass];

但是要注意的是, 通过上述方法, 图片被加载到了内存缓存, 那么图片的实例是一个SDAnimatedImage对象, 用其他 UIImageView 加载该 url 命中内存缓存, 展示在页面上只是一张静态图.

SDAnimatedImageView 通过 SDAnimatedImagePlayer 来实现动图的展示.

  1. 调用setImage:时会初始化新的 player.
  2. 使用 SDDisplayLink (对CADisplayLink 的封装, 同时支持 iOS/tvOS/macOS/watchOS) 来展示对应帧.
  3. 使用NSOperationQueue在背景线程进行解码, 然后存储在playerframeBuffer中作为缓存. 总结下来思路跟 YYAnimatedImageView 差不多.

3. Q音 iOS 端加载动图的思路以及问题

我们项目中图片加载是由早期的 SDWebImage 衍变而来, 后来随着业务不断发展, 加入了异步解码/下载统计/改用端内网络组件等逻辑.

使用这套方案加载动图有如下三个问题:

  1. 当且仅当所有帧图片都加载完毕时,才能够显示, 特别是在做异步解码的时候, 会导致动图首帧加载时长较长.
  2. 不同帧的展示时长一样,使得动图失真. (最大公约数方案可解决)
  3. 在背景线程解析出所有帧, 此时如果对帧不做解码会造成卡顿, 但是做异步解码, 小内存的机型会直接内存暴涨导致崩溃, 因此在线上只能灰度开启.

基于上述的问题, 应该将逐帧加载思路应用到端内, 在动图加载到内存时, 只从二进制数据中解码第一帧; 然后在 CADisplayLink 触发时解析当前需要展示的帧, 同时合理地使用帧缓存, 避免上述 YYAnimatedImageView 不断加载动图导致的问题.

4. Q音 iOS 端动图加载优化实践

Q 音 iOS 端的图片异步加载流程与上述 SDWebImage 加载流程相似, 解码流程会有一些不同, Q 音图片解码流程图如下:

下面针对存在的问题逐一优化:

4.1 解码每一帧导致首帧加载太慢

怎么基于异步加载框架实现动图的逐帧加载呢?

4.1.1 动图逐帧加载方案

目前的图片加载流程的主要痛点是, 动图直接遍历并解码了每一帧, 一瞬间占用大量 CPU 以及内存.

优化思路如下:

  1. 在解码之前封装动图为一个 QMAnimatedWebImage(UIImage 子类)并只解码第一帧,
  2.  交给 QMAnimatedImageView(UIImageView 的子类)直接展示,
  3. 在 QMAnimatedImageView 中添加 CADisplayLink 定时展示对应帧,
  4. 启动一个任务队列, 异步解码即将展示的帧, 放在 QMAnimatedImageView 的缓存区中. 这样实现一个既支持异步加载又能逐帧解码动图组件, 下图是动图解码优化的流程, 红色字是逐帧加载的改造.

4.1.2 首帧耗时

改造完之后, 需要验证逐帧加载方案是否会在首帧加载上有所改善.

根据线上统计数据, 对于优化前是否解码, 以及优化后的逐帧解码三个方案, 首帧加载平均数据如下:

相比于预先全部解码, 逐帧解码的首帧耗时降低了一半; 在灰度期间, 动图首帧加载平均耗时都在 25ms 上下波动, 逐帧解码对整体数据无明显影响.

4.2 动图失真的问题

由于 QMAnimatedImageView 是通过 CADisplayLink 来驱动帧的展示, 在距离上一帧时间间隔超过帧时长时候才会展示下一帧, 自然解决了动图失真的问题, 同时也能避免像 SDWebImage4 那样去算每一帧的最大公约数.

4.3 解码帧导致内存暴涨的问题

提前异步解码图片是常见的优化思路, 解码后的 CGRasterData 被缓存在内存中, 等到主线程渲染图片时不再解码, 以解决系统隐式解码导致的卡顿.

但是在动图场景, 连续解码动图会快速消耗内存, 内存不足导致动图缓存命中率降低, 新的动图触发解码又会进一步消耗 CPU, MemoryWarning 触发之前就发生了崩溃; CPU 和内存互相挤兑, 难以优化.

YYAnimatedImageView 只解码第一帧, 并保留动图的 NSData, 在背景线程解码帧. 但即使这样, 不断加载动图时, 低端机上依旧有性能问题. 在用户快速滑动或是数据刷新的场景, YYAnimatedImageView 会丢弃前一张图的所有帧数据, 下次展示这张图又会从头解码, 造成额外的 CPU 消耗, 在此继续做如下优化.

4.3.1 NSDictionary 帧缓存改为 NSCache

YYAnimatedImageView 采用 NSDictionary 来缓存解码帧, 但是 iOS 系统在内存紧张时会对 NSDictionary 做压缩, 从而产生额外的 CPU 消耗, 并且释放帧缓存依赖于 MemoryWarning 通知. 而 NSCache 更适合用于缓存开销较大的数据, 并且是线程安全的, 系统会自动根据内存使用情况以及cost 直接移除缓存, 在此次优化中, 解码帧使用 NSCache 来缓存.

4.3.2 解绑 View 与帧缓存, 优化快速滑动场景的 CPU 高负载问题

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] 提供的代码实现即可:

代码语言:javascript
复制
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”. 图片一般是内存消耗的大户, 因此可以在图片解码失败时, 主动尝试释放图片内存缓存, 正在使用的图片不会被释放, 未被使用的图片先释放掉以腾出内存, 从而规避内存不足造成崩溃.

4.4 其他优化措施

4.4.1 滑动场景下不执行解码任务, 降低 CPU 负载

在快速滑动的场景, CPU 一般都是比较繁忙的, 因此可以在滑动时不生成帧解码任务从而降低 CPU 压力, QMAnimatedImageView 也提供了接口屏蔽这一功能.

4.4.2 SDImageCache 设置

SDImageCache 提供了最大缓存的选项maxMemoryCost, 但是我们之前没有自行设置, SDWebImage 就会尽可能的去占用内存, 在 MemoryWarning 时释放内存缓存, 内存曲线会如同山峰一样变化, 在危险边缘不断试探.

而在此次优化中, 我将 maxMemoryCost 值设置成最大可用内存的 30%(线上 ABT 得出), 内存曲线就会很平缓, 能有效减少 OOM. 

最大可用内存的计算代码如下:

代码语言:javascript
复制
// 获取进程可用内存 单位 (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 触发次数也在下降).

4.4.3 做成图片通用加载方案

考虑到很多场景是静态图和动图混用的, 在下载完成之前, 程序并不知道 url 是不是动图, QMAnimatedImageView 做了下载后检查文件类型和帧数的逻辑, 根据图片的实际类型来开启逐帧加载, 同时支持 GIF/Animated WebP/APNG 三种动图格式, 在可能加载动图的场景均可直接使用.

5. 对比各种开源方案

改造完成后, 新的方案性能是不是要优于主流的方案呢? 我准备了一个较为极限的场景, 构造一个动图流, 每一个 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 分钟会崩溃

总结:

  • 直接用 UIImageView, 几乎是不可用的, 虽然帧数还不错, 但是非常卡, 具体帧数与卡顿的关系可以参考文章 APP&游戏需要关注Jank卡顿及卡顿率吗[3].
  • SDAnimatedImageView CPU 占用相对比较低, 但是帧数只有 40 帧上下, 虽然严重卡顿次数较少, 但是体验下来还是很卡.
  • YYAnimatedImageView 的内存以及 CPU 占用都是比较高的, 在使用一分钟后容易触发崩溃, 滑动过程中也有少量卡顿, 另外由于 YYImageCache 的调度非常保守, 导致动图加载速度明显比 SDWebImage 慢.
  • 而 QMAnimatedImageView 全程无卡顿, CPU 占用一直维持在较低水平, 内存达到设置上限后便不再增长, 在资源调度上达到更好的平衡点.

6. 思路总结

主要优化手段以及目的:

  1. 使用动图逐帧加载的方案, 避免在动图展示之前就全部解码消耗太多内存, 并提升首帧耗时.
  2. 使用 Image 绑定帧缓存, 避免 YYAnimatedImageView 方案在滑动场景中不断加载新的动图并清空缓存导致一直在做解码, 从而引起 CPU 负载过高.
  3. 设置 SDImageCache 的内存缓存阈值, 避免 CPU 负载较高时 MemoryWarning 未及时触发, 导致 MallocException 崩溃.
  4. 使用 NSCache 代替 NSDictionary 做帧缓存, 避免系统压缩内存时带来额外 CPU 消耗, 并由系统自动释放帧缓存.
  5. 在内存不足导致解码失败时主动释放 SDImageCache 的 memoryCache, 避免其他业务申请不到内存导致崩溃.
  6. 设置开启图片下采样, 以合理使用内存.
  7. 在主线程滑动时, 暂停解码新的帧, 避免快速滑动场景浪费 CPU 资源.
  8. 完成图片通用加载组件, 在动图静态图复用的场景可以直接使用 QMAnimatedImageView, 组件不会造成额外性能消耗.

7. 参考资料

[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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-05-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 端内动图展示的问题以及优化结果
    • 2.1 使用 ImageIO.framework 来展示动图
      • 2.2 FLAnimatedImage
        • 2.3 YYAnimatedImageView 的实现与不足
          • 2.4 SDWebImage 各版本的使用简介
          • 3. Q音 iOS 端加载动图的思路以及问题
          • 4. Q音 iOS 端动图加载优化实践
            • 4.1 解码每一帧导致首帧加载太慢
              • 4.1.1 动图逐帧加载方案
              • 4.1.2 首帧耗时
            • 4.3 解码帧导致内存暴涨的问题
              • 4.3.1 NSDictionary 帧缓存改为 NSCache
              • 4.3.2 解绑 View 与帧缓存, 优化快速滑动场景的 CPU 高负载问题
            • 4.4 其他优化措施
              • 4.4.1 滑动场景下不执行解码任务, 降低 CPU 负载
              • 4.4.2 SDImageCache 设置
              • 4.4.3 做成图片通用加载方案
          • 5. 对比各种开源方案
          • 6. 思路总结
          • 7. 参考资料
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档