之前工作中遇到的问题,使用 SDWebImage(v4.4.8)下载并存储了一份 GIF 图片,第一次是可以正常显示的,但之后再从缓存中取出的时候就变成了静态图片。debug 发现是因为从磁盘里取出该图片时已经是 PNG 格式的了。为了解决这个问题所以去仔细看了下 SD 对 GIF 的处理。
测试代码大概是这个样子的:
1 | NSURL *url = [NSURL URLWithString:@"http://img.xxx.gif"]; |
发现问题主要是出在 SDImageCache
这个类的 storeImage:forKey:completion:
方法,在 L192-L197 行是这样的:
1 | if (!data && image) { |
注释也写的明白:
If we do not have any data to detect image format, check whether it contains alpha channel to use PNG or JPEG format
判断图片格式主要用的这个方法:NSData+ImageContentType.m #L23-L70:
1 | + (SDImageFormat)sd_imageFormatForImageData:(nullable NSData *)data { |
因为所用的缓存方法只传了 UIImage 进去,没有 NSData,SD 无法判断图片格式,所以就根据 alpha channel 使用了 PNG 或者 JPEG 格式。
当时为了快速解决 bug,项目上线。所以将 storeImage:forKey:completion:
方法替换为了 storeImage:imageData:forKey:toDisk:completion:
,即:
1 | [[SDWebImageDownloader sharedDownloader] downloadImageWithURL:url options:SDWebImageDownloaderLowPriority progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) { |
并在代码里加了备注,解释了原因及提醒了相关维护同学。
这个周末有空了,突然想起来这个问题,想着既然图片是由 SD 下载的,那它肯定能拿到图片的 NSData。既然这样,可以在下载的时候根据 NSData 识别图片格式,给 UIImage 打个标签,这样,在存储的时候不就可以识别到该图片的 GIF 格式了嘛。然后就去浏览了下 SD 的源码,发现,其实 SD 已经给所有它经手的图片打过了标签:sd_imageFormat
,即 UIImage+Metadata
这个分类的属性:
1 | - (SDImageFormat)sd_imageFormat { |
既然如此的话,那在 SDImageCache
这个类的 storeImage:forKey:completion:
方法 L192-L197 行优先识别下 sd_imageFormat
不就好了,即:
1 | if (!data && image) { |
虽然那些未经 SD 处理过的 GIF 格式的 UIImage 使用该方法时仍然会判定为 PNG 或者 JPEG,但经过 SD 下载来的图片做缓存存取的话都会保证格式的。我提了 Issue #2952 ,SD 的维护者已经采纳了意见,提交了 PR #2953,改动了代码到 Master 了,预计 SD 5.6.0 版本会生效。
看到 5.6.0 版本可能会很突兀,文章开头不是说 SDWebImage(v4.4.8)吗?
是这样的,我到 SD 官网查代码的时候发现 SD 在去年就发布了 5.x 版本。经测试,存储 GIF 图片再取出时仍然是动图,似乎已经修复了这个问题,但是通过 debug 发现,仍然是动图的原因是 SD 将图片的编解码方式改了,实际存储到磁盘的是 _UIAnimatedImage
类型,即直接设置 _UIAnimatedImage
类型给 UIImageView.image 也是动图效果,但是从取出图片的 NSData 头发现仍然是 0x89 PNG 类型。这也是为什么我仍然在 5.x 版本提交 Issus 的原因。
顺便提一下,SD v5.x 版本移除了 FLAnimatedImageView+WebCache
分类,新增了 SDAnimatedImageView
类来处理 GIF 等格式。其实,得益于 v5.x 新的编解码方法,直接使用 UIImageView 就可以正常显示 GIF。
你仍然可以使用 FLAnimatedImageView+WebCache
这个分类兼容代码,通过引入 SDWebImageFLPlugin
插件的方式。
可以去了解下 SD 的 README,提供了一堆的 Plugin 和 Coder 来支持各种类型的图片和三方库。这个周末还新增了 SDWebImageLottiePlugin
插件,支持 JSON 动画。
通过无限插件的形式来丰富 SD 的功能,感觉很牛逼。升级升级!
言归正传,现在,我们来看一下关键代码,为什么同样存储为 0x89,v4.x 版本就是静态图片,而 v5.x 就是动图呢?
v4.x
在 v4.x 的代码里,SDWebImageDownloaderOperation
这个类负责下载数据,在 #L418 这一行将 NSData 转换为 UIImage。继续查看 SDWebImageCodersManager
这个类的解码(decodedImageWithData:
)方法:
1 | - (UIImage *)decodedImageWithData:(NSData *)data { |
遍历 self.coders 寻找能够解码当前 NSData 的 coder,而此时 self.coders 数组里也就只有 SDWebImageImageIOCoder
这个类而已,继续查看这个类的解码方法:
1 | - (UIImage *)decodedImageWithData:(NSData *)data { |
直接将 data 转成了 UIImage,并且在这个时候根据 data,拿到了图片格式,设置了 sd_imageFormat
。
这里的 image 就是最终回调的 image 了,然后使用
1、SDImageCache
类的 storeImage:forKey:completion:
方法将该 image 存到磁盘的时候,还是调用的
2、SDWebImageCodersManager
这个类的编码(encodedDataWithImage:format:options:
)方法,遍历 self.coders 数组,使用唯一的类:
3、SDWebImageImageIOCoder
的编码方法(encodedDataWithImage:format:
)将 image 按 PNG 格式编码成了 NSData,完成这个方法图片就彻底变成了静态图片,后续存到了磁盘里。编码方法即为关键代码:
1 | - (NSData *)encodedDataWithImage:(UIImage *)image format:(SDImageFormat)format { |
所以这一步存储的时候如果可以通过 sd_imageFormat
拿到正确的 GIF 格式的话,在存储时按照 GIF 格式编码,那么下次取出来动图还是可以生效的。
v5.x
在 v5.x 的代码里,仍然是 SDWebImageDownloaderOperation
这个类负责下载数据,在 #L464 这行处理 NSData 数据。继续查看 SDImageLoader
这个类提供的 SDImageLoaderDecodeImageData
方法:
1 | ... |
在这个方法里判断是否要用 SDAnimatedImage
这个类来解码,主要给 SDAnimatedImageView
或者 FLAnimatedImageView
展示动图时用的。
这里也是和 v4.x 设计不同的地方:v4.x 是在 FLAnimatedImageView+WebCache
分类里拿到回调的 image 和 data 之后再将 data 交由 FLAnimatedImage
处理,而 v5.x 由自己处理动图的编解码工作,在 SDAnimatedImageView+WebCache
/ FLAnimatedImageView+WebCache
分类里下载图片一开始打上 SDWebImageContextAnimatedImageClass
的标记,然后在当前方法里判断标记从而决定是否使用 SDAnimatedImage
解码。
我们这里是直接使用的 SDWebImageDownloader
下载图片,所以最终会由 SDImageCodersManager
这个类的 decodedImageWithData:options:
方法来解码:
1 | - (UIImage *)decodedImageWithData:(NSData *)data options:(nullable SDImageCoderOptions *)options { |
这里就和 v4.x 很相似了,遍历 self.coders 寻找能够解码当前 NSData 的 coder,而此时 self.coders 数组里有 SDImageIOCoder
、SDImageGIFCoder
、SDImageAPNGCoder
三种 coder,当然匹配到 SDImageGIFCoder
来解码当前 data 了,其实走的也是它的父类(SDImageIOAnimatedCoder
)方法:decodedImageWithData:options:
,即为关键代码:
1 |
|
在这个方法拿到 GIF 的每一帧处理成一组 SDImageFrame
类组成的数组,交由 SDImageCoderHelper
这个类处理:
1 | + (UIImage *)animatedImageWithFrames:(NSArray<SDImageFrame *> *)frames { |
最终调用的关键代码就是:```
animatedImage = [UIImage animatedImageWithImages:animatedImages duration:totalDuration / 1000.f];
1 |
|
(lldb) po animatedImage
<_UIAnimatedImage:0x600002040aa0 anonymous {128, 128}>
(lldb)
1 | 这个 `_UIAnimatedImage` 类就是为什么直接将该类设置给 UIImageView.image 也是能够展示动图效果的原因了(注意这个类苹果并没有暴露给开发者使用)。 |
(NSData *)encodedDataWithImage:(UIImage *)image format:(SDImageFormat)format options:(nullable SDImageCoderOptions *)options {
if (!image) {
return nil;
}if (format != self.class.imageFormat) {
return nil;
}NSMutableData *imageData = [NSMutableData data];
CFStringRef imageUTType = [NSData sd_UTTypeFromImageFormat:format];
NSArray<SDImageFrame *> *frames = [SDImageCoderHelper framesFromAnimatedImage:image];// Create an image destination. Animated Image does not support EXIF image orientation TODO
// TheCGImageDestinationCreateWithData
will log a warning when count is 0, use 1 instead.
CGImageDestinationRef imageDestination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, imageUTType, frames.count ?: 1, NULL);
if (!imageDestination) {
// Handle failure.
return nil;
}
NSMutableDictionary *properties = [NSMutableDictionary dictionary];
double compressionQuality = 1;
if (options[SDImageCoderEncodeCompressionQuality]) {
compressionQuality = [options[SDImageCoderEncodeCompressionQuality] doubleValue];
}
properties[(__bridge NSString *)kCGImageDestinationLossyCompressionQuality] = @(compressionQuality);BOOL encodeFirstFrame = [options[SDImageCoderEncodeFirstFrameOnly] boolValue];
if (encodeFirstFrame || frames.count == 0) {
// for static single images
CGImageDestinationAddImage(imageDestination, image.CGImage, (__bridge CFDictionaryRef)properties);
} else {
// for animated images
NSUInteger loopCount = image.sd_imageLoopCount;
NSDictionary *containerProperties = @{self.class.loopCountProperty : @(loopCount)};
properties[self.class.dictionaryProperty] = containerProperties;
CGImageDestinationSetProperties(imageDestination, (__bridge CFDictionaryRef)properties);
for (size_t i = 0; i < frames.count; i++) {
SDImageFrame *frame = frames[i];
NSTimeInterval frameDuration = frame.duration;
CGImageRef frameImageRef = frame.image.CGImage;
NSDictionary *frameProperties = @{self.class.dictionaryProperty : @{self.class.delayTimeProperty : @(frameDuration)}};
CGImageDestinationAddImage(imageDestination, frameImageRef, (__bridge CFDictionaryRef)frameProperties);
}
}
// Finalize the destination.
if (CGImageDestinationFinalize(imageDestination) == NO) {
// Handle failure.
imageData = nil;
}CFRelease(imageDestination);
return [imageData copy];
}
以上,就是为什么同样存储为 0x89,v4.x 版本是静态图片,而 v5.x 是动图的原因。并且分析了 SD 的处理逻辑:v4.x & v5.x 都是由下载模块下载资源拿到 data 交由编解码模块解码成 image 回调使用;存储过程又是将 image 交由编解码模块编码成 data 存储到磁盘。
需要注意的版本差异是:
使用 `[[SDImageCache sharedImageCache] storeImage:image forKey:key completion:nil];` 方法存储 GIF 时,从磁盘再次取出该图片:
v5.x 得益于新的编解码方法会保留动图效果,其中 v5.6.0 之前虽然有动图效果,但取出的 NSData 头信息仍然是 0x89 即 PNG 格式;v5.6.0 之后会修正为 0x47 GIF 格式(根据 SD 维护者提供的预计版本,见 [Issue #2952](https://github.com/SDWebImage/SDWebImage/issues/2952) 和 [PR #2953](https://github.com/SDWebImage/SDWebImage/pull/2953))
v4.x 则会失去动图效果
再次需要注意的是:
使用以上方法所存储的 image 如果是 SD 经手过的,由 NSData 类型转换来的 UIImage,SD 会给它打上 `sd_imageFormat` 标签,即能够正确识别图片类型。如果并非 SD 经手的话,即没有 `sd_imageFormat` 标签的情况,该方法还是会将图片编码为 PNG 或者 JPEG,除非使用 `[[SDImageCache sharedImageCache] storeImage:image imageData:imageData forKey:key toDisk:YES completion:nil];` 方法,同时传入 image 和 imageData。
当然,使用该方法传入 imageData 的话,存储过程中就节省了 image 转 data 的操作,这就是 API 易用性和高性能的选择了。