TwitterImagePipeline/TIPImagePipeline.m (511 lines of code) (raw):
//
// TIPImagePipeline.m
// TwitterImagePipeline
//
// Created on 2/5/15.
// Copyright (c) 2015 Twitter, Inc. All rights reserved.
//
#import "TIP_Project.h"
#import "TIPError.h"
#import "TIPFileUtils.h"
#import "TIPGlobalConfiguration+Project.h"
#import "TIPImageDiskCache.h"
#import "TIPImageDownloader.h"
#import "TIPImageFetchDelegate.h"
#import "TIPImageFetchOperation+Project.h"
#import "TIPImageFetchRequest.h"
#import "TIPImageMemoryCache.h"
#import "TIPImagePipeline+Project.h"
#import "TIPImagePipelineInspectionResult+Project.h"
#import "TIPImageRenderedCache.h"
#import "TIPImageStoreAndMoveOperations.h"
NS_ASSUME_NONNULL_BEGIN
NSString * const TIPImagePipelineDidStoreCachedImageNotification = @"TIPImagePipelineDidStoreCachedImageNotification";
NSString * const TIPImagePipelineDidStandUpImagePipelineNotification = @"TIPImagePipelineDidStandUpImagePipelineNotification";
NSString * const TIPImagePipelineDidTearDownImagePipelineNotification = @"TIPImagePipelineDidTearDownImagePipelineNotification";
NSString * const TIPImagePipelineImageIdentifierNotificationKey = @"imageIdentifier";
NSString * const TIPImagePipelineImageURLNotificationKey = @"imageURL";
NSString * const TIPImagePipelineImageDimensionsNotificationKey = @"imageDimensions";
NSString * const TIPImagePipelineImageContainerNotificationKey = @"imageContainer";
NSString * const TIPImagePipelineImageWasManuallyStoredNotificationKey = @"wasManuallyStored";
NSString * const TIPImagePipelineImagePipelineIdentifierNotificationKey = @"imagePipelineId";
NSString * const TIPImagePipelineImageTreatAsPlaceholderNofiticationKey = @"treatAsPlaceholder";
static NSString * const kImagePipelineFolderName = @"TIPImagePipeline";
#define TIPRegisterAssertMessage(expression, format, ...) \
do { \
if (TIPShouldAssertDuringPipelineRegistation()) { \
TIPAssertMessage(expression, format, ##__VA_ARGS__); \
} else { \
TIPLogError(@"assertion failed: (" #expression ") message: %@", [NSString stringWithFormat:format, ##__VA_ARGS__]); \
} \
} while (0)
@interface TIPSimpleImageFetchDelegate ()
@property (nonatomic, readonly, copy, nullable) TIPImagePipelineFetchCompletionBlock completion;
- (instancetype)initWithCompletion:(nullable TIPImagePipelineFetchCompletionBlock)completion;
@end
static NSMapTable *sStrongIdentifierToWeakImagePipelineMap;
static dispatch_queue_t sRegistrationQueue;
static dispatch_once_t sOnceToken = 0;
static void TIPEnsureStaticImagePipelineVariables(void);
static void TIPEnsureStaticImagePipelineVariables(void)
{
dispatch_once(&sOnceToken, ^{
sStrongIdentifierToWeakImagePipelineMap = [NSMapTable strongToWeakObjectsMapTable];
sRegistrationQueue = dispatch_queue_create("TIPImagePipeline.registration.queue", DISPATCH_QUEUE_SERIAL);
});
}
static BOOL TIPImagePipelineIdentifierIsValid(NSString * identifier);
static BOOL TIPRegisterImagePipelineWithIdentifier(TIPImagePipeline *pipeline, NSString *identifier);
static void TIPUnregisterImagePipelineWithIdentifier(NSString *identifier);
static NSString * TIPImagePipelinePath(void) __attribute__((const));
static NSString * __nullable TIPOpenImagePipelineWithIdentifier(NSString *identifier);
static NSDictionary *TIPCopyAllRegisteredImagePipelines(void);
static void TIPEnqueueOperation(TIPImageFetchOperation *operation);
static void TIPFireFetchCompletionBlock(TIPImagePipelineFetchCompletionBlock __nullable completion,
id<TIPImageFetchResult> __nullable finalResult,
NSError * __nullable error);
@implementation TIPImagePipeline
{
NSString *_imagePipelinePath;
}
// the following getters may appear superfluous, and would be, if it weren't for the need to
// annotate them with __attribute__((no_sanitize("thread")). the getters make the @synthesize
// lines necessary.
//
// the reason these are thread-safe is that the ivars are assigned at init time and never
// mutated afterwards making their access thread safe via nonatomic
@synthesize renderedCache = _renderedCache;
@synthesize memoryCache = _memoryCache;
@synthesize diskCache = _diskCache;
- (nullable TIPImageRenderedCache *)renderedCache TIP_THREAD_SANITIZER_DISABLED
{
return _renderedCache;
}
- (nullable TIPImageMemoryCache *)memoryCache TIP_THREAD_SANITIZER_DISABLED
{
return _memoryCache;
}
- (nullable TIPImageDiskCache *)diskCache TIP_THREAD_SANITIZER_DISABLED
{
return _diskCache;
}
#pragma mark Class Methods
+ (NSDictionary<NSString *, TIPImagePipeline *> *)allRegisteredImagePipelines
{
return TIPCopyAllRegisteredImagePipelines();
}
+ (void)getKnownImagePipelineIdentifiers:(void (^)(NSSet<NSString *> *identifiers))callback
{
tip_dispatch_async_autoreleasing([TIPGlobalConfiguration sharedInstance].queueForDiskCaches, ^{
NSMutableSet *identifiers = [[NSMutableSet alloc] init];
NSString *pipelineDir = TIPImagePipelinePath();
NSArray<NSURL *> *files = TIPContentsAtPath(pipelineDir, NULL);
for (NSURL *subdir in files) {
if ([[subdir resourceValuesForKeys:@[NSURLIsDirectoryKey] error:NULL][NSURLIsDirectoryKey] boolValue]) {
[identifiers addObject:[subdir lastPathComponent]];
}
}
tip_dispatch_async_autoreleasing(dispatch_get_main_queue(), ^{
callback(identifiers);
});
});
}
#pragma mark init/dealloc
- (instancetype)init
{
[self doesNotRecognizeSelector:_cmd];
abort(); // will never be reached, but prevents compiler warning
}
- (void)dealloc
{
if (_identifier) {
TIPUnregisterImagePipelineWithIdentifier(_identifier);
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc removeObserver:self name:UIApplicationDidEnterBackgroundNotification object:nil];
[nc postNotificationName:TIPImagePipelineDidTearDownImagePipelineNotification object:nil userInfo:@{ TIPImagePipelineImagePipelineIdentifierNotificationKey : _identifier }];
}
}
- (nullable instancetype)initWithIdentifier:(NSString *)identifier
{
identifier = [identifier copy];
if (self = [super init]) {
if (!TIPRegisterImagePipelineWithIdentifier(self, identifier)) {
return nil;
}
_identifier = identifier;
_imagePipelinePath = [TIPOpenImagePipelineWithIdentifier(identifier) copy];
_diskCache = [[TIPImageDiskCache alloc] initWithPath:_imagePipelinePath];
_memoryCache = [[TIPImageMemoryCache alloc] init];
_renderedCache = [[TIPImageRenderedCache alloc] init];
_downloader = [TIPImageDownloader sharedInstance];
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self selector:@selector(_tip_applicationDidEnterBackground) name:UIApplicationDidEnterBackgroundNotification object:nil];
[nc postNotificationName:TIPImagePipelineDidStandUpImagePipelineNotification object:self userInfo:@{ TIPImagePipelineImagePipelineIdentifierNotificationKey : _identifier }];
}
return self;
}
#pragma mark Generate Operation
- (TIPImageFetchOperation *)operationWithRequest:(id<TIPImageFetchRequest>)request
context:(nullable id)context
delegate:(nullable id<TIPImageFetchDelegate>)delegate
{
TIPImageFetchOperation *operation = [[TIPImageFetchOperation alloc] initWithImagePipeline:self
request:request
delegate:delegate];
operation.context = context;
return operation;
}
- (TIPImageFetchOperation *)operationWithRequest:(id<TIPImageFetchRequest>)request
context:(nullable id)context
completion:(nullable TIPImagePipelineFetchCompletionBlock)completion
{
TIPSimpleImageFetchDelegate *delegate = [[TIPSimpleImageFetchDelegate alloc] initWithCompletion:completion];
return [self operationWithRequest:request
context:context
delegate:delegate];
}
#pragma mark Fetch
- (void)fetchImageWithOperation:(TIPImageFetchOperation *)op
{
if (!op) {
@throw [NSException exceptionWithName:NSInvalidArgumentException
reason:@"Provided TIPImageFetchOperation is nil"
userInfo:nil];
}
if (op.imagePipeline != self) {
@throw [NSException exceptionWithName:NSInvalidArgumentException
reason:@"Provided TIPImageFetchOperation does not belong to the target TIPImagePipeline."
userInfo:@{ @"operation" : op }];
}
TIPImageCacheEntry *entry = nil;
id<TIPImageFetchRequest> request = op.request;
// Validate the target dimensions
const CGSize targetDimensions = [request respondsToSelector:@selector(targetDimensions)] ? request.targetDimensions : CGSizeZero;
const UIViewContentMode targetContentMode = [request respondsToSelector:@selector(targetContentMode)] ? request.targetContentMode : UIViewContentModeCenter;
if (!TIPSizeGreaterThanZero(targetDimensions) && !TIPSizeEqualToZero(targetDimensions)) {
NSDictionary *userInfo = @{
TIPProblemInfoKeyTargetDimensions : [NSValue valueWithCGSize:targetDimensions],
TIPProblemInfoKeyTargetContentMode : @(targetContentMode),
TIPProblemInfoKeyImageIdentifier : op.imageIdentifier ?: @"",
TIPProblemInfoKeyImageURL : op.imageURL ?: @"",
TIPProblemInfoKeyFetchRequest : request,
};
[[TIPGlobalConfiguration sharedInstance] postProblem:TIPProblemImageFetchHasInvalidTargetDimensions userInfo:userInfo];
}
// Perform synchronous access?
NSString *imageId = op.imageIdentifier;
NSString *transformerId = op.transformerIdentifier;
CGSize sourceImageDimensions = CGSizeZero;
BOOL isDirty = NO;
if ([NSThread isMainThread] && [op supportsLoadingFromRenderedCache] && (imageId != nil)) {
// Sync Access, for optimization
entry = [_renderedCache imageEntryWithIdentifier:imageId
transformerIdentifier:transformerId
targetDimensions:targetDimensions
targetContentMode:targetContentMode
sourceImageDimensions:&sourceImageDimensions
dirty:&isDirty];
}
if (entry.completeImage) {
if (!isDirty) {
// Sync Completion
[op completeOperationEarlyWithImageEntry:entry
transformed:(transformerId != nil)
sourceImageDimensions:sourceImageDimensions];
return;
}
// Sync early preview
[op handleEarlyLoadOfDirtyImageEntry:entry
transformed:(transformerId != nil)
sourceImageDimensions:sourceImageDimensions];
}
// Async Operation
TIPEnqueueOperation(op);
}
#pragma mark Store / Move
- (NSObject<TIPDependencyOperation> *)changeIdentifierForImageWithIdentifier:(NSString *)currentIdentifier
toIdentifier:(NSString *)newIdentifier
completion:(nullable TIPImagePipelineOperationCompletionBlock)completion
{
TIPImageMoveOperation *moveOp = [[TIPImageMoveOperation alloc] initWithPipeline:self
originalIdentifier:currentIdentifier
updatedIdentifier:newIdentifier
completion:completion];
[[TIPGlobalConfiguration sharedInstance] enqueueImagePipelineOperation:moveOp];
return moveOp;
}
- (NSObject<TIPDependencyOperation> *)storeImageWithRequest:(id<TIPImageStoreRequest>)request
completion:(nullable TIPImagePipelineOperationCompletionBlock)completion
{
TIPImageStoreOperation *storeOp = [self storeOperationWithRequest:request
completion:completion];
TIPImageStoreHydrationOperation *prepOp = [self _createHydrationOperationWithRequest:request];
if (prepOp) {
[storeOp setHydrationDependency:prepOp];
[[TIPGlobalConfiguration sharedInstance] enqueueImagePipelineOperation:prepOp];
}
[[TIPGlobalConfiguration sharedInstance] enqueueImagePipelineOperation:storeOp];
return storeOp;
}
- (TIPImageStoreOperation *)storeOperationWithRequest:(id<TIPImageStoreRequest>)request
completion:(nullable TIPImagePipelineOperationCompletionBlock)completion
{
return [[TIPImageStoreOperation alloc] initWithRequest:request
pipeline:self
completion:completion];
}
- (nullable TIPImageStoreHydrationOperation *)_createHydrationOperationWithRequest:(id<TIPImageStoreRequest>)request TIP_OBJC_DIRECT
{
id<TIPImageStoreRequestHydrater> hydrater = [request respondsToSelector:@selector(hydrater)] ? request.hydrater : nil;
if (!hydrater) {
return nil;
}
return [[TIPImageStoreHydrationOperation alloc] initWithRequest:request
pipeline:self
hydrater:hydrater];
}
#pragma mark Clear
- (void)clearImageWithIdentifier:(NSString *)imageIdentifier
{
TIPAssert(imageIdentifier != nil);
[_renderedCache clearImageWithIdentifier:imageIdentifier];
[_memoryCache clearImageWithIdentifier:imageIdentifier];
[_diskCache clearImageWithIdentifier:imageIdentifier];
}
- (void)clearRenderedMemoryCacheImageWithIdentifier:(NSString *)imageIdentifier
{
TIPAssert(imageIdentifier != nil);
[_renderedCache clearImageWithIdentifier:imageIdentifier];
}
- (void)dirtyRenderedMemoryCacheImageWithIdentifier:(NSString *)imageIdentifier
{
TIPAssert(imageIdentifier != nil);
[_renderedCache dirtyImageWithIdentifier:imageIdentifier];
}
- (void)clearMemoryCaches
{
[_renderedCache clearAllImages:NULL];
[_memoryCache clearAllImages:NULL];
}
- (void)clearDiskCache
{
[_diskCache clearAllImages:NULL];
}
#pragma mark Copy Disk Cache File
- (void)copyDiskCacheFileWithIdentifier:(NSString *)imageIdentifier
completion:(nullable TIPImagePipelineCopyFileCompletionBlock)completion
{
TIPAssert(imageIdentifier != nil);
NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
[self _background_copyDiskCacheFileWithIdentifier:imageIdentifier
completion:completion];
}];
[[TIPGlobalConfiguration sharedInstance] enqueueImagePipelineOperation:op];
}
- (void)_background_copyDiskCacheFileWithIdentifier:(NSString *)imageIdentifier
completion:(nullable TIPImagePipelineCopyFileCompletionBlock)completion TIP_OBJC_DIRECT
{
// Copy to temp location
NSString *temporaryFile = nil;
NSError *error = nil;
temporaryFile = [self.diskCache copyImageEntryFileForIdentifier:imageIdentifier
error:&error];
// Indicate completion
if (completion) {
completion(temporaryFile, error);
}
// Clean up the temporary file
if (temporaryFile) {
[[NSFileManager defaultManager] removeItemAtPath:temporaryFile error:NULL];
}
}
#pragma mark Post Completed
- (void)postCompletedEntry:(TIPImageCacheEntry *)entry manual:(BOOL)manual
{
if (!entry.identifier || !entry.completeImageContext.URL) {
return;
}
tip_dispatch_async_autoreleasing(dispatch_get_main_queue(), ^{
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithCapacity:6];
if (entry.completeImage) {
userInfo[TIPImagePipelineImageContainerNotificationKey] = entry.completeImage;
}
userInfo[TIPImagePipelineImageIdentifierNotificationKey] = entry.identifier;
userInfo[TIPImagePipelineImageURLNotificationKey] = entry.completeImageContext.URL;
userInfo[TIPImagePipelineImageDimensionsNotificationKey] = [NSValue valueWithCGSize:entry.completeImageContext.dimensions];
userInfo[TIPImagePipelineImageWasManuallyStoredNotificationKey] = @(manual);
userInfo[TIPImagePipelineImagePipelineIdentifierNotificationKey] = self.identifier;
userInfo[TIPImagePipelineImageTreatAsPlaceholderNofiticationKey] = @(entry.completeImageContext.treatAsPlaceholder);
[[NSNotificationCenter defaultCenter] postNotificationName:TIPImagePipelineDidStoreCachedImageNotification
object:self
userInfo:userInfo];
});
}
#pragma mark Properties
- (nullable id<TIPImageCache>)cacheOfType:(TIPImageCacheType)type
{
switch (type) {
case TIPImageCacheTypeDisk:
return self.diskCache;
case TIPImageCacheTypeMemory:
return self.memoryCache;
case TIPImageCacheTypeRendered:
return self.renderedCache;
}
return nil;
}
#pragma mark Private
- (void)_tip_applicationDidEnterBackground
{
if ([TIPGlobalConfiguration sharedInstance].clearMemoryCachesOnApplicationBackgroundEnabled) {
dispatch_block_t endBackgroundTaskBlock = TIPStartBackgroundTask([NSString stringWithFormat:@"[%@ %@]", NSStringFromClass([self class]), NSStringFromSelector(_cmd)]);
[_renderedCache weakifyEntries];
[_memoryCache clearAllImages:endBackgroundTaskBlock];
}
}
@end
@implementation TIPImagePipeline (Inspect)
- (void)inspect:(TIPImagePipelineInspectionCallback)callback
{
TIPImagePipelineInspectionResult *result = [[TIPImagePipelineInspectionResult alloc] initWithImagePipeline:self];
[self.renderedCache inspect:^(NSArray *completedEntries, NSArray *partialEntries) {
[result addEntries:completedEntries];
[result addEntries:partialEntries];
[self.memoryCache inspect:^(NSArray *memoryCachedCompletedEntries, NSArray *memoryCachePartialEntries) {
[result addEntries:memoryCachedCompletedEntries];
[result addEntries:memoryCachePartialEntries];
[self.diskCache inspect:^(NSArray *diskCacheCompletedEntries, NSArray *diskCachePartialEntries) {
[result addEntries:diskCacheCompletedEntries];
[result addEntries:diskCachePartialEntries];
tip_dispatch_async_autoreleasing(dispatch_get_main_queue(), ^{
callback(result);
});
}];
}];
}];
}
@end
@implementation TIPSimpleImageFetchDelegate
- (instancetype)initWithCompletion:(nullable TIPImagePipelineFetchCompletionBlock)completion
{
if (self = [super init]) {
_completion = [completion copy];
}
return self;
}
- (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op
didLoadFinalImage:(id<TIPImageFetchResult>)finalResult
{
TIPFireFetchCompletionBlock(self.completion, finalResult, nil);
}
- (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op
didFailToLoadFinalImage:(NSError *)error
{
TIPFireFetchCompletionBlock(self.completion, nil, error);
}
@end
static BOOL TIPImagePipelineIdentifierIsValid(NSString *identifier)
{
static NSCharacterSet *sCharSet = nil;
if (!sCharSet) {
NSRange r;
r = NSMakeRange('0', '9'-'0' + 1);
NSMutableCharacterSet *charSet = [NSMutableCharacterSet characterSetWithRange:r];
r = NSMakeRange('a', 'z'-'a' + 1);
[charSet addCharactersInRange:r];
r = NSMakeRange('A', 'Z'-'A' + 1);
[charSet addCharactersInRange:r];
[charSet addCharactersInString:@"._-"];
[charSet invert];
sCharSet = [charSet copy];
}
if (identifier.length == 0) {
return NO;
}
NSRange range = [identifier rangeOfCharacterFromSet:sCharSet];
if (range.location != NSNotFound) {
return NO;
}
return YES;
}
static BOOL TIPRegisterImagePipelineWithIdentifier(TIPImagePipeline *pipeline, NSString *identifier)
{
if (!identifier) {
TIPRegisterAssertMessage(identifier != nil, @"%@ cannot have a nil identifier!", NSStringFromClass([TIPImagePipeline class]));
return NO;
}
if (!pipeline) {
TIPRegisterAssertMessage(pipeline != nil, @"Cannot register nil pipeline!");
return NO;
}
TIPEnsureStaticImagePipelineVariables();
__block struct {
BOOL didRegister:1;
BOOL alreadyRegistered:1;
BOOL isInvalidIdentifier:1;
} flags;
flags.didRegister = flags.alreadyRegistered = flags.isInvalidIdentifier = 0;
dispatch_sync(sRegistrationQueue, ^{
if (TIPImagePipelineIdentifierIsValid(identifier)) {
if (![sStrongIdentifierToWeakImagePipelineMap objectForKey:identifier]) {
[sStrongIdentifierToWeakImagePipelineMap setObject:pipeline forKey:identifier];
flags.didRegister = 1;
} else {
flags.alreadyRegistered = 1;
}
} else {
flags.isInvalidIdentifier = 1;
}
});
TIPRegisterAssertMessage(!flags.alreadyRegistered, @"%@ already exists with identifier '%@'!", NSStringFromClass([TIPImagePipeline class]), identifier);
TIPRegisterAssertMessage(!flags.isInvalidIdentifier, @"%@ cannot be created with identifier '%@'!", NSStringFromClass([TIPImagePipeline class]), identifier);
if (flags.didRegister) {
TIPLogDebug(@"<%@ '%@'> registered!", NSStringFromClass([pipeline class]), identifier);
}
return flags.didRegister;
}
static void TIPUnregisterImagePipelineWithIdentifier(NSString *identifier)
{
if (!identifier) {
TIPRegisterAssertMessage(identifier != nil, @"Cannot dealloc %@ with nil identifier!", NSStringFromClass([TIPImagePipeline class]));
return;
}
TIPEnsureStaticImagePipelineVariables();
tip_dispatch_async_autoreleasing(sRegistrationQueue, ^{
TIPRegisterAssertMessage([sStrongIdentifierToWeakImagePipelineMap objectForKey:identifier] == nil, @"%@'s identifier (%@) still in use, should not unregister!", NSStringFromClass([TIPImagePipeline class]), identifier);
TIPLogDebug(@"<%@ '%@'> unregistered!", NSStringFromClass([TIPImagePipeline class]), identifier);
[sStrongIdentifierToWeakImagePipelineMap removeObjectForKey:identifier];
});
}
static NSString *TIPImagePipelinePath(void)
{
static NSString *sPath = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
#if !TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
// platform may be non-sandboxed, or "sandbox" may contain sym-links outside of expected sandbox
// ensure unique path using bundle-id for safety (if possible)
NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
if (bundleId) {
sPath = [sPath stringByResolvingSymlinksInPath];
if (![sPath containsString:[NSString stringWithFormat:@"/%@/", bundleId]]) {
sPath = [sPath stringByAppendingPathComponent:bundleId];
}
}
#endif
sPath = [sPath stringByAppendingPathComponent:kImagePipelineFolderName];
});
return sPath;
}
static NSString * __nullable TIPOpenImagePipelineWithIdentifier(NSString *identifier)
{
NSString *path = TIPImagePipelinePath();
path = [path stringByAppendingPathComponent:identifier];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error;
if (![fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error]) {
TIPLogError(@"Failed to open images store at path: %@\nContinuing without an on disk cache", path);
path = nil;
}
return path;
}
static NSDictionary *TIPCopyAllRegisteredImagePipelines()
{
TIPEnsureStaticImagePipelineVariables();
__block NSDictionary *pipelines;
tip_dispatch_sync_autoreleasing(sRegistrationQueue, ^{
pipelines = sStrongIdentifierToWeakImagePipelineMap.dictionaryRepresentation;
});
return pipelines;
}
static void TIPEnqueueOperation(TIPImageFetchOperation *operation)
{
[operation willEnqueue];
[[TIPGlobalConfiguration sharedInstance] enqueueImagePipelineOperation:operation];
}
static void TIPFireFetchCompletionBlock(TIPImagePipelineFetchCompletionBlock __nullable completion,
id<TIPImageFetchResult> __nullable finalResult,
NSError * __nullable error)
{
if (completion) {
completion(finalResult, error);
}
}
NS_ASSUME_NONNULL_END