// An `FLAnimatedImage`'s job is to deliver frames in a highly performant way and works in conjunction with `FLAnimatedImageView`.
// It subclasses `NSObject` and not `UIImage` because it's only an "image" in the sense that a sea lion is a lion.
// It tries to intelligently choose the frame cache size depending on the image and memory situation with the goal to lower CPU usage for smaller ones, lower memory usage for larger ones and always deliver frames for high performant play-back.
// Note: `posterImage`, `size`, `loopCount`, `delayTimes` and `frameCount` don't change after successful initialization.
//
@interfaceFLAnimatedImage:NSObject
@property(nonatomic,strong,readonly)UIImage*posterImage;// Guaranteed to be loaded; usually equivalent to `-imageLazilyCachedAtIndex:0`
@property(nonatomic,assign,readonly)CGSizesize;// The `.posterImage`'s `.size`
@property(nonatomic,assign,readonly)NSUIntegerloopCount;// 0 means repeating the animation indefinitely
@property(nonatomic,strong,readonly)NSDictionary*delayTimesForIndexes;// Of type `NSTimeInterval` boxed in `NSNumber`s
@property(nonatomic,assign,readonly)NSUIntegerframeCount;// Number of valid frames; equal to `[.delayTimes count]`
@property(nonatomic,assign,readonly)NSUIntegerframeCacheSizeCurrent;// Current size of intelligently chosen buffer window; can range in the interval [1..frameCount]
@property(nonatomic,assign)NSUIntegerframeCacheSizeMax;// Allow to cap the cache size; 0 means no specific limit (default)
// Intended to be called from main thread synchronously; will return immediately.
// If the result isn't cached, will return `nil`; the caller should then pause playback, not increment frame counter and keep polling.
// After an initial loading time, depending on `frameCacheSize`, frames should be available immediately from the cache.
// Copyright (c) 2013-2015 Flipboard. All rights reserved.
//
#import "FLAnimatedImage.h"
#import <ImageIO/ImageIO.h>
#import <MobileCoreServices/MobileCoreServices.h>
// From vm_param.h, define for iOS 8.0 or higher to build on device.
#ifndef BYTE_SIZE
#define BYTE_SIZE 8 // byte size in bits
#endif
#define MEGABYTE (1024 * 1024)
// This is how the fastest browsers do it as per 2012: http://nullsleep.tumblr.com/post/16524517190/animated-gif-minimum-frame-delay-browser-compatibility
FLAnimatedImageFrameCacheSizeNoLimit=0,// 0 means no specific limit
FLAnimatedImageFrameCacheSizeLowMemory=1,// The minimum frame cache size; this will produce frames on-demand.
FLAnimatedImageFrameCacheSizeGrowAfterMemoryWarning=2,// If we can produce the frames faster than we consume, one frame ahead will already result in a stutter-free playback.
FLAnimatedImageFrameCacheSizeDefault=5// Build up a comfy buffer window to cope with CPU hiccups etc.
@property(nonatomic,assign,readonly)NSUIntegerframeCacheSizeOptimal;// The optimal number of frames to cache based on image size & number of frames; never changes
@property(nonatomic,assign,readonly,getter=isPredrawingEnabled)BOOLpredrawingEnabled;// Enables predrawing of images to improve performance.
@property(nonatomic,assign)NSUIntegerframeCacheSizeMaxInternal;// Allow to cap the cache size e.g. when memory warnings occur; 0 means no specific limit (default)
@property(nonatomic,assign)NSUIntegerrequestedFrameIndex;// Most recently requested frame index
@property(nonatomic,assign,readonly)NSUIntegerposterImageFrameIndex;// Index of non-purgable poster image; never changes
// UIKit notifications are posted on the main thread. didReceiveMemoryWarning: is expecting the main run loop, and we don't lock on allAnimatedImagesWeak
NSAssert([NSThreadisMainThread],@"Received memory warning on non-main thread");
// Get a strong reference to all of the images. If an instance is returned in this array, it is still live and has not entered dealloc.
// Note that FLAnimatedImages can be created on any thread, so the hash table must be locked.
NSArray*images=nil;
@synchronized(allAnimatedImagesWeak){
images=[[allAnimatedImagesWeakallObjects]copy];
}
// Now issue notifications to all of the images while holding a strong reference to them
FLLog(FLLogLevelError,@"Use `-initWithAnimatedGIFData:` and supply the animated GIF data as an argument to initialize an object of type `FLAnimatedImage`.");
FLLog(FLLogLevelError,@"No animated GIF data supplied.");
returnnil;
}
self=[superinit];
if(self){
// 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.
// 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`.
// If we don't get a delay time from the properties, fall back to `kDelayTimeIntervalDefault` or carry over the preceding frame's value.
constNSTimeIntervalkDelayTimeIntervalDefault=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.
FLLog(FLLogLevelInfo,@"Rounding frame %zu's `delayTime` from %f up to default %f (minimum supported: %f).",i,[delayTimefloatValue],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);
// 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.
FLLog(FLLogLevelVerbose,@"Predrew frame %lu in %f ms for animated image: %@",(unsignedlong)i,(predrawDuration+slowdownDuration)*1000,self);
#endif
// The results get returned one by one as soon as they're ready (and not in batch).
// The benefits of having the first frames as quick as possible outweigh building up a buffer to cope with potential hiccups when the CPU suddenly gets busy.
// Bear trap to capture bad images; we have seen crashers cropping up on iOS 7.
FLLog(FLLogLevelError,@"`image` isn't of expected types `UIImage` or `FLAnimatedImage`: %@",image);
}
returnimageSize;
}
#pragma mark - Private Methods
#pragma mark Frame Loading
-(UIImage*)imageAtIndex:(NSUInteger)index
{
// It's very important to use the cached `_imageSource` since the random access to a frame with `CGImageSourceCreateImageAtIndex` turns from an O(1) into an O(n) operation when re-initializing the image source every time.
// Loading in the image object is only half the work, the displaying image view would still have to synchronosly wait and decode the image, so we go ahead and do that here on the background thread.
if(self.isPredrawingEnabled){
image=[[selfclass]predrawnImageFromImage:image];
}
returnimage;
}
#pragma mark Frame Caching
-(NSMutableIndexSet*)frameIndexesToCache
{
NSMutableIndexSet*indexesToCache=nil;
// Quick check to avoid building the index set if the number of frames to cache equals the total frame count.
FLLog(FLLogLevelDebug,@"Grew frame cache size max to %lu after memory warning for animated image: %@",(unsignedlong)self.frameCacheSizeMaxInternal,self);
// Schedule resetting the frame cache size max completely after a while.
// Go down to the minimum and by that implicitly immediately purge from the cache if needed to not get jettisoned by the system and start producing frames on-demand.
FLLog(FLLogLevelDebug,@"Attempt setting frame cache size max to %lu (previous was %lu) after memory warning #%lu for animated image: %@",(unsignedlong)FLAnimatedImageFrameCacheSizeLowMemory,(unsignedlong)self.frameCacheSizeMaxInternal,(unsignedlong)self.memoryWarningCount,self);
// Schedule growing larger again after a while, but cap our attempts to prevent a periodic sawtooth wave (ramps upward and then sharply drops) of memory usage.
//
// [mem]^ (2) (5) (6) 1) Loading frames for the first time
// (*)| , , , 2) Mem warning #1; purge cache
// | /| (4)/| /| 3) Grow cache size a bit after a while, if no mem warning occurs
// | / | _/ | _/ | 4) Try to grow cache size back to optimum after a while, if no mem warning occurs
// Note: It's not possible to get the level of a memory warning with a public API: http://stackoverflow.com/questions/2915247/iphone-os-memory-warnings-what-do-the-different-levels-mean/2915477#2915477
}
#pragma mark Image Decoding
// 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
FLLog(FLLogLevelError,@"Failed to `CGColorSpaceCreateDeviceRGB` for image %@",imageToPredraw);
returnimageToPredraw;
}
// Even when the image doesn't have transparency, we have to add the extra channel because Quartz doesn't support other pixel formats than 32 bpp/8 bpc for RGB:
// "The constants for specifying the alpha channel information are declared with the `CGImageAlphaInfo` type but can be passed to this parameter safely." (source: docs)
bitmapInfo|=alphaInfo;
// Create our own graphics context to draw to; `UIGraphicsGetCurrentContext`/`UIGraphicsBeginImageContextWithOptions` doesn't create a new context but returns the current one which isn't thread-safe (e.g. main thread could use it at the same time).
// Note: It's not worth caching the bitmap context for multiple frames ("unique key" would be `width`, `height` and `hasAlpha`), it's ~50% slower. Time spent in libRIP's `CGSBlendBGRA8888toARGB8888` suddenly shoots up -- not sure why.
FLLog(FLLogLevelError,@"Failed to `CGBitmapContextCreate` with color space %@ and parameters (width: %zu height: %zu bitsPerComponent: %zu bytesPerRow: %zu) for image %@",colorSpaceDeviceRGBRef,width,height,bitsPerComponent,bytesPerRow,imageToPredraw);
returnimageToPredraw;
}
// Draw image in bitmap context and create image by preserving receiver's properties.
FLLog(FLLogLevelError,@"Failed to `imageWithCGImage:scale:orientation:` with image ref %@ created with color space %@ and bitmap context %@ and properties and properties (scale: %f orientation: %ld) for image %@",predrawnImageRef,colorSpaceDeviceRGBRef,bitmapContextRef,imageToPredraw.scale,(long)imageToPredraw.imageOrientation,imageToPredraw);
// We only get here if `forwardingTargetForSelector:` returns nil.
// In that case, our weak target has been reclaimed. Return a dummy method signature to keep `doesNotRecognizeSelector:` from firing.
// We'll emulate the Obj-c messaging nil behavior by setting the return value to nil in `forwardInvocation:`, but we'll assume that the return value is `sizeof(void *)`.
// Other libraries handle this situation by making use of a global method signature cache, but that seems heavier than necessary and has issues as well.
// See https://www.mikeash.com/pyblog/friday-qa-2010-02-26-futures.html and https://github.com/steipete/PSTDelegateProxy/issues/1 for examples of using a method signature cache.
// Copyright (c) 2013-2015 Flipboard. All rights reserved.
//
#import <UIKit/UIKit.h>
@classFLAnimatedImage;
@protocolFLAnimatedImageViewDebugDelegate;
//
// An `FLAnimatedImageView` can take an `FLAnimatedImage` and plays it automatically when in view hierarchy and stops when removed.
// The animation can also be controlled with the `UIImageView` methods `-start/stop/isAnimating`.
// It is a fully compatible `UIImageView` subclass and can be used as a drop-in component to work with existing code paths expecting to display a `UIImage`.
// Under the hood it uses a `CADisplayLink` for playback, which can be inspected with `currentFrame` & `currentFrameIndex`.
//
@interfaceFLAnimatedImageView:UIImageView
// Setting `[UIImageView.image]` to a non-`nil` value clears out existing `animatedImage`.
// And vice versa, setting `animatedImage` will initially populate the `[UIImageView.image]` to its `posterImage` and then start animating and hold `currentFrame`.
// The animation runloop mode. Enables playback during scrolling by allowing timer events (i.e. animation) with NSRunLoopCommonModes.
// To keep scrolling smooth on single-core devices such as iPhone 3GS/4 and iPod Touch 4th gen, the default run loop mode is NSDefaultRunLoopMode. Otherwise, the default is NSDefaultRunLoopMode.
@property(nonatomic,assign)BOOLshouldAnimate;// Before checking this value, call `-updateShouldAnimate` whenever the animated image or visibility (window, superview, hidden, alpha) has changed.
// -initWithImage: isn't documented as a designated initializer of UIImageView, but it actually seems to be.
// Using -initWithImage: doesn't call any of the other designated initializers.
-(instancetype)initWithImage:(UIImage*)image
{
self=[superinitWithImage:image];
if(self){
[selfcommonInit];
}
returnself;
}
// -initWithImage:highlightedImage: also isn't documented as a designated initializer of UIImageView, but it doesn't call any other designated initializers.
// 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.
[selfinvalidateIntrinsicContentSize];
}else{
// Stop animating before the animated image gets cleared out.
[selfstopAnimating];
}
_animatedImage=animatedImage;
self.currentFrame=animatedImage.posterImage;
self.currentFrameIndex=0;
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.
[selfupdateShouldAnimate];
if(self.shouldAnimate){
[selfstartAnimating];
}
[self.layersetNeedsDisplay];
}
}
#pragma mark - Life Cycle
-(void)dealloc
{
// Removes the display link from all run loop modes.
[_displayLinkinvalidate];
}
#pragma mark - UIView Method Overrides
#pragma mark Observing View-Related Changes
-(void)didMoveToSuperview
{
[superdidMoveToSuperview];
[selfupdateShouldAnimate];
if(self.shouldAnimate){
[selfstartAnimating];
}else{
[selfstopAnimating];
}
}
-(void)didMoveToWindow
{
[superdidMoveToWindow];
[selfupdateShouldAnimate];
if(self.shouldAnimate){
[selfstartAnimating];
}else{
[selfstopAnimating];
}
}
-(void)setAlpha:(CGFloat)alpha
{
[supersetAlpha:alpha];
[selfupdateShouldAnimate];
if(self.shouldAnimate){
[selfstartAnimating];
}else{
[selfstopAnimating];
}
}
-(void)setHidden:(BOOL)hidden
{
[supersetHidden:hidden];
[selfupdateShouldAnimate];
if(self.shouldAnimate){
[selfstartAnimating];
}else{
[selfstopAnimating];
}
}
#pragma mark Auto Layout
-(CGSize)intrinsicContentSize
{
// Default to let UIImageView handle the sizing of its image, and anything else it might consider.
// If we have have an animated image, use its image size.
// UIImageView's intrinsic content size seems to be the size of its image. The obvious approach, simply calling `-invalidateIntrinsicContentSize` when setting an animated image, results in UIImageView steadfastly returning `{UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric}` for its intrinsicContentSize.
// (Perhaps UIImageView bypasses its `-image` getter in its implementation of `-intrinsicContentSize`, as `-image` is not called after calling `-invalidateIntrinsicContentSize`.)
if(self.animatedImage){
intrinsicContentSize=self.image.size;
}
returnintrinsicContentSize;
}
#pragma mark Smart Invert Colors
#pragma mark - UIImageView Method Overrides
#pragma mark Image Data
-(UIImage*)image
{
UIImage*image=nil;
if(self.animatedImage){
// Initially set to the poster image.
image=self.currentFrame;
}else{
image=super.image;
}
returnimage;
}
-(void)setImage:(UIImage*)image
{
if(image){
// Clear out the animated image and implicitly pause animation playback.
self.animatedImage=nil;
}
super.image=image;
}
#pragma mark Animating Images
-(NSTimeInterval)frameDelayGreatestCommonDivisor
{
// Presision is set to half of the `kFLAnimatedImageDelayTimeIntervalMinimum` in order to minimize frame dropping.