ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用YYImage实现GIF压缩:抽帧、缩放、降色三招搞定动图体积

用YYImage实现GIF压缩:抽帧、缩放、降色三招搞定动图体积 如果你的项目里要处理动图表情、聊天图片、活动海报 GIF迟早会遇到同一个问题GIF 的体积失控。我上个月接到一个需求要把一批用视频工具转出来的 GIF 压缩后供移动端展示最大的一张有 21MB用户加载一次等于看了半分钟进度条。最后我用 YYImage 做底搭了一条“抽帧-缩放-降色”的压缩流水线几类素材都压到了原始体积的 10% 左右观感还过得去。这篇把思路、代码和踩坑点整理出来给同样要处理 GIF 的 iOS 开发参考。YYImage 是 iOS 上处理 GIF、APNG、WebP 这类动态图的常用框架核心价值是“按帧解码、帧缓存、动态显示”而不是一次性把几百帧全部解成高分辨率位图。正因为它提供了干净的帧级接口我才能在压缩时逐帧拿到完整画面、帧时长和画布尺寸而不是对着一个 UIImage 不知所措。这篇文章适合两类人一类是第一次用 YYImage 做图像处理的另一类是已经用 YYImage 做显示、但没想过拿它做压缩的。1. GIF 的体积到底花在哪三件事上1.1 帧数动态图等于是很多张图排队播放一个 5 秒、20 帧每秒的 GIF里面实际上有 100 幅画面。每一幅画面最终都要占一份压缩数据。GIF 的单帧 LZW 压缩率并不稳定画面越杂、噪点越多压缩出来的数据就越大。帧数是体积的第一放大器帧率翻倍体积通常接近翻倍。所以压 GIF 的第一步是想清楚用户真的需要 20fps 吗聊天工具里展示的表情包10fps 完全够用列表封面图8fps 也没问题。把帧率从 20fps 降到 10fps体积大约少一半副作用只是动画变“顿”了一点点大多数业务场景完全可接受。1.2 画布尺寸体积和宽高的乘方有关GIF 编码的是像素不是矢量。一张 640×480 的画面无论内容多简单编码前都要重建整幅位图。GIF 每一帧都是全尺寸的索引图即使画面里只有小区域变化也仍然需要处理全图尺寸的数据局部帧只是减少了“相同区域”的重复存储并不会让单帧位图变小。视频转出来的 GIF 通常保持原始视频分辨率720p 甚至 1080p 都有。这类 GIF 在聊天框里实际显示时只有 300pt 左右却要解码 720p 的画面内存和体积都在为用不上的分辨率买单。把画布缩到目标尺寸是整个压缩方案里回报最高的一步。1.3 颜色GIF 被 256 色卡住了脖子GIF 单像素只有 8bit调色板最多 256 色。对于视频转出来的 GIF原始画面往往是真彩色转换到 256 色时如果编码器不认真做量化会出现大量噪声而噪声恰恰是 LZW 压缩率最大的敌人。这就是为什么同样尺寸、同样帧数的两张 GIF一张是扁平的实色动画一张是视频截图前者可能只有几百 KB后者却有几 MB。颜色越“收敛”、色块越均匀GIF 越小。很多人以为“把 GIF 重新解码再编码一次”就能压缩实际上没有用。GIF 已经是索引图无损重编码只会得到接近原体积的结果。真正能减体积的手段只有三个抽帧、缩画布、降颜色数量。后面的方案就是围绕这三刀展开的。2. 为什么我拿 YYImage 当压缩底座2.1 帧级解码接口是压缩管线的关键YYImage 提供 YYImageDecoder可以单独创建一个解码器读取某个 NSData 的帧数、画布尺寸、每帧时长并按需解码某一帧。这对压缩来说很重要我可以不把所有帧解出来而是一帧一帧地处理内存峰值能压得很低。同时YYImageDecoder 的frameAtIndex:decodeForDisplay:能拿到当前帧的完整显示结果。GIF 有局部帧、有 disposal method如果直接用 ImageIO 一层层手动合成很容易在缩放后出花屏。YYImageDecoder 把合成逻辑封装好了我只需要关注画面本身不需要背着 GIF 规范去解析每一帧的处置方式。这里顺手对比一下常见方案能力UIImageImageIO CGImageSourceYYImageDecoder逐帧读取不支持支持支持GIF 局部帧合成不处理需要自己手动算内置帧时长、循环次数拿不到需要解析属性直接提供内存策略一次解全部逐帧读取逐帧读取 帧缓存控制2.2 显示端和编码端都能接上压缩完的 NSData 可以用 YYImage 的imageWithData:包一下放进 YYAnimatedImageView 或普通 UIImageView动态效果不会丢。要重新编码时YYImageEncoder 直接支持 GIF 类型也可以把帧交给 ImageIO 的 CGImageDestination。也就是说从“解码”到“显示”再到“编码”YYImage 这一套都能衔接上。如果你只处理静态图片当然不用引入 YYImage。但只要是 GIF、APNG、WebP 动态图YYImage 的运算都在帧级别处理起来比 UIImage 舒服得多。我做这个压缩方案时解码全部走 YYImageDecoder编码走 YYImageEncoder 或 ImageIO。前者省事后者对帧参数的掌控更细后面会分别给出示例。有一点需要提前说明YYImageEncoder 的输入是 UIImage它本身不是一个精细的颜色量化器所以严格的颜色数量控制我会在编码前单独做而不是依赖编码器的内置压缩。3. 压缩落地方案抽帧、缩放、降色三板斧3.1 先定三个参数写代码之前先把需求参数化最大宽度 maxWidth、抽帧步长 stride、颜色数量 maxColors。比如聊天表情maxWidth320stride2maxColors128视频转 GIF 封面maxWidth480stride3maxColors96。参数化之后压缩流程可以抽象成一次遍历后面调参只需要改三个数字。不要一上来就追求一个“万能参数”不同素材的正确参数本来就不一样。3.2 用 YYImageDecoder 把帧时长先取出来Objective-C 代码如下NSData *srcData ...; YYImageDecoder *decoder [YYImageDecoder decoderWithData:srcData scale:1]; if (decoder.frameCount 0) return nil; NSMutableArrayNSNumber * *durations [NSMutableArray array]; for (NSUInteger i 0; i decoder.frameCount; i) { YYImageFrame *frame [decoder frameAtIndex:i decodeForDisplay:NO]; double duration frame.duration; // 很多导出工具会写入 0 或很小的 delay播放器基本都会做兜底 // 这里我们也兜底到 0.04s避免压缩后总时长“越压越短”。 if (duration 0.02) duration 0.04; [durations addObject:(duration)]; }取 durations 时用decodeForDisplay:NO因为这时候不需要合成完整画面能省掉大量 CPU。真正要处理画面时再用YES。3.3 抽帧要保留总时长抽帧不是简单地把间隔帧丢掉。比如原 GIF 是 20fps每帧 0.05s抽帧 stride2 后若直接保留第一帧、丢弃第二帧动画会突然加快一倍。正确做法是把被抽走帧的 duration 累加给保留帧保持总播放时间不变。NSUInteger frameCount decoder.frameCount; NSUInteger stride 2; for (NSUInteger i 0; i frameCount; i stride) { YYImageFrame *frame [decoder frameAtIndex:i decodeForDisplay:YES]; if (!frame.image) continue; double segmentDuration 0; NSUInteger end MIN(i stride, frameCount); for (NSUInteger j i; j end; j) { segmentDuration durations[j].doubleValue; } UIImage *scaled [self scaleImage:frame.image toWidth:maxWidth]; // 到这里拿到scaled缩放后的当前帧、segmentDuration该帧应持续的时间 }这一段循环里又用 durations 做了累加不会重复解码完整帧因为取的是之前存好的 NSNumberCPU 开销很小。如果帧数非常多也可以提前做一个前缀和数组不过我实测下来直接累加就够了。3.4 缩放保留透明通道缩放的核心代码- (UIImage *)scaleImage:(UIImage *)image toWidth:(CGFloat)maxWidth { if (maxWidth 0 || image.size.width maxWidth) return image; CGFloat ratio (CGFloat)maxWidth / image.size.width; CGSize target CGSizeMake(floor(image.size.width * ratio), floor(image.size.height * ratio)); UIGraphicsBeginImageContextWithOptions(target, NO, 1.0); [image drawInRect:CGRectMake(0, 0, target.width, target.height)]; UIImage *result UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return result; }注意两点一是UIGraphicsBeginImageContextWithOptions的 opaque 参数要传 NO否则透明背景会被填成黑色二是 scale 参数传 1.0。如果你传 0在 Retina 屏上可能会生成 2x 尺寸的 bitmap导致 GIF 实际像素更大或者内存翻倍。有些人习惯用UIGraphicsBeginImageContext它默认 scale0新 SDK 里还容易触发弃用警告统一用带 options 的版本最稳。3.5 重量级优化颜色量化到了降色这一步才真正把 GIF 压“透”。一个典型的做法是引入 libimagequant。它接收 RGBA 像素数据做中位切分量化输出一个调色板和一个索引图还能顺便做抖动。具体流程是把当前帧的 CGImage 转成 RGBA 字节数组用liq_image_create_rgba创建量化输入图像调用liq_image_quantize得到量化结果读取量化后的索引图像素和调色板再转回 CGImage交给 GIF 编码器。libimagequant 很小很多图像工具链都在用接起来也不难。如果你不想接 C 库也可以只做“抽帧 缩放”两步很多场景能把 21MB 压到 5MB 左右观感损失很小内存占用和加载时间已经有明显改善。如果产品还要求“再小一点”再上颜色量化。这一点很关键我建议先不加量化跑通主流程确认前两刀的效果再决定要不要第三刀。3.6 编码YYImageEncoder 还是 ImageIO如果对帧时长没有特殊要求YYImageEncoder 是最省事的YYImageEncoder *encoder [YYImageEncoder encoderWithType:YYImageTypeGIF]; for (NSUInteger i 0; i frameCount; i stride) { // ...拿到 scaled 和 segmentDuration... [encoder addImage:scaled duration:segmentDuration]; } NSData *outData [encoder encode];如果希望完全控制 GIF 的 delay 字段用 ImageIO 更直接NSMutableData *outData [NSMutableData data]; CGImageDestinationRef dest CGImageDestinationCreateWithData((__bridge CFMutableDataRef)outData, kUTTypeGIF, frameCount, NULL); for (NSUInteger i 0; i frameCount; i stride) { // ...拿到 scaled 和 segmentDuration... NSDictionary *frameProps { (__bridge id)kCGImagePropertyGIFDictionary: { (__bridge id)kCGImagePropertyGIFDelayTime: (segmentDuration), } }; CGImageDestinationAddImage(dest, scaled.CGImage, (__bridge CFDictionaryRef)frameProps); } CGImageDestinationFinalize(dest); CFRelease(dest);我一般用 YYImageDecoder ImageIO 的组合解码交给 YYImage编码交给系统框架。这样即使以后要改成 WebP 或 APNG只需要换编码器管线不用重写。4. 压完效果怎么样一组实测数据4.1 三种典型素材我拿了三类典型素材跑测试素材 A聊天表情动图280×2805 秒20fps原始 3.2MB素材 B在线视频工具转出来的 GIF720p8 秒15fps原始 21.4MB素材 C带透明通道的 UI 动效240×2406 秒25fps原始 1.6MB。下面是我这边的一组对比数据素材压缩配置压缩后体积压缩率观感A仅抽帧 stride21.9MB59%无明显差异Astride2 颜色 1281.2MB37%肉眼看不出明显断层Astride2 颜色 640.8MB25%渐变处有轻微色块Bscale0.5 stride26.8MB32%小窗观看流畅清晰Bscale0.5 stride2 128 色2.9MB14%人脸、实物观感可接受Bscale0.4 stride3 64 色1.2MB6%有明显噪点适合列表封面Cstride2 颜色 1280.9MB56%透明边缘有轻微锯齿说明不同素材的画噪程度不同压缩率会有波动但趋势是一致的——先抽帧再缩放通常能压到原始体积的三分之一上下再加上颜色量化能到十分之一。也就是从 20MB 量级压到 2MB 量级移动端体验差别是断崖级的。4.2 内存和耗时要单独评估GIF 压缩最容易被忽视的是内存峰值。有人会把 100 帧全部解成 UIImage 放进数组再统一处理结果 iPhone 11 上内存直接飙到 400MB紧接着就 OOM。我上面这套流程是边解码边写编码器解码器本身只保留必要状态RGB 位图用完即释放素材 B 压缩全程内存峰值约 80MB耗时约 3.5 秒iPhone 12含 libimagequant 量化。如果不开颜色量化耗时通常在 1 秒内。这个操作必须丢到后台队列并且要有取消入口否则用户从网络慢的环境打开大图主线程会被卡死。压缩完成后回到主线程更新 UI不要让回调占住全局队列不放。5. 容易翻车的地方透明、延迟、局部帧、内存5.1 透明背景压成黑底GIF 支持 1bit 透明也就是像素要么全透明要么全不透明没有半透明。如果你用UIGraphicsBeginImageContext或忘记传 opaqueNO透明区域会被填成不透明黑色。我一开始就踩过这个坑一帧帧检查时才看到黑底一片。处理方法就是前面写的UIGraphicsBeginImageContextWithOptions(target, NO, 1)。另外如果素材边缘有半透明过渡压成 GIF 后要么掉成锯齿要么形成一圈白边。更稳妥的做法是压缩前把半透明区域合并到纯色背景上比如统一压到白色背景这样体积通常也更小。聊天场景里白色背景的表情包看起来也比透明底干净不少。5.2 帧时长越压越短抽帧时如果不做时长累加整个 GIF 的播放时间会缩短。比如 20fps 抽掉一半帧动画时长变成原来的一半人物动作会加速。我早期测试中发现有些 GIF 总时长从 5 秒变成 2.5 秒还以为是解码器的问题后来才发现是被抽掉的帧没有把 duration 转移过来。还有一类特殊情况很多工具导出的 GIF第一帧或某几帧 delay 是 0。GIF 规范里 delay 为 0 时播放器要用默认值但不同播放器默认值不一样有人按 0.1s 算有人按 0.04s 算。如果我在压缩前不设置兜底值抽帧累加出来的时长会和原视频对不上。统一duration 0.02就置为0.04是个比较稳的兜底。5.3 局部帧不合成直接缩放会花屏GIF 不是每一帧都存完整画面。很多 GIF 第一帧是全图后面几帧可能只记录“下一帧相对上一帧变了哪些像素”。如果用decodeForDisplay:NO拿到原始帧再按自己的尺寸缩放那些只记录差异的帧就会变成残缺画面压完之后动起来全是错位的碎片。解决方法就是解码每一帧时传YES让 YYImageDecoder 先把该帧合成到完整画布上再缩放。代价是 CPU 会高一些但正确性优先。这也是我坚持用 YYImage 而不是自己拿 ImageIO 拼的原因之一。5.4 内存不要一次全解还要注意自动释放池压缩动图时最忌讳一次性把所有帧解码到内存里。我实际算过一笔账720p 的 RGBA 位图一帧是 4MB50 帧就是 200MB再叠加调色板、临时 bufferiPhone 上很容易触发内存警告。正确的做法是“解码一帧、处理一帧、编码一帧、释放一帧”。用 YYImageDecoder 时循环体内对 frame.image 处理完后不要强引用它。如果是 Swift 项目建议在 autoreleasepool 包里包一层避免图片 CGImage 被缓存到自动释放池底部。ImageIO 的CGImageDestinationAddImage是逐帧写入的整个过程不需要提前缓存全部帧这正是我选它做编码端的原因之一。顺带提一下取消逻辑压缩长 GIF 时用户可能已经离开页面。最好在压缩方法里增加一个isCanceled的原子标志位循环里检查到就 break 掉。我见过不少项目压缩到一半卡住就是因为没有取消机制。6. 视频转出来的 GIF也别绕路6.1 在线工具只是起点现在有很多在线工具可以把视频片段转成 GIF比如大家常用的 aconvert 这类视频转 GIF 服务。它们确实方便但输出通常有两个特点分辨率保持原始视频尺寸帧率动不动 15fps 以上于是体积非常夸张。在线转换只是第一步最后仍然需要走一次“抽帧-缩放-降色”的处理才适合移动端分发。我的建议是如果产品里要展示的是 6 秒以内的短素材先用 maxWidth480、stride3、maxColors128 跑一版预览。拿这个预览和原图对比绝大多数场景下体积能从 15~20MB 掉到 2~3MB观感损失很小。如果素材内容是大面积的渐变或高速运动场景再在 256 色和 128 色之间调一档。6.2 移动端展示优先看“目标尺寸”视频转 GIF 时很多人习惯直接转成 720p然后到 App 里靠 UIImageView 的 contentMode 缩小。这是最浪费的做法解码的时候位图还是 720p内存和体积全被撑大。不如在压缩管线里直接限制 maxWidth。如果聊天列表只显示 320pt 宽那 480px 的源图已经绰绰有余。压缩后再用 YYImage 封装显示端不用做任何缩放计算内存还有明显下降。另外在转换环节如果能控制帧率尽量先让工具导出 10fps 以下的版本后续抽帧压力会小很多。如果工具不提供帧率选项也没关系直接在压缩管线里做 stride3 就好。6.3 压缩后如何接回播放器压完的 NSData 可以直接包成 YYImageNSData *compressedData [self compressGIFData:rawData maxWidth:320 stride:2 maxColors:128]; YYImage *animatedImage [YYImage imageWithData:compressedData]; YYAnimatedImageView *imageView [[YYAnimatedImageView alloc] initWithImage:animatedImage];这样从压缩到展示是一条完整的链路。如果业务端用的是普通 UIImageView直接[UIImage imageWithData:compressedData]也可以但要注意普通 UIImageView 只能显示 GIF 的第一帧动态图必须配合 YYAnimatedImageView 或 FLAnimatedImage 这类视图控件。视频转出来的 GIF 往往用 128 色就够了不要一开始就上 32 色。颜色太少时人的皮肤、天空、树木这类连续色调会直接断层看上去像损坏的图片64 色适合缩略图128 色适合正常表情和卡片图。纯色块的 UI 动效32 色都绰绰有余。跑一版数据再定参数比我在这列一堆推荐值都管用。最后再说一个我自己的默认参数聊天场景长边 240~320步长 2颜色数 128列表封面类长边 480步长 3颜色数 96。真上线前我会把最复杂的一帧抽出来做对比肉眼无大断层就发布。这套流程我已经跑了三四个需求目前还没有用户反馈图片太糊倒是收到了“表情包加载快多了”的反馈。希望你也少踩几个我踩过的坑。
返回列表