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(帧缓冲存储器)简称帧缓存,最终显示到手机屏幕)

iOS 图片渲染的原理

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 &gt;图形上下文的在表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)。

iOS框架使用:Lottie 动画特效