iOS GIF动画加载 - AlvinSunny/OC-TheUnderlying GitHub Wiki
iOS对原有API加载GIF图缺陷分析
在iOS中处理GIF图片,如果通过原生系统的能力,可能只有两种方式;并且这两种方式都不是专门针对于GIF的解决方案,更像是一种hack
第一种方式: UIImage虽然提供一种创建连续切换的动画图片的能力,但这种能力更像是为了哪些简单动画而服务的;比如加载数据时候显示的loading图片;如果将GIF图片通过这种能力来显示,会带来诸多问题
第二种方式: 可能是大家用的最多的,就是创建一个UIWebView然后在这里面把GIF显示出来;但是从原理来说UIWebView并不是为了显示GIF图片而生的
图片渲染原理分析
- 从磁盘读取原始压缩的图片数据(png/jpeg格式等等)缓存到内存
- CPU解压成未压缩的图片数据位图(ImageBuffer)
- 渲染图片(会生成FrameBuffer(帧缓冲存储器)简称帧缓存,最终显示到手机屏幕)
GIF图加载会遇到什么问题 ?
想象一个场景: 在UITableView的cell上显示图片,图片的显示势必要经历解码的阶段,解码操作是一个复杂耗时的任务; 如果UITableView滑动过程中在cellForRowAtIndexPath方法中加载图片赋值给UIImageView,就相当于在主线程同时进行IO、解码等操作,这样会造成内存迅速增长、CPU负载瞬间提升,内存的占用会导致我们APP的CPU占用高,直接导致耗电大,APP响应变慢进而就会出现卡顿现象,并且内存迅速增加最终会触发系统的内存回收机制,尝试回收其他后台进程的内存,继续下去如果还无法满足就会结束当前进程(FOOM)
以上是单张图片展示时的遇到的问题,GIF图是一组多张图片的显示播放,遇到相同情况只会更加的严重几乎是成倍的内存飙升
由此可见需要解决的问题是: 内存飙升、主线程解码、什么时机停止播放
思路:
解决内存飙升: 预加载+根据FPS单张解码+字节对齐
解决主线程解码: 开启子线程异步解码
什么时机停止播放: 显示GIF的视图还在主窗口上 && 显示GIF视图的父控件不为nil && 显示GIF的视图没有被隐藏 && 显示GIF的视图透明度大于0 && 没超过循环播放次数
FLAnimatedImage
FLAnimatedImage 是由Flipboard开源的iOS平台上播放GIF动画的一个优秀解决方案,在内存占用和播放体验都有不错的表现
对gif图进行解析,将解析后的图片数据放到图片数组里面,这时候先不进行解码,然后启动定时器,定时去数组里面获取单张图片数据去解码,渲染显示到视图上,这样就可以避免一次性对图片数组解码导致的内存飙升问题
FLAnimatedImage会开启异步线程负责解码GIF的每一帧的图片内容(大体上就是加载GIF文件数据,然后抽取出来当前需要哪一帧),然后 FLAnimatedImage会有一个内存区域专门放置这些渲染好的帧。这时候,在主线程中的UIImageView会根据当前需要,从这个内存区域中读取相应的帧。这是一个典型的生产者-消费者模式
FLAnimatedImage是iOS的一个性能动画GIF引擎:
- 同时播放多个动图,播放速度可与桌面浏览器媲美
- 可变帧延迟
- 内存占用底
- 消除第一次播放循环期间的延迟或阻塞
- 用现代浏览器同样的方式解释快速动图的帧延迟
FLAnimatedImage流程图
FLAnimatedImage就是负责GIF数据的处理,然后提供给FLAnimatedImageView一个UIImage对象, FLAnimatedImageView拿到UIImage对象显示出来就可以了
解决内存飙升+主线程问题FLAnimatedImage的核心处理逻辑
创建CADisplayLink定时器
self.displayLink = [CADisplayLink displayLinkWithTarget:weakProxy selector:@selector(displayDidRefresh:)];
随着屏幕FPS播放GIF
- (void)displayDidRefresh:(CADisplayLink *)displayLink {
简化后代码....
//imageLazilyCachedAtIndex: 图像缓存在索引中
UIImage *_Nullable const image = [self.animatedImage imageLazilyCachedAtIndex:self.currentFrameIndex];
}
// See header for more details.
// Note: both consumer and producer are throttled: consumer by frame timings and producer by the available memory (max buffer window size).
- (UIImage *)imageLazilyCachedAtIndex:(NSUInteger)index
{
if ([frameIndexesToAddToCache count] > 0) {
//核心代码 异步添加帧到我们的缓存
[self addFrameIndexesToCache:frameIndexesToAddToCache];
}
// Get the specified image.
UIImage *const image = self.cachedFramesForIndexes[@(index)];
// Purge if needed based on the current playhead position.
[self purgeFrameCacheIfNeeded];
return image;
}
异步解码操作
简化过的代码
- (void)addFrameIndexesToCache:(NSIndexSet *)frameIndexesToAddToCache {
//异步串行队列,用串行队列是为了防止线程爆炸💥
if (!self.serialQueue) {
_serialQueue = dispatch_queue_create("com.flipboard.framecachingqueue", DISPATCH_QUEUE_SERIAL);
}
//开始在后台将请求的帧流传输到缓存中。
//weakSelf 避免在块中捕获自我,因为如果动画图像消失了,就没有理由继续工作。
__weak __typeof(self) weakSelf = self;
dispatch_async(self.serialQueue, ^{
// 产生和缓存下一个需要的帧。
void (^frameRangeBlock)(NSRange, BOOL *) = ^(NSRange range, BOOL *stop) {
// 遍历连续索引;可以比' enumerateIndexesInRange:options:usingBlock: '更快。
for (NSUInteger i = range.location; i < NSMaxRange(range); i++) {
//😁😁😁核心解码代码 imageAtIndex:
UIImage *const image = [weakSelf imageAtIndex:i];
// 一旦准备好(而不是批处理),就会逐一返回结果
if (image && weakSelf) {
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.cachedFramesForIndexes[@(i)] = image;
[weakSelf.cachedFrameIndexes addIndex:i];
[weakSelf.requestedFrameIndexes removeIndex:i];
});
}
}
};
[frameIndexesToAddToCache enumerateRangesInRange:firstRange options:0 usingBlock:frameRangeBlock];
[frameIndexesToAddToCache enumerateRangesInRange:secondRange options:0 usingBlock:frameRangeBlock];
});
}
- (UIImage *)imageAtIndex:(NSUInteger)index
{
// 载入图像对象只完成了一半的工作,显示图像视图仍然需要同步等待并解码图像,所以我们继续在后台线程中执行。
if (self.isPredrawingEnabled) {
image = [[self class] predrawnImageFromImage:image];
}
return image;
}
解码核心逻辑 predrawnImageFromImage:
#define BYTE_SIZE 8 /* byte size in bits */
#pragma mark Image Decoding
// 解码图像数据,并将其完全绘制在内存中;它是线程安全的,因此可以在后台线程中调用
// 成功时,返回的对象是一个新的' UIImage '实例,其内容与传入的对象相同。
// 失败时,返回的对象是传入的未更改的对象;数据不会被预绘制到内存中,并且会记录错误
// First inspired by & good Karma to: https://gist.github.com/steipete/1144242
+ (UIImage *)predrawnImageFromImage:(UIImage *)imageToPredraw {
// 总是使用设备RGB颜色空间,以简单和可预测性将发生什么。
const CGColorSpaceRef _Nullable colorSpaceDeviceRGBRef = CGColorSpaceCreateDeviceRGB();
// Early return on failure!
if (!colorSpaceDeviceRGBRef) {
FLLog(FLLogLevelError, @"Failed to `CGColorSpaceCreateDeviceRGB` for image %@", imageToPredraw);
return imageToPredraw;
}
/**
即使图像没有透明度,我们也必须添加额外的通道,因为Quartz不支持其他像素格式,除了32 bpp/8 bpc的RGB:
kcgimagealphanoneskiipfirst, kCGImageAlphaNoneSkipLast, kCGImageAlphaPremultipliedFirst, kCGImageAlphaPremultipliedLast
(来源:docs“Quartz 2D Programming Guide >图形上下文的在表2-1位图图形上下文支持的像素格式”)
*/
const size_t numberOfComponents = CGColorSpaceGetNumberOfComponents(colorSpaceDeviceRGBRef) + 1; // 4: RGB + A
//“在iOS 4.0和更高版本,OS X v10.6和更高版本中,如果你想让Quartz为位图分配内存,你可以传递NULL。”(来源:文档)
void *_Nullable data = NULL;
const size_t width = imageToPredraw.size.width;//宽
const size_t height = imageToPredraw.size.height;//高
const size_t bitsPerComponent = CHAR_BIT; //CHAR_BIT:表示每个字节的位数
//字节对齐
const size_t bitsPerPixel = (bitsPerComponent * numberOfComponents);
const size_t bytesPerPixel = (bitsPerPixel / BYTE_SIZE);
const size_t bytesPerRow = (bytesPerPixel * width);
CGBitmapInfo bitmapInfo = kCGBitmapByteOrderDefault;
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageToPredraw.CGImage);
// 如果alpha信息与支持的格式之一不匹配(见上面),选择一个合理的支持格式。
// 对于在iOS 3.2及更高版本中创建的位图,绘图环境使用预乘ARGB格式存储位图数据。”(来源:文档)
if (alphaInfo == kCGImageAlphaNone || alphaInfo == kCGImageAlphaOnly) {
alphaInfo = kCGImageAlphaNoneSkipFirst;
} else if (alphaInfo == kCGImageAlphaFirst) {
alphaInfo = kCGImageAlphaPremultipliedFirst;
} else if (alphaInfo == kCGImageAlphaLast) {
alphaInfo = kCGImageAlphaPremultipliedLast;
}
// “用于指定alpha通道信息的常量是用‘CGImageAlphaInfo’类型声明的,但可以安全地传递给这个参数。”(来源:文档)
bitmapInfo |= alphaInfo;
/**
创建我们自己的绘图上下文;
UIGraphicsGetCurrentContext/UIGraphicsBeginImageContextWithOptions不会创建一个新的上下文,
而是返回当前的不是线程安全的上下文(例如主线程可以同时使用它)
注意:不值得为多帧缓存位图上下文(“唯一键”将是' width ', ' height '和' hasAlpha '),
它会慢大约50%。在libRIP的“CGSBlendBGRA8888toARGB8888”中花费的时间突然增加了——不知道为什么。
*/
const CGContextRef _Nullable bitmapContextRef = CGBitmapContextCreate(data, width, height, bitsPerComponent, bytesPerRow, colorSpaceDeviceRGBRef, bitmapInfo);
CGColorSpaceRelease(colorSpaceDeviceRGBRef);
// Early return on failure!
if (!bitmapContextRef) {
return imageToPredraw;
}
// 在位图上下文中绘制图像,并通过保留接收者的属性来创建图像。
CGContextDrawImage(bitmapContextRef, CGRectMake(0.0, 0.0, imageToPredraw.size.width, imageToPredraw.size.height), imageToPredraw.CGImage);
//创建图像
const CGImageRef _Nullable predrawnImageRef = CGBitmapContextCreateImage(bitmapContextRef);
UIImage *_Nullable predrawnImage = predrawnImageRef ? [UIImage imageWithCGImage:predrawnImageRef scale:imageToPredraw.scale orientation:imageToPredraw.imageOrientation] : nil;
CGImageRelease(predrawnImageRef);
CGContextRelease(bitmapContextRef);
// Early return on failure!
if (!predrawnImage) {
return imageToPredraw;
}
return predrawnImage;
}
解决什么时机停止播放问题FLAnimatedImage的核心处理逻辑
通过self.shouldAnimate控制是否继续播放GIF
- (void)updateShouldAnimate {
const BOOL isVisible = self.window && self.superview && ![self isHidden] && self.alpha > 0.0;
self.shouldAnimate = self.animatedImage && isVisible;
}
第一处
- (void)setAnimatedImage:(FLAnimatedImage *)animatedImage {
//简化后的代码.....
// Start animating after the new animated image has been set.
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
}
}
第二处
- (void)didMoveToSuperview {
[super didMoveToSuperview];
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
} else {
[self stopAnimating];
}
}
第三处
- (void)didMoveToWindow {
[super didMoveToWindow];
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
} else {
[self stopAnimating];
}
}
第四处
- (void)setAlpha:(CGFloat)alpha {
[super setAlpha:alpha];
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
} else {
[self stopAnimating];
}
}
第五处
- (void)setHidden:(BOOL)hidden {
[super setHidden:hidden];
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
} else {
[self stopAnimating];
}
}
FLAnimatedImage使用
使用FLAnimatedImage处理GIF动画数据,使用FLAnimatedImageView展示FLAnimatedImage处理后的动画数据,有两种方式NSData、URL
- 可以使用NSData初始化FLAnimatedImage,然后将FLAnimatedImage赋值给FLAnimatedImageView
- 可以使用URL初始化FLAnimatedImage,然后将FLAnimatedImage赋值给FLAnimatedImageView
//NSData方式
NSURL *url1 = [[NSBundle mainBundle] URLForResource:@"rock" withExtension:@"gif"];
NSData *data1 = [NSData dataWithContentsOfURL:url1];
FLAnimatedImage *animatedImage1 = [FLAnimatedImage animatedImageWithGIFData:data1];
self.imageView1.animatedImage = animatedImage1;
//URL方式
NSURL *url2 = [NSURL URLWithString:@"https://cloud.githubusercontent.com/assets/1567433/10417835/1c97e436-7052-11e5-8fb5-69373072a5a0.gif"];
[self loadAnimatedImageWithURL:url2 completion:^(FLAnimatedImage *animatedImage) {
self.imageView2.animatedImage = animatedImage;
}];
FLAnimatedImage项目代码结构
FLAnimatedImage项目采用了“生产者和消费者”模型来处理这个GIF动画的播放问题。一个线程负责生产数据,另一个线程负责消费数据。生产者FLAnimatedImage负责提供帧UIImage对象,消费者FLAnimatedImageView负责显示该UIImage对象
FLAnimatedImage接口介绍
@property (nonatomic, strong, readonly) UIImage *posterImage;//GIF动画的封面帧图片
@property (nonatomic, assign, readonly) CGSize size; //GIF动画的封面帧图片的尺寸
@property (nonatomic, assign, readonly) NSUInteger loopCount; //GIF动画的循环播放次数
@property (nonatomic, strong, readonly) NSDictionary *delayTimesForIndexes; // GIF动画中的每帧图片的显示时间集合
@property (nonatomic, assign, readonly) NSUInteger frameCount; //GIF动画的帧数量
@property (nonatomic, assign, readonly) NSUInteger frameCacheSizeCurrent; //当前被缓存的帧图片的总数量
@property (nonatomic, assign) NSUInteger frameCacheSizeMax; // 允许缓存多少帧图片
// 取出对应索引的帧图片
- (UIImage *)imageLazilyCachedAtIndex:(NSUInteger)index;
// 计算该帧图片的尺寸
+ (CGSize)sizeForImage:(id)image;
// 初始化方法
- (instancetype)initWithAnimatedGIFData:(NSData *)data;
- (instancetype)initWithAnimatedGIFData:(NSData *)data optimalFrameCacheSize:(NSUInteger)optimalFrameCacheSize predrawingEnabled:(BOOL)isPredrawingEnabled NS_DESIGNATED_INITIALIZER;
+ (instancetype)animatedImageWithGIFData:(NSData *)data;
//初始化数据
@property (nonatomic, strong, readonly) NSData *data;
FLAnimatedImage源码解析
- 关键方法 初始化解析 a、对传进来的数据进行合法性判断,至少不能为nil b、初始化对应的变量,用于存储各类辅助数据 c、将传进来的数据处理成图片数据,根据 kCGImageSourceShouldCache 的官方文档描述 Whether the image should be cached in a decoded form. The value of this key must be a CFBoolean value. The default value is kCFBooleanFalse in 32-bit, kCFBooleanTrue in 64-bit. 所以设置 kCGImageSourceShouldCache为NO,可以避免系统对图片进行缓存 d、从数据中读取图片类型,判断该图片是不是GIF动画类型 e、读取GIF动画中的动画信息,包括动画循环次数,有几帧图片等 f、遍历GIF动画中的所有帧图片,取出并保存帧图片的播放信息,设置GIF动画的封面帧图片 g、根据设置或者GIF动画的占用内存大小,与缓存策略对比,确认缓存策略
- (instancetype)initWithAnimatedGIFData:(NSData *)data optimalFrameCacheSize:(NSUInteger)optimalFrameCacheSize predrawingEnabled:(BOOL)isPredrawingEnabled
{
// 1、进行数据合法性判断
BOOL hasData = ([data length] > 0);
if (!hasData) {
FLLog(FLLogLevelError, @"No animated GIF data supplied.");
return nil;
}
self = [super init];
if (self) {
// 2、初始化对应的变量
// Do one-time initializations of `readonly` properties directly to ivar to prevent implicit actions and avoid need for private `readwrite` property overrides.
// Keep a strong reference to `data` and expose it read-only publicly.
// However, we will use the `_imageSource` as handler to the image data throughout our life cycle.
_data = data;
_predrawingEnabled = isPredrawingEnabled;
// Initialize internal data structures
_cachedFramesForIndexes = [[NSMutableDictionary alloc] init];//key->帧图片在GIF动画的索引位置 value->单帧图片
_cachedFrameIndexes = [[NSMutableIndexSet alloc] init];//缓存的帧图片在GIF动画的索引位置集合
_requestedFrameIndexes = [[NSMutableIndexSet alloc] init];//需要生产者生产的的帧图片的索引位置
// 3、创建图片数据
// Note: We could leverage `CGImageSourceCreateWithURL` too to add a second initializer `-initWithAnimatedGIFContentsOfURL:`.
_imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)data,
(__bridge CFDictionaryRef)@{(NSString *)kCGImageSourceShouldCache: @NO});
// Early return on failure!
if (!_imageSource) {
FLLog(FLLogLevelError, @"Failed to `CGImageSourceCreateWithData` for animated GIF data %@", data);
return nil;
}
// 4、取出图片类型,判断是否是GIF动画
// Early return if not GIF!
CFStringRef imageSourceContainerType = CGImageSourceGetType(_imageSource);
BOOL isGIFData = UTTypeConformsTo(imageSourceContainerType, kUTTypeGIF);
if (!isGIFData) {
FLLog(FLLogLevelError, @"Supplied data is of type %@ and doesn't seem to be GIF data %@", imageSourceContainerType, data);
return nil;
}
// 5、取出GIF动画信息
// Get `LoopCount`
// Note: 0 means repeating the animation indefinitely.
// Image properties example:
// {
// FileSize = 314446;
// "{GIF}" = {
// HasGlobalColorMap = 1;
// LoopCount = 0;
// };
// }
NSDictionary *imageProperties = (__bridge_transfer NSDictionary *)CGImageSourceCopyProperties(_imageSource, NULL);
//获取GIF动画循环次数
_loopCount = [[[imageProperties objectForKey:(id)kCGImagePropertyGIFDictionary] objectForKey:(id)kCGImagePropertyGIFLoopCount] unsignedIntegerValue];
// Iterate through frame images
//遍历图片
size_t imageCount = CGImageSourceGetCount(_imageSource);
NSUInteger skippedFrameCount = 0;//用于记录GIF动画中异常帧的数量
NSMutableDictionary *delayTimesForIndexesMutable = [NSMutableDictionary dictionaryWithCapacity:imageCount];//记录GIF动画中每帧图片的显示时间
for (size_t i = 0; i < imageCount; i++) {
@autoreleasepool {
// 6、取出帧图片
//Return the image at `index' in the image source `isrc'.
CGImageRef frameImageRef = CGImageSourceCreateImageAtIndex(_imageSource, i, NULL);
if (frameImageRef) {
UIImage *frameImage = [UIImage imageWithCGImage:frameImageRef];
// Check for valid `frameImage` before parsing its properties as frames can be corrupted (and `frameImage` even `nil` when `frameImageRef` was valid).
if (frameImage) {
// Set poster image
// 取出的第一张图片为GIF动画的封面图片
if (!self.posterImage) {
_posterImage = frameImage;
// Set its size to proxy our size.
_size = _posterImage.size;
// Remember index of poster image so we never purge it; also add it to the cache.
_posterImageFrameIndex = i;
[self.cachedFramesForIndexes setObject:self.posterImage forKey:@(self.posterImageFrameIndex)];
[self.cachedFrameIndexes addIndex:self.posterImageFrameIndex];
}
// 7、取出帧图片的信息
// Get `DelayTime`
// Note: It's not in (1/100) of a second like still falsely described in the documentation as per iOS 8 (rdar://19507384) but in seconds stored as `kCFNumberFloat32Type`.
// Frame properties example:
// {
// ColorModel = RGB;
// Depth = 8;
// PixelHeight = 960;
// PixelWidth = 640;
// "{GIF}" = {
// DelayTime = "0.4";
// UnclampedDelayTime = "0.4";
// };
// }
NSDictionary *frameProperties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(_imageSource, i, NULL);
NSDictionary *framePropertiesGIF = [frameProperties objectForKey:(id)kCGImagePropertyGIFDictionary];
// 8、取出帧图片的展示时间
// Try to use the unclamped delay time; fall back to the normal delay time.
NSNumber *delayTime = [framePropertiesGIF objectForKey:(id)kCGImagePropertyGIFUnclampedDelayTime];
if (!delayTime) {
delayTime = [framePropertiesGIF objectForKey:(id)kCGImagePropertyGIFDelayTime];
}
// If we don't get a delay time from the properties, fall back to `kDelayTimeIntervalDefault` or carry over the preceding frame's value.
const NSTimeInterval kDelayTimeIntervalDefault = 0.1;
if (!delayTime) {
if (i == 0) {
FLLog(FLLogLevelInfo, @"Falling back to default delay time for first frame %@ because none found in GIF properties %@", frameImage, frameProperties);
delayTime = @(kDelayTimeIntervalDefault);
} else {
FLLog(FLLogLevelInfo, @"Falling back to preceding delay time for frame %zu %@ because none found in GIF properties %@", i, frameImage, frameProperties);
delayTime = delayTimesForIndexesMutable[@(i - 1)];
}
}
// Support frame delays as low as `kFLAnimatedImageDelayTimeIntervalMinimum`, with anything below being rounded up to `kDelayTimeIntervalDefault` for legacy compatibility.
// To support the minimum even when rounding errors occur, use an epsilon when comparing. We downcast to float because that's what we get for delayTime from ImageIO.
if ([delayTime floatValue] < ((float)kFLAnimatedImageDelayTimeIntervalMinimum - FLT_EPSILON)) {
FLLog(FLLogLevelInfo, @"Rounding frame %zu's `delayTime` from %f up to default %f (minimum supported: %f).", i, [delayTime floatValue], kDelayTimeIntervalDefault, kFLAnimatedImageDelayTimeIntervalMinimum);
delayTime = @(kDelayTimeIntervalDefault);
}
delayTimesForIndexesMutable[@(i)] = delayTime;
} else {
skippedFrameCount++;
FLLog(FLLogLevelInfo, @"Dropping frame %zu because valid `CGImageRef` %@ did result in `nil`-`UIImage`.", i, frameImageRef);
}
CFRelease(frameImageRef);
} else {
skippedFrameCount++;
FLLog(FLLogLevelInfo, @"Dropping frame %zu because failed to `CGImageSourceCreateImageAtIndex` with image source %@", i, _imageSource);
}
}
}
//帧图片展示时间的数组
_delayTimesForIndexes = [delayTimesForIndexesMutable copy];
//GIF动画有多少帧图片
_frameCount = imageCount;
if (self.frameCount == 0) {
FLLog(FLLogLevelInfo, @"Failed to create any valid frames for GIF with properties %@", imageProperties);
return nil;
} else if (self.frameCount == 1) {
// Warn when we only have a single frame but return a valid GIF.
FLLog(FLLogLevelInfo, @"Created valid GIF but with only a single frame. Image properties: %@", imageProperties);
} else {
// We have multiple frames, rock on!
}
// 9、GIF动画缓存策略
// If no value is provided, select a default based on the GIF.
if (optimalFrameCacheSize == 0) {
// Calculate the optimal frame cache size: try choosing a larger buffer window depending on the predicted image size.
// It's only dependent on the image size & number of frames and never changes.
// 图片的每行字节大小*高*图片数量/1M的字节 = GIF大小(M)
// 根据GIF图的大小和缓存策略判断需要缓存的单帧图片数量
//GIF动画的占用内存大小与FLAnimatedImageDataSizeCategory的方案比较,确认缓存策略
CGFloat animatedImageDataSize = CGImageGetBytesPerRow(self.posterImage.CGImage) * self.size.height * (self.frameCount - skippedFrameCount) / MEGABYTE;
if (animatedImageDataSize <= FLAnimatedImageDataSizeCategoryAll) {
_frameCacheSizeOptimal = self.frameCount;
} else if (animatedImageDataSize <= FLAnimatedImageDataSizeCategoryDefault) {
// This value doesn't depend on device memory much because if we're not keeping all frames in memory we will always be decoding 1 frame up ahead per 1 frame that gets played and at this point we might as well just keep a small buffer just large enough to keep from running out of frames.
_frameCacheSizeOptimal = FLAnimatedImageFrameCacheSizeDefault;
} else {
// The predicted size exceeds the limits to build up a cache and we go into low memory mode from the beginning.
_frameCacheSizeOptimal = FLAnimatedImageFrameCacheSizeLowMemory;
}
} else {
// Use the provided value.
_frameCacheSizeOptimal = optimalFrameCacheSize;
}
// In any case, cap the optimal cache size at the frame count.
// _frameCacheSizeOptimal 不能大于 self.frameCount
// 确认最佳的GIF动画的帧图片缓存数量
_frameCacheSizeOptimal = MIN(_frameCacheSizeOptimal, self.frameCount);
// Convenience/minor performance optimization; keep an index set handy with the full range to return in `-frameIndexesToCache`.
_allFramesIndexSet = [[NSIndexSet alloc] initWithIndexesInRange:NSMakeRange(0, self.frameCount)];
// See the property declarations for descriptions.
//成为FLWeakProxy的代理
_weakProxy = (id)[FLWeakProxy weakProxyForObject:self];
// Register this instance in the weak table for memory notifications. The NSHashTable will clean up after itself when we're gone.
// Note that FLAnimatedImages can be created on any thread, so the hash table must be locked.
@synchronized(allAnimatedImagesWeak) {
[allAnimatedImagesWeak addObject:self];
}
}
return self;
}
- 关键方法 取UIImage对象 a、对索引位置进行判断,避免出现越界情况 b、记录当前取出的帧图片的索引位置 c、根据缓存策略判断接下来需要生产的帧图片索引,正常是当前显示帧图片之后的帧图片的索引。 d、根据需要生产的帧图片索引生产帧图片 e、取出对应的帧图片 f、根据缓存策略清缓存
// See header for more details.
// Note: both consumer and producer are throttled: consumer by frame timings and producer by the available memory (max buffer window size).
- (UIImage *)imageLazilyCachedAtIndex:(NSUInteger)index
{
// Early return if the requested index is beyond bounds.
// Note: We're comparing an index with a count and need to bail on greater than or equal to.
// 1、索引位置判断
if (index >= self.frameCount) {
FLLog(FLLogLevelWarn, @"Skipping requested frame %lu beyond bounds (total frame count: %lu) for animated image: %@", (unsigned long)index, (unsigned long)self.frameCount, self);
return nil;
}
// Remember requested frame index, this influences what we should cache next.
// 2、记录当前要生产的帧图片在GIF动画中的索引位置
self.requestedFrameIndex = index;
#if defined(DEBUG) && DEBUG
if ([self.debug_delegate respondsToSelector:@selector(debug_animatedImage:didRequestCachedFrame:)]) {
[self.debug_delegate debug_animatedImage:self didRequestCachedFrame:index];
}
#endif
// Quick check to avoid doing any work if we already have all possible frames cached, a common case.
// 3、判断GIF动画的帧图片的是否全部缓存下来了,因为有可能缓存策略是缓存所有的帧图片
if ([self.cachedFrameIndexes count] < self.frameCount) {
// If we have frames that should be cached but aren't and aren't requested yet, request them.
// Exclude existing cached frames, frames already requested, and specially cached poster image.
// 4、根据缓存策略得到接下来需要缓存的帧图片索引,
NSMutableIndexSet *frameIndexesToAddToCacheMutable = [self frameIndexesToCache];
// 5、除去已经缓存下来的帧图片索引
[frameIndexesToAddToCacheMutable removeIndexes:self.cachedFrameIndexes];
[frameIndexesToAddToCacheMutable removeIndexes:self.requestedFrameIndexes];
[frameIndexesToAddToCacheMutable removeIndex:self.posterImageFrameIndex];
NSIndexSet *frameIndexesToAddToCache = [frameIndexesToAddToCacheMutable copy];
// Asynchronously add frames to our cache.
if ([frameIndexesToAddToCache count] > 0) {
// 6、生产帧图片
[self addFrameIndexesToCache:frameIndexesToAddToCache];
}
}
// Get the specified image.
// 7、取出帧图片
UIImage *image = self.cachedFramesForIndexes[@(index)];
// Purge if needed based on the current playhead position.
// 8、根据缓存策略清缓存
[self purgeFrameCacheIfNeeded];
return image;
}
- 其他关键方法简单介绍
// Only called once from `-imageLazilyCachedAtIndex` but factored into its own method for logical grouping.
// 生产帧图片
- (void)addFrameIndexesToCache:(NSIndexSet *)frameIndexesToAddToCache;
// 取出GIF动画的帧图片
- (UIImage *)imageAtIndex:(NSUInteger)index;
// Decodes the image's data and draws it off-screen fully in memory; it's thread-safe and hence can be called on a background thread.
// On success, the returned object is a new `UIImage` instance with the same content as the one passed in.
// On failure, the returned object is the unchanged passed in one; the data will not be predrawn in memory though and an error will be logged.
// First inspired by & good Karma to: https://gist.github.com/steipete/1144242
// 解码图片
+ (UIImage *)predrawnImageFromImage:(UIImage *)imageToPredraw;
FLAnimatedImageView接口
// FLAnimatedImageView是UIImageView的子类,完全兼容UIImageView的各个方法。
@interface FLAnimatedImageView : UIImageView
@property (nonatomic, strong) FLAnimatedImage *animatedImage;//设置GIF动画数据
@property (nonatomic, copy) void(^loopCompletionBlock)(NSUInteger loopCountRemaining);//GIF动画播放一次之后的回调Block
@property (nonatomic, strong, readonly) UIImage *currentFrame;//GIF动画当前显示的帧图片
@property (nonatomic, assign, readonly) NSUInteger currentFrameIndex;//GIF动画当前显示的帧图片索引
//动画循环模式。通过使用NSRunLoopCommonModes允许计时器事件(即动画)在滚动期间启用回放。
//为了在单核设备(如iPhone 3GS/4和iPod Touch第4代)上保持滚动流畅,默认的运行循环模式是nsdefultrunloopmode。否则,默认为nsdefaulultrunloopmode
@property (nonatomic, copy) NSString *runLoopMode;
@end
FLAnimatedImageView解析
- 关键方法 设置FLAnimatedImage对象解析 a、判断新旧FLAnimatedImage对象是否一致,一致就不需要继续操作了 b、设置GIF动画的封面帧图片,当前帧索引,GIF动画的循环播放次数,播放时间累加器 c、更新是否发起动画的标志位,判断是否启动GIF动画 d、刷新View的layer
- (void)setAnimatedImage:(FLAnimatedImage *)animatedImage {
//新设置的GIF动画数据和当前的数据不一致
if (![_animatedImage isEqual:animatedImage]) {
if (animatedImage) {
// Clear out the image.
super.image = nil;
// Ensure disabled highlighting; it's not supported (see `-setHighlighted:`).
super.highlighted = NO;
// UIImageView seems to bypass some accessors when calculating its intrinsic content size, so this ensures its intrinsic content size comes from the animated image.
//确保UIImageView的content size 大小来自 animated image
[self invalidateIntrinsicContentSize];
} else {
// Stop animating before the animated image gets cleared out.
// animatedImage为nil,需要清空当前动画图片
[self stopAnimating];
}
_animatedImage = animatedImage;
self.currentFrame = animatedImage.posterImage;//GIF动画的封面帧图片
self.currentFrameIndex = 0;//当前的帧图片索引
//设置GIF动画的循环播放次数
if (animatedImage.loopCount > 0) {
self.loopCountdown = animatedImage.loopCount;
} else {
self.loopCountdown = NSUIntegerMax;
}
//播放时间累加器
self.accumulator = 0.0;
// Start animating after the new animated image has been set.
[self updateShouldAnimate];
if (self.shouldAnimate) {
[self startAnimating];
}
[self.layer setNeedsDisplay];
}
}
- 关键方法 设置CADisplayLink的frameInterval
- (void)startAnimating
{
//使用CADisplayLink来播放GIF动画
if (self.animatedImage) {
// Lazily create the display link.
if (!self.displayLink) {
FLWeakProxy *weakProxy = [FLWeakProxy weakProxyForObject:self];
self.displayLink = [CADisplayLink displayLinkWithTarget:weakProxy selector:@selector(displayDidRefresh:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:self.runLoopMode];
}
//注意:显示链接的'.frameinterval '值为1(默认值)意味着以显示的刷新率(~60Hz)获得回调。
//设置为2将帧速率除以2,因此在每次刷新显示时回调。
// 1、frameInterval : Defines how many display frames must pass between each time the display link fires.
// 2、先求出gif中每帧图片的播放时间,求出这些播放时间的最大公约数,
// 3、将这个最大公约数*刷新速率,再与1比取最大值,该值作为frameInterval。
// 4、将GIF动画的每帧图片显示时间除以帧显示时间的最大公约数,得到单位时间内GIF动画的每个帧显示时间的比例,然后再乘以屏幕刷新速率kDisplayRefreshRate作为displayLink.frameInterval,正好可以用displayLink调用刷新方法的频率来保证GIF动画的帧图片展示时间 frame delays的间隔比例,使GIF动画的效果能够正常显示。
if (@available(iOS 10, *)) {
//调整preferredFramesPerSecond允许我们在显示不快速动画的gif时跳过对displaydirefresh:的不必要调用。使用天花板在太多FPS的情况下犯错,这样我们就不会错过一个帧转换时刻。
self.displayLink.preferredFramesPerSecond = ceil(1.0 / [self frameDelayGreatestCommonDivisor]);
} else {
const NSTimeInterval kDisplayRefreshRate = 60.0; // 60Hz
self.displayLink.frameInterval = MAX([self frameDelayGreatestCommonDivisor] * kDisplayRefreshRate, 1);
}
self.displayLink.paused = NO;
} else {
[super startAnimating];
}
}
- 关键方法 播放GIF动画 该方法关键点在于accumulator累加器的使用和displayLink.frameInterval的计算,涉及一些简单的数学过程
- (void)displayDidRefresh:(CADisplayLink *)displayLink
{
// If for some reason a wild call makes it through when we shouldn't be animating, bail.
// Early return!
if (!self.shouldAnimate) {
FLLog(FLLogLevelWarn, @"Trying to animate image when we shouldn't: %@", self);
return;
}
NSNumber *delayTimeNumber = [self.animatedImage.delayTimesForIndexes objectForKey:@(self.currentFrameIndex)];
// If we don't have a frame delay (e.g. corrupt frame), don't update the view but skip the playhead to the next frame (in else-block).
if (delayTimeNumber) {
NSTimeInterval delayTime = [delayTimeNumber floatValue];
// If we have a nil image (e.g. waiting for frame), don't update the view nor playhead.
// 拿到当前要显示的图片
UIImage *image = [self.animatedImage imageLazilyCachedAtIndex:self.currentFrameIndex];
if (image) {
FLLog(FLLogLevelVerbose, @"Showing frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage);
//显示图片
self.currentFrame = image;
if (self.needsDisplayWhenImageBecomesAvailable) {
[self.layer setNeedsDisplay];
self.needsDisplayWhenImageBecomesAvailable = NO;
}
//frameInterval:Defines how many display frames must pass between each time the display link fires
//duration :duration of the display frame
//displayLink.duration * displayLink.frameInterval是每个display link fires之间的时间间隔
self.accumulator += displayLink.duration * displayLink.frameInterval;
//从前面的startAnimating方法中displayLink.frameInterval的计算过程可以知道,
//GIF动画中的帧图片的展示时间都是delayTime都是displayLink.duration * displayLink.frameInterval的倍数关系,
//也就是说一个GIF动画帧图片的展示时间至少是一个display link fires的时间间隔。
//以下数据是使用FLAnimatedImage的Demo项目的第一个GIF动画的播放信息打印出来的。
//按照Demo中的打印数据来说,第0帧图片的展示时间是14个display link fires的时间间隔,而1,2,3帧图片都是只有一个display link fires的时间间隔。
//所以累加器self.accumulator的意义在于累加display link fires的时间间隔,并与帧图片的delayTime做比较,如果小于delayTime说明该帧图片还需要继续展示,否则该帧图片结束展示。
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.050000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.100000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.150000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.200000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.250000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.300000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.350000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.400000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.450000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.500000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.550000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.600000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.650000, delayTime-->0.700000
// currentFrameIndex-->0, duration--->0.016667, frameInterval-->3, accumulator-->0.700000, delayTime-->0.700000
// currentFrameIndex-->1, duration--->0.016667, frameInterval-->3, accumulator-->0.050000, delayTime-->0.050000
// currentFrameIndex-->2, duration--->0.016667, frameInterval-->3, accumulator-->0.050000, delayTime-->0.050000
// currentFrameIndex-->3, duration--->0.016667, frameInterval-->3, accumulator-->0.050000, delayTime-->0.050000
// While-loop first inspired by & good Karma to: https://github.com/ondalabs/OLImageView/blob/master/OLImageView.m
while (self.accumulator >= delayTime) {
self.accumulator -= delayTime;
self.currentFrameIndex++;
if (self.currentFrameIndex >= self.animatedImage.frameCount) {
// 播放到结尾,循环次数减1
// If we've looped the number of times that this animated image describes, stop looping.
self.loopCountdown--;
if (self.loopCompletionBlock) {
self.loopCompletionBlock(self.loopCountdown);
}
// 循环次数为0,停止播放,退出方法
if (self.loopCountdown == 0) {
[self stopAnimating];
return;
}
//重置帧图片索引,继续从头开始播放gif动画
self.currentFrameIndex = 0;
}
// Calling `-setNeedsDisplay` will just paint the current frame, not the new frame that we may have moved to.
// Instead, set `needsDisplayWhenImageBecomesAvailable` to `YES` -- this will paint the new image once loaded.
// 展示新图片
self.needsDisplayWhenImageBecomesAvailable = YES;
}
} else {
FLLog(FLLogLevelDebug, @"Waiting for frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage);
#if defined(DEBUG) && DEBUG
if ([self.debug_delegate respondsToSelector:@selector(debug_animatedImageView:waitingForFrame:duration:)]) {
[self.debug_delegate debug_animatedImageView:self waitingForFrame:self.currentFrameIndex duration:(NSTimeInterval)displayLink.duration * displayLink.frameInterval];
}
#endif
}
} else {
//取不到需要的信息直接开始下一张图片播放
self.currentFrameIndex++;
}
}
其他优秀框架
YYWebImage
YYWebImage 是一个异步图片加载框架 (YYKit 组件之一).
其设计目的是试图替代 SDWebImage、PINRemoteImage、FLAnimatedImage 等开源框架,它支持这些开源框架的大部分功能,同时增加了大量新特性、并且有不小的性能提升。 它底层用 YYCache 实现了内存和磁盘缓存, 用 YYImage 实现了 WebP/APNG/GIF 动图的解码和播放。
特性
- 异步的图片加载,支持 HTTP 和本地文件。
- 支持 GIF、APNG、WebP 动画(动态缓存,低内存占用)。
- 支持逐行扫描、隔行扫描、渐进式图像加载。
- UIImageView、UIButton、MKAnnotationView、CALayer 的 Category 方法支持。
- 常见图片处理:模糊、圆角、大小调整、裁切、旋转、色调等。
- 高性能的内存和磁盘缓存。
- 高性能的图片设置方式,以避免主线程阻塞。
- 每个类和方法都有完善的文档注释
PINRemoteImage
Pinterest 作为图片社交 app 的始祖之一,每天要处理千万计的图片,它们在图片下载和显示方面的能力自然也是毋庸置疑的。最近 Pinterest 开源了一个iOS 下的图片下载和缓存的框架PINRemoteImage。PINRemoteImage除了常规的异步下载和缓存之外,还可以支持像是 WepP 或者 gif 这样的图片。另外,这个框架还提供对 JPG 图片的逐步下载,即可以迅速显示部分或者模糊的图片,在过程中等待下载的完成,最后显示完整图片。这在增强用户体验方面会十分有用
PINRemoteImageManager是一个图像下载、处理和缓存管理器。它使用下载和处理任务的概念,以确保即使多次调用下载或处理图像,也只发生一次(除非缓存中不再有项)。PINRemoteImageManager由GCD支持,可以安全地从多个线程同时访问。它确保图像从主线程中被解码,这样动画性能就不会受到影响。它公开的方法都不允许同步访问。但是,如果一个项在调用线程的内存缓存中,它优化为在调用线程上调用补全。
PINRemoteImage支持下载多种类型的文件。当然,它同时支持png和jpg。如果谷歌的库可用,它还支持解码WebP图像。它甚至通过PINAnimatedImageView支持gif和动画WebP。
PINRemoteImage还有两种方法来改善在慢速网络连接下下载图像的体验。首先是对渐进jpg的支持。这并不是对渐进式jpg的任何旧支持:PINRemoteImage在返回渐进式扫描之前添加了一个吸引人的模糊
PINRemoteImageCategoryManager定义了一个协议,UIView子类可以实现它,并提供对PINRemoteImageManager的方法的简单访问。在UIImageView, PINAnimatedImageView和UIButton上有内置的类别,它很容易实现一个新的类别。参考已有分类的[UIImageView+PINRemoteImage](/Pod/Classes/Image Categories/UIImageView+PINRemoteImage.h)。