iOS 动图优化实践

iOS 动图优化实践

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

1. 目前动图展示的问题以及优化结果

目前项目采用 SDWebImage 加载动图, 在异步线程提前解码图片, 但是这个方案有棘手的内存问题, 解码动图所有帧在低端机容易直接 OOM, 图文流滑动过程中内存一直在增长, 在触发系统的 MemoryWarning 通知之前就直接触发了 NSMallocException(Failed to grow buffer) 的崩溃, 因此不得不针对低端机做只展示尾帧的处理.
我经过两个月灰度逐步上线了动图的逐帧解码方案, 并封装了图片的通用加载组件, 优化带来如下改善:

  1. 解决低端机展示动图频繁崩溃的问题, 包括 OOM NSMallocException CPU 负载过高等.
  2. 在每一帧都解码情况下, 优化前后首帧加载时长持平.
  3. 图片内存缓存命中率由 65% 上升至 80%.
  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 属性来达成动画的支持. 示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 产生 CGImageSourceRef
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);

int count = CGImageSourceGetCount(source);
NSMutableArray *images = [NSMutableArray array];
for (int i = 0; i < count; i++)
{
// 遍历得到每一帧
CGImageRef image = CGImageSourceCreateImageAtIndex(source, i, NULL);
[images addObject:[UIImage imageWithCGImage:image scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp]];
// 注意释放, 可以在这儿加一个 autorelease
CGImageRelease(image);
}
// CGImageSourceRef 也要释放
CFRelease(source);

UIImageView *imageView = [[UIImageView alloc] init];
// 通过 animationImages 设置动画
imageView.animationImages = images;
// 获取每一帧的处理略复杂, 先按照每一帧 100ms 吧
imageView.animationDuration = 0.1 * count;
// 启动动画
[imageView startAnimating];

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 解码流程:

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  加载在线图片的流程

SDWebImage 早期也集成了 FLAnimatedImageView 用于在线加载 GIF, 后来通过[UIImage animatedImageWithImages:images duration:duration]; 直接解析 GIF 为 _UIAnimatedImage (UIImage 的私有子类), SDWebImage4 加入解析各帧时长的最大公约数来批量增加同一帧, 以还原帧时长.

SDWebImage5 引入SDAnimatedImageView, 一如 SDWebImage 简洁的接口, 可以直接使用SDWebImageMatchAnimatedImageClass options 来加载动图, 代码如下:

1
2
3
4
SDAnimatedImageView *imageView = [SDAnimatedImageView new];
[imageView sd_setImageWithURL:[NSURL URLWithString:url]
placeholderImage:nil
options:SDWebImageMatchAnimatedImageClass];

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

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

3. 我们项目目前加载动图的思路以及问题

在我们的项目中, 沿用主流的 SDWebImage 方案来加载图片, 代码是早期的 SDWebImage 修改而来, 加入了”异步解码/下载统计/改用端内网络组件下载”等逻辑.

这一套方案加载动图有如下三个问题(也是 SDWebImage 的问题):

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

基于上述的问题, 应该将 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 截图实现即可:
ImageDownsmaple
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 开发通常有三种解码方法.

  1. 第一种最常用的解码 CGContextDrawImage, 核心代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    UIImage *image = [UIImage imageWithData:data];
    //创建上下文
    CGContextRef context = CGBitmapContextCreate();
    // 在上下文中绘制原图
    CGContextDrawImage(context, rect, image.CGImage);
    // 创建得到解码后的图片
    CGImageRef newImageRef = CGBitmapContextCreateImage(context);
    // 释放上下文
    CGContextRelease(context);
  2. 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);
  3. 是 iOS15 提供的解码方法, 由于新版本才发布, 覆盖面不全, 暂不考虑

1
2
3
4
5
6
7
8
/// Asynchronously prepares this image for displaying on the specified screen.
///
/// The completion handler will be invoked on a private queue. Be sure to return to the main queue before assigning the prepared image to an image view.
///
/// @param completionHandler A block to invoke with the prepared image. If preparation failed (for example, beacuse the image data is corrupt), @c image will be nil.
///
/// @note The prepared UIImage is not related to the original image. If the properties of the screen (such as its resolution or color gamut) change, or if the image is displayed on a different screen that the one it was prepared for, it may not render correctly.
- (void)prepareForDisplayWithCompletionHandler:(void (^)(UIImage *_Nullable))completionHandler NS_SWIFT_ASYNC_NAME(byPreparingForDisplay()) API_AVAILABLE(ios(15.0),tvos(15.0),watchos(8.0));

其中第二种解码方法在内存紧张时更容易产生崩溃, 统计崩溃的机型不难发现, 主要是 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
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 获取进程可用内存 单位 (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(@"[SDWebImage] 获取可用内存失败, 使用物理内存作为进程可用内存");
int64_t deviceMemory = [NSProcessInfo processInfo].physicalMemory;
memoryLimit = deviceMemory * 0.55;
}

if (memoryLimit <= 0)
{
NSLog(@"[SDWebImage] 获取物理内存失败, 使用可用内存作为进程可用内存");
// 这个值一般要小很多, 上面都获取不到才使用
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 后有降低图片内存缓存命中率的风险, 我也做了相关统计, 随着灰度比例的提升以及更多业务切换到 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 分钟会崩溃

总结:

  1. 直接用 UIImageView: 几乎是不可用的, 虽然帧数还不错, 但是非常卡, 滑动一会儿就崩溃了, 没有完成过 2 分钟的测试.
  2. SDAnimatedImageView: CPU 占用相对比较低, 但是长时间帧数都只维持在 40 帧上下, 虽然严重卡顿次数较少, 但是体验下来还是很卡.
  3. YYAnimatedImageView 的内存以及 CPU 占用都是比较高的, 在使用一分钟后容易触发崩溃, 滑动过程中也有少量卡顿, 另外由于 YYImageCache 的调度非常保守, 导致动图加载速度明显比 SDWebImage 慢.
  4. NewSDWebImageView 全程无卡顿, CPU 占用一直维持在较低水平, 内存达到设置上限后便不再增长, 在资源调度上达到更好的平衡点, 并且全程无崩溃.

6. 优化思路总结

主要优化手段以及目的:

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

7. 参考文章

YYWebImage工作原理介绍—–gif动态图

YYImage之YYAnimatedImageView

https://juejin.cn/post/6847902216540389390

带你打造一套 APM 监控系统 之 OOM 问题

iOS缓存 NSCache详解及SDWebImage缓存策略源码分析

iOS中的imageIO与image解码

iOS Memory 内存详解

内存监控:Instruments和三方库

关于iOS内存的深入排查和优化

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道