TwitterImagePipeline/TIPImageViewFetchHelper.m (1,232 lines of code) (raw):

// // TIPImageViewFetchHelper.m // TwitterImagePipeline // // Created on 4/18/16. // Copyright © 2020 Twitter. All rights reserved. // #import <UIKit/UIKit.h> #import "TIP_Project.h" #import "TIPError.h" #import "TIPGlobalConfiguration.h" #import "TIPImageFetchable.h" #import "TIPImageFetchMetrics.h" #import "TIPImageFetchOperation+Project.h" #import "TIPImageFetchRequest.h" #import "TIPImagePipeline.h" #import "TIPImageViewFetchHelper.h" #import "UIImage+TIPAdditions.h" NS_ASSUME_NONNULL_BEGIN NSString * const TIPImageViewDidUpdateDebugInfoVisibilityNotification = @"TIPImageViewDidUpdateDebugInfoVisibility"; NSString * const TIPImageViewDidUpdateDebugInfoVisibilityNotificationKeyVisible = @"visible"; static NSString * const kRetryFailedLoadsNotification = @"tip.retry.fetchHelpers"; #define kDEBUG_HIGHLIGHT_COLOR_DEFAULT [UIColor colorWithWhite:(CGFloat)0.3 alpha:(CGFloat)0.55] #define kDEBUG_TEXT_COLOR_DEFAULT [UIColor whiteColor] static BOOL sDebugInfoVisible = NO; NS_INLINE BOOL TIPIsViewVisible(UIView * __nullable view) { return view != nil && view.window != nil && !view.isHidden; } @interface TIPImageViewSimpleFetchRequest : NSObject <TIPImageFetchRequest> @property (nonatomic, readonly) NSURL *imageURL; @property (nonatomic, readonly) CGSize targetDimensions; @property (nonatomic, readonly) UIViewContentMode targetContentMode; - (instancetype)initWithImageURL:(NSURL *)imageURL targetView:(nullable UIView *)view TIP_OBJC_DIRECT; @end @interface TIPImageViewFetchHelper () @property (nonatomic) float fetchProgress; @property (nonatomic, nullable) NSError *fetchError; @property (nonatomic, nullable) TIPImageFetchMetrics *fetchMetrics; @property (nonatomic) CGSize fetchResultDimensions; @property (nonatomic) TIPImageLoadSource fetchSource; @property (nonatomic, nullable) id<TIPImageFetchRequest> fetchRequest; @property (tip_atomic_direct, weak, nullable) id<TIPImageViewFetchHelperDelegate> atomicDelegate; - (void)_setDelegateInternal:(nullable id<TIPImageViewFetchHelperDelegate>)delegate TIP_OBJC_DIRECT; - (void)_markAsIfLoaded TIP_OBJC_DIRECT; - (void)_markAsIfPlaceholder TIP_OBJC_DIRECT; @end TIP_OBJC_DIRECT_MEMBERS @interface TIPImageViewFetchHelper (Events) - (BOOL)_shouldUpdateImageWithResult:(id<TIPImageFetchResult>)previewImageResult; - (BOOL)_shouldContinueLoadingWithResult:(id<TIPImageFetchResult>)previewImageResult; - (BOOL)_shouldLoadProgressivelyWithIdentifier:(NSString *)identifier imageURL:(NSURL *)URL imageType:(NSString *)imageType originalDimensions:(CGSize)originalDimensions; - (BOOL)_shouldReloadAfterDifferentFetchCompletedWithImageContainer:(TIPImageContainer *)image dimensions:(CGSize)dimensions identifier:(NSString *)identifier imageURL:(NSURL *)URL treatedAsPlaceholder:(BOOL)treatedAsPlaceholder manuallyStored:(BOOL)manuallyStored; - (void)_didStartLoading; - (void)_didUpdateProgress:(float)progress; - (void)_didUpdateDisplayedImageContainer:(TIPImageContainer *)imageContainer sourceDimensions:(CGSize)sourceDimensions isFinal:(BOOL)isFinal; - (void)_didLoadFinalImageFromSource:(TIPImageLoadSource)source; - (void)_didFailToLoadFinalImageWithError:(NSError *)error; - (void)_didReset; @end @interface TIPImageViewFetchHelper (TIPImageFetchDelegate) <TIPImageFetchDelegate> @end TIP_OBJC_DIRECT_MEMBERS @interface TIPImageViewFetchHelper (Private) - (void)_tearDown; - (void)_prep; - (void)_handleViewResizeEvent; - (BOOL)_resizeRequestIfNeeded; - (void)_updateImageContainer:(nullable TIPImageContainer *)imageContainer sourceDimensions:(CGSize)sourceDimensions URL:(nullable NSURL *)URL source:(TIPImageLoadSource)source type:(nullable NSString *)type progress:(float)progress error:(nullable NSError *)error metrics:(nullable TIPImageFetchMetrics *)metrics final:(BOOL)final scaled:(BOOL)scaled progressive:(BOOL)progressive preview:(BOOL)preview placeholder:(BOOL)placeholder; - (void)_resetToImageContainer:(nullable TIPImageContainer *)imageContainer; - (void)_cancelFetch; - (void)_refetchWithPeek:(nullable id<TIPImageFetchRequest>)peekedRequest; - (nullable id<TIPImageFetchRequest>)_extractRequestFromDataSource:(nullable id<TIPImageViewFetchHelperDataSource>)dataSource; - (id<TIPImageFetchRequest>)_createRequestWithURL:(NSURL *)imageURL; - (void)_startObservingImagePipeline:(nullable TIPImagePipeline *)pipeline; - (void)_showDebugInfo; - (void)_hideDebugInfo; - (NSString *)_buildDebugInfoString:(nonnull out NSInteger *)lineCount; @end @implementation TIPImageViewFetchHelper { TIPImageFetchOperation *_fetchOperation; NSOperationQueuePriority _priorPriority; UILabel *_debugInfoView; struct { BOOL transitioningAppearance:1; BOOL didCancelOnDisapper:1; BOOL didChangePriorityOnDisappear:1; BOOL isLoadedImageFinal:1; BOOL isLoadedImageProgressive:1; BOOL isLoadedImagePreview:1; BOOL isLoadedImageScaled:1; BOOL treatAsPlaceholder:1; BOOL didTreatBackgroundingAsDisappearance:1; } _flags; NSString *_loadedImageType; UIColor *_debugImageHighlightColor; UIColor *_debugInfoTextColor; id _Nullable _opaqueNotificationObserver; NSString * _Nullable _observedPipelineIdentifier; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-implementations" - (void)setFetchDisappearanceBehavior:(TIPImageViewDisappearanceBehavior)fetchDisappearanceBehavior #pragma clang diagnostic pop { self.disappearanceBehavior = fetchDisappearanceBehavior; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-implementations" - (TIPImageViewDisappearanceBehavior)fetchDisappearanceBehavior #pragma clang diagnostic pop { return self.disappearanceBehavior; } - (instancetype)init { return [self initWithDelegate:nil dataSource:nil]; } - (instancetype)initWithDelegate:(nullable id<TIPImageViewFetchHelperDelegate>)delegate dataSource:(nullable id<TIPImageViewFetchHelperDataSource>)dataSource { if (self = [super init]) { [self _setDelegateInternal:delegate]; _dataSource = dataSource; [self _prep]; } return self; } - (void)dealloc { if (_opaqueNotificationObserver != nil) { [[NSNotificationCenter defaultCenter] removeObserver:_opaqueNotificationObserver]; } [self _tearDown]; } - (BOOL)fetchedImageTreatedAsPlaceholder { return _flags.treatAsPlaceholder; } - (BOOL)fetchedImageIsPreview { return _flags.isLoadedImagePreview; } - (BOOL)fetchedImageIsProgressiveFrame { return _flags.isLoadedImageProgressive; } - (BOOL)fetchedImageIsScaledPreviewAsFinal { return _flags.isLoadedImageScaled; } - (BOOL)fetchedImageIsFullLoad { return _flags.isLoadedImageFinal; } - (BOOL)didLoadAny { return _flags.isLoadedImageFinal || _flags.isLoadedImageScaled || _flags.isLoadedImageProgressive || _flags.isLoadedImagePreview; } - (void)setDelegate:(nullable id<TIPImageViewFetchHelperDelegate>)delegate { [self _setDelegateInternal:delegate]; } - (void)_setDelegateInternal:(nullable id<TIPImageViewFetchHelperDelegate>)delegate { _delegate = delegate; // Certain callbacks are made via non-main thread and require atomic property backing if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldLoadProgressivelyWithIdentifier:URL:imageType:originalDimensions:)]) { self.atomicDelegate = delegate; } else { self.atomicDelegate = nil; } } - (void)setFetchView:(nullable UIView<TIPImageFetchable> *)fetchView { TIPAssert(!fetchView || [fetchView respondsToSelector:@selector(setTip_fetchedImage:)] || [fetchView respondsToSelector:@selector(setTip_fetchedImageContainer:)]); UIView<TIPImageFetchable> *oldView = _fetchView; if (oldView != fetchView) { const BOOL triggerDisappear = TIPIsViewVisible(oldView); const BOOL triggerAppear = TIPIsViewVisible(fetchView); if (triggerDisappear) { if (_debugInfoView) { [_debugInfoView removeFromSuperview]; } [self triggerViewWillDisappear]; [self triggerViewDidDisappear]; } _fetchView = fetchView; if (_debugInfoView && fetchView) { _debugInfoView.frame = fetchView.bounds; [fetchView addSubview:_debugInfoView]; [self setDebugInfoNeedsUpdate]; } if (triggerAppear) { [self triggerViewWillAppear]; [self triggerViewDidAppear]; } } } - (BOOL)isLoading { return _fetchOperation != nil; } #pragma mark Load - (void)cancelFetchRequest { [self _cancelFetch]; self.fetchRequest = nil; } - (void)clearImage { [self _resetToImageContainer:nil]; } - (void)reload { [self _refetchWithPeek:nil]; } #pragma mark Override methods - (void)setImageAsIfLoaded:(UIImage *)image { TIPImageContainer *container = (image) ? [[TIPImageContainer alloc] initWithImage:image] : nil; [self setImageContainerAsIfLoaded:container]; } - (void)setImageContainerAsIfLoaded:(TIPImageContainer *)imageContainer { [self cancelFetchRequest]; [self _startObservingImagePipeline:nil]; [self _markAsIfLoaded]; TIPImageFetchableSetImageContainer(self.fetchView, imageContainer); } - (void)markAsIfLoaded { if (TIPImageFetchableHasImage(self.fetchView)) { [self _markAsIfLoaded]; } } - (void)_markAsIfLoaded { _flags.isLoadedImageFinal = YES; _flags.isLoadedImageScaled = NO; _flags.isLoadedImagePreview = NO; _flags.isLoadedImageProgressive = NO; _flags.treatAsPlaceholder = NO; } - (void)setImageAsIfPlaceholder:(UIImage *)image { TIPImageContainer *container = (image) ? [[TIPImageContainer alloc] initWithImage:image] : nil; [self setImageContainerAsIfPlaceholder:container]; } - (void)setImageContainerAsIfPlaceholder:(TIPImageContainer *)imageContainer { [self cancelFetchRequest]; [self _startObservingImagePipeline:nil]; [self _markAsIfPlaceholder]; TIPImageFetchableSetImageContainer(self.fetchView, imageContainer); } - (void)markAsIfPlaceholder { if (TIPImageFetchableHasImage(self.fetchView)) { [self _markAsIfPlaceholder]; } } - (void)_markAsIfPlaceholder { _flags.isLoadedImageFinal = NO; _flags.isLoadedImageScaled = NO; _flags.isLoadedImagePreview = NO; _flags.isLoadedImageProgressive = NO; _flags.treatAsPlaceholder = YES; } #pragma mark Helpers + (void)transitionView:(UIView<TIPImageFetchable> *)fetchableView fromFetchHelper:(nullable TIPImageViewFetchHelper *)fromHelper toFetchHelper:(nullable TIPImageViewFetchHelper *)toHelper { if (fromHelper == toHelper || !toHelper) { return; } if (fromHelper && fromHelper.fetchView != fetchableView) { return; } toHelper.fetchView = fetchableView; TIPImageFetchOperation *oldOp = fromHelper ? fromHelper->_fetchOperation : nil; if (oldOp) { [oldOp discardDelegate]; // we want the old operation be coalesced with the new one (from the new fetch helper), // so defer the cancellation until after a coalescing can happen dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @autoreleasepool { [oldOp cancel]; } }); } fromHelper.fetchView = nil; } - (void)triggerViewWillMoveToWindow:(nullable UIWindow *)newWindow { UIView *imageView = self.fetchView; if (TIPIsViewVisible(imageView) && !newWindow) { // going from visible to not [self triggerViewWillDisappear]; } else if (!imageView.window && !imageView.isHidden && newWindow) { // going from not visible to visible [self triggerViewWillAppear]; } } - (void)triggerViewDidMoveToWindow { UIView *imageView = self.fetchView; if (_flags.transitioningAppearance) { if (imageView.window) { TIPAssert(TIPIsViewVisible(imageView)); [self triggerViewDidAppear]; } else { [self triggerViewDidDisappear]; } } } - (void)triggerApplicationDidEnterBackground { if (self.shouldTreatApplicationBackgroundAsViewDisappearance && TIPIsViewVisible(self.fetchView) && !TIPIsExtension()) { /** Call `triggerViewWillDisappear` and `triggerViewDidDisappear`, but do so async. This is because on app background the OS will take a snapshot of our app for previewing and resuming. If we run those triggers synchronously, they can yield UI changes (images being unloaded) which will affect the snapshot. Instead, dispatch async to the main queue (with a delay) and wrap the work in a background task. */ #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" UIApplication *app = [NSClassFromString(@"UIApplication") performSelector:NSSelectorFromString(@"sharedApplication")]; #pragma clang diagnostic pop UIBackgroundTaskIdentifier bgId = [app beginBackgroundTaskWithName:@"tip.defer.unload.image" expirationHandler:^{}]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @autoreleasepool { self->_flags.didTreatBackgroundingAsDisappearance = 1; [self triggerViewWillDisappear]; [self triggerViewDidDisappear]; } dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [app endBackgroundTask:bgId]; }); }); } } - (void)triggerApplicationWillEnterForeground { if (_flags.didTreatBackgroundingAsDisappearance) { _flags.didTreatBackgroundingAsDisappearance = 0; if (TIPIsViewVisible(self.fetchView)) { [self triggerViewWillAppear]; [self triggerViewDidAppear]; } } } #pragma mark Triggers - (void)triggerViewLayingOutSubviews { [self _handleViewResizeEvent]; } - (void)triggerViewWillDisappear { _flags.transitioningAppearance = 1; } - (void)triggerViewDidDisappear { _flags.transitioningAppearance = 0; switch (self.disappearanceBehavior) { case TIPImageViewDisappearanceBehaviorNone: break; case TIPImageViewDisappearanceBehaviorCancelImageFetch: { if (_fetchOperation) { [self _cancelFetch]; _flags.didCancelOnDisapper = 1; } break; } case TIPImageViewDisappearanceBehaviorLowerImageFetchPriority: { if (_fetchOperation) { _priorPriority = _fetchOperation.priority; _fetchOperation.priority = NSOperationQueuePriorityVeryLow + 2; _flags.didChangePriorityOnDisappear = 1; } break; } case TIPImageViewDisappearanceBehaviorUnload: { if (_fetchRequest != nil) { // Unload [self _resetToImageContainer:nil]; } break; } case TIPImageViewDisappearanceBehaviorReplaceWithPlaceholder: { if (_fetchRequest != nil) { // Replace with a Placeholder UIView<TIPImageFetchable> *fetchView = self.fetchView; // 1) get the placeholder static const CGFloat kPlaceholderDimension = 180.0; TIPImageContainer *placeholder = TIPImageFetchableGetImageContainer(fetchView); if (placeholder.dimensions.width > kPlaceholderDimension || placeholder.dimensions.height > kPlaceholderDimension) { placeholder = [placeholder scaleToTargetDimensions:CGSizeMake(kPlaceholderDimension, kPlaceholderDimension) contentMode:UIViewContentModeScaleAspectFit]; } // 2) unload the image [self _resetToImageContainer:nil]; // 3) set the placeholder TIPImageFetchableSetImageContainer(fetchView, placeholder); [self markAsIfPlaceholder]; } break; } } } - (void)triggerViewWillAppear { _flags.transitioningAppearance = 1; if (!_fetchOperation) { if (!_flags.isLoadedImageFinal) { [self cancelFetchRequest]; [self _startObservingImagePipeline:nil]; [self _refetchWithPeek:nil]; } } else { if (_flags.didChangePriorityOnDisappear) { _fetchOperation.priority = _priorPriority; } } _flags.didCancelOnDisapper = _flags.didChangePriorityOnDisappear = 0; } - (void)triggerViewDidAppear { _flags.transitioningAppearance = 0; } + (void)notifyAllFetchHelpersToRetryFailedLoads { tip_dispatch_async_autoreleasing(dispatch_get_main_queue(), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:kRetryFailedLoadsNotification object:nil userInfo:nil]; }); } @end @implementation TIPImageViewFetchHelper (Events) #pragma mark Events - (BOOL)_shouldUpdateImageWithResult:(id<TIPImageFetchResult>)previewImageResult { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldUpdateImageWithPreviewImageResult:)]) { return [delegate tip_fetchHelper:self shouldUpdateImageWithPreviewImageResult:previewImageResult]; } return NO; } - (BOOL)_shouldContinueLoadingWithResult:(id<TIPImageFetchResult>)previewImageResult { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldContinueLoadingAfterFetchingPreviewImageResult:)]) { return [delegate tip_fetchHelper:self shouldContinueLoadingAfterFetchingPreviewImageResult:previewImageResult]; } if (previewImageResult.imageIsTreatedAsPlaceholder) { return YES; } id<TIPImageFetchRequest> request = self.fetchRequest; if ([request respondsToSelector:@selector(options)] && (request.options & TIPImageFetchTreatAsPlaceholder)) { // would be a downgrade, stop return NO; } const CGSize originalDimensions = previewImageResult.imageOriginalDimensions; const CGSize viewDimensions = TIPDimensionsFromView(self.fetchView); if (originalDimensions.height >= viewDimensions.height && originalDimensions.width >= viewDimensions.width) { return NO; } return YES; } - (BOOL)_shouldLoadProgressivelyWithIdentifier:(NSString *)identifier imageURL:(NSURL *)URL imageType:(NSString *)imageType originalDimensions:(CGSize)originalDimensions { id<TIPImageViewFetchHelperDelegate> delegate = self.atomicDelegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldLoadProgressivelyWithIdentifier:URL:imageType:originalDimensions:)]) { return [delegate tip_fetchHelper:self shouldLoadProgressivelyWithIdentifier:identifier URL:URL imageType:imageType originalDimensions:originalDimensions]; } return NO; } - (BOOL)_shouldReloadAfterDifferentFetchCompletedWithImageContainer:(TIPImageContainer *)imageContainer dimensions:(CGSize)dimensions identifier:(NSString *)identifier imageURL:(NSURL *)URL treatedAsPlaceholder:(BOOL)placeholder manuallyStored:(BOOL)manuallyStored { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldReloadAfterDifferentFetchCompletedWithImageContainer:dimensions:identifier:URL:treatedAsPlaceholder:manuallyStored:)]) { return [delegate tip_fetchHelper:self shouldReloadAfterDifferentFetchCompletedWithImageContainer:imageContainer dimensions:dimensions identifier:identifier URL:URL treatedAsPlaceholder:placeholder manuallyStored:manuallyStored]; } else if ([delegate respondsToSelector:@selector(tip_fetchHelper:shouldReloadAfterDifferentFetchCompletedWithImage:dimensions:identifier:URL:treatedAsPlaceholder:manuallyStored:)]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" return [delegate tip_fetchHelper:self shouldReloadAfterDifferentFetchCompletedWithImage:imageContainer.image dimensions:dimensions identifier:identifier URL:URL treatedAsPlaceholder:placeholder manuallyStored:manuallyStored]; #pragma clang diagnostic pop } id<TIPImageFetchRequest> request = self.fetchRequest; if (!TIPImageFetchableHasImage(self.fetchView) && [request.imageURL isEqual:URL]) { // auto handle when the image loaded someplace else return YES; } if (self.fetchedImageTreatedAsPlaceholder && !placeholder) { // take the non-placeholder over the placeholder return YES; } return NO; } - (void)_didStartLoading { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelperDidStartLoading:)]) { [delegate tip_fetchHelperDidStartLoading:self]; } } - (void)_didUpdateProgress:(float)progress { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:didUpdateProgress:)]) { [delegate tip_fetchHelper:self didUpdateProgress:progress]; } } - (void)_didUpdateDisplayedImageContainer:(TIPImageContainer *)imageContainer sourceDimensions:(CGSize)sourceDimensions isFinal:(BOOL)isFinal { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:didUpdateDisplayedImageContainer:fromSourceDimensions:isFinal:)]) { [delegate tip_fetchHelper:self didUpdateDisplayedImageContainer:imageContainer fromSourceDimensions:sourceDimensions isFinal:isFinal]; } else if ([delegate respondsToSelector:@selector(tip_fetchHelper:didUpdateDisplayedImage:fromSourceDimensions:isFinal:)]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [delegate tip_fetchHelper:self didUpdateDisplayedImage:imageContainer.image fromSourceDimensions:sourceDimensions isFinal:isFinal]; #pragma clang diagnostic pop } } - (void)_didLoadFinalImageFromSource:(TIPImageLoadSource)source { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:didLoadFinalImageFromSource:)]) { [delegate tip_fetchHelper:self didLoadFinalImageFromSource:source]; } } - (void)_didFailToLoadFinalImageWithError:(NSError *)error { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelper:didFailToLoadFinalImage:)]) { [delegate tip_fetchHelper:self didFailToLoadFinalImage:error]; } } - (void)_didReset { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelperDidReset:)]) { [delegate tip_fetchHelperDidReset:self]; } } @end @implementation TIPImageViewFetchHelper (TIPImageFetchDelegate) #pragma mark Fetch Delegate - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didLoadDirtyPreviewImage:(id<TIPImageFetchResult>)result { if (op != _fetchOperation) { return; } const BOOL shouldUpdate = [self _shouldUpdateImageWithResult:result]; if (shouldUpdate) { [self _updateImageContainer:result.imageContainer sourceDimensions:result.imageOriginalDimensions URL:result.imageURL source:result.imageSource type:nil progress:0.f error:nil metrics:nil final:NO scaled:NO progressive:NO preview:YES placeholder:!!result.imageIsTreatedAsPlaceholder]; } } - (void)tip_imageFetchOperationDidStart:(TIPImageFetchOperation *)op { if (op != _fetchOperation) { return; } [self _didStartLoading]; [self setDebugInfoNeedsUpdate]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didLoadPreviewImage:(id<TIPImageFetchResult>)previewResult completion:(TIPImageFetchDidLoadPreviewCallback)completion { BOOL continueLoading = (op == _fetchOperation); if (continueLoading) { const BOOL shouldUpdate = [self _shouldUpdateImageWithResult:previewResult]; if (shouldUpdate) { continueLoading = !![self _shouldContinueLoadingWithResult:previewResult]; [self _updateImageContainer:previewResult.imageContainer sourceDimensions:previewResult.imageOriginalDimensions URL:previewResult.imageURL source:previewResult.imageSource type:nil progress:(continueLoading) ? 0.f : 1.f error:nil metrics:(continueLoading) ? nil : op.metrics final:!continueLoading scaled:!continueLoading progressive:NO preview:continueLoading placeholder:!!previewResult.imageIsTreatedAsPlaceholder]; } } completion(continueLoading ? TIPImageFetchPreviewLoadedBehaviorContinueLoading : TIPImageFetchPreviewLoadedBehaviorStopLoading); } - (BOOL)tip_imageFetchOperation:(TIPImageFetchOperation *)op shouldLoadProgressivelyWithIdentifier:(NSString *)identifier URL:(NSURL *)URL imageType:(NSString *)imageType originalDimensions:(CGSize)originalDimensions { if (op != _fetchOperation) { return NO; } return [self _shouldLoadProgressivelyWithIdentifier:identifier imageURL:URL imageType:imageType originalDimensions:originalDimensions]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didUpdateProgressiveImage:(id<TIPImageFetchResult>)progressiveResult progress:(float)progress { if (op != _fetchOperation) { return; } [self _updateImageContainer:progressiveResult.imageContainer sourceDimensions:op.networkImageOriginalDimensions URL:progressiveResult.imageURL source:progressiveResult.imageSource type:op.networkLoadImageType progress:progress error:nil metrics:nil final:NO scaled:NO progressive:YES preview:NO placeholder:YES /*TODO: investigate whether there are conditions when we can pass NO*/]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didLoadFirstAnimatedImageFrame:(id<TIPImageFetchResult>)progressiveResult progress:(float)progress { if (op != _fetchOperation) { return; } [self _updateImageContainer:progressiveResult.imageContainer sourceDimensions:op.networkImageOriginalDimensions URL:op.request.imageURL source:progressiveResult.imageSource type:op.networkLoadImageType progress:progress error:nil metrics:nil final:NO scaled:NO progressive:YES preview:NO placeholder:YES /*TODO: investigate whether there are conditions when we can pass NO*/]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didUpdateProgress:(float)progress { if (op != _fetchOperation) { return; } self.fetchProgress = progress; _loadedImageType = op.networkLoadImageType; [self _didUpdateProgress:progress]; [self setDebugInfoNeedsUpdate]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didLoadFinalImage:(id<TIPImageFetchResult>)finalResult { if (op != _fetchOperation) { return; } TIPImageContainer *imageContainer = finalResult.imageContainer; _fetchOperation = nil; [self _updateImageContainer:imageContainer sourceDimensions:finalResult.imageOriginalDimensions URL:finalResult.imageURL source:finalResult.imageSource type:op.networkLoadImageType progress:1.f error:nil metrics:op.metrics final:YES scaled:NO progressive:NO preview:NO placeholder:!!finalResult.imageIsTreatedAsPlaceholder]; } - (void)tip_imageFetchOperation:(TIPImageFetchOperation *)op didFailToLoadFinalImage:(NSError *)error { if (op != _fetchOperation) { return; } _fetchOperation = nil; self.fetchMetrics = op.metrics; if ([error.domain isEqualToString:TIPImageFetchErrorDomain] && TIPImageFetchErrorCodeCancelledAfterLoadingPreview == error.code) { // already finished as success } else { self.fetchError = error; [self _didFailToLoadFinalImageWithError:error]; } [self setDebugInfoNeedsUpdate]; } - (void)tip_imageFetchOperation:(nonnull TIPImageFetchOperation *)op willAttemptToLoadFromSource:(TIPImageLoadSource)source { if (op != _fetchOperation) { return; } if (source >= TIPImageLoadSourceNetwork) { id<TIPImageViewFetchHelperDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(tip_fetchHelperDidStartLoadingFromNetwork:)]) { [delegate tip_fetchHelperDidStartLoadingFromNetwork:self]; } } } @end @implementation TIPImageViewFetchHelper (Private) - (void)_tearDown { [self _cancelFetch]; NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc removeObserver:self name:TIPImageViewDidUpdateDebugInfoVisibilityNotification object:nil]; [nc removeObserver:self name:kRetryFailedLoadsNotification object:nil]; if (_opaqueNotificationObserver) { [nc removeObserver:_opaqueNotificationObserver]; } [_debugInfoView removeFromSuperview]; } - (void)_prep { NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self selector:@selector(_tip_didUpdateDebugVisibility) name:TIPImageViewDidUpdateDebugInfoVisibilityNotification object:nil]; [nc addObserver:self selector:@selector(_tip_retryFailedLoadsNotification:) name:kRetryFailedLoadsNotification object:nil]; [self _tip_didUpdateDebugVisibility]; _disappearanceBehavior = TIPImageViewDisappearanceBehaviorUnload; _shouldTreatApplicationBackgroundAsViewDisappearance = NO; [self _resetToImageContainer:nil]; } - (void)_handleViewResizeEvent { if (!self.fetchRequest || [self _resizeRequestIfNeeded]) { id<TIPImageFetchRequest> peekRequest = nil; if (_flags.isLoadedImageFinal) { // downgrade what we have from being "final" to a "preview" _flags.isLoadedImageFinal = 0; _flags.isLoadedImagePreview = 1; [self _cancelFetch]; } else if (_fetchOperation) { id<TIPImageViewFetchHelperDataSource> dataSource = self.dataSource; peekRequest = [self _extractRequestFromDataSource:dataSource]; if (peekRequest && [_fetchOperation.request.imageURL isEqual:peekRequest.imageURL]) { // We're about to fetch the same image as our current op. // Don't cancel the current op, just make it headless (no delegate). // That way, the resize doesn't force the request to stop and start again. [_fetchOperation discardDelegate]; } else { [_fetchOperation cancelAndDiscardDelegate]; } _fetchOperation = nil; } [self _refetchWithPeek:peekRequest]; } } - (BOOL)_resizeRequestIfNeeded { id<TIPImageViewFetchHelperDataSource> dataSource = self.dataSource; if (![dataSource respondsToSelector:@selector(tip_shouldRefetchOnTargetSizingChangeForFetchHelper:)]) { return NO; } id<TIPImageFetchRequest> fetchRequest = self.fetchRequest; const CGSize targetDimensions = [fetchRequest respondsToSelector:@selector(targetDimensions)] ? [fetchRequest targetDimensions] : CGSizeZero; const UIViewContentMode targetContentMode = [fetchRequest respondsToSelector:@selector(targetContentMode)] ? [fetchRequest targetContentMode] : UIViewContentModeCenter; BOOL canRefetch = NO; if (!TIPContentModeDoesScale(targetContentMode) || !TIPSizeGreaterThanZero(targetDimensions)) { canRefetch = YES; // don't know the sizing, can refetch } else { UIView *fetchView = self.fetchView; const CGSize viewDimensions = TIPDimensionsFromView(fetchView); const UIViewContentMode viewContentMode = fetchView.contentMode; if (!CGSizeEqualToSize(viewDimensions, targetDimensions)) { canRefetch = YES; // size differs, can refetch } else if (viewContentMode != targetContentMode) { canRefetch = YES; // content mode differs, can refetch } } if (canRefetch) { return [dataSource tip_shouldRefetchOnTargetSizingChangeForFetchHelper:self]; } return NO; } - (void)_updateImageContainer:(nullable TIPImageContainer *)imageContainer sourceDimensions:(CGSize)sourceDimensions URL:(nullable NSURL *)URL source:(TIPImageLoadSource)source type:(nullable NSString *)type progress:(float)progress error:(nullable NSError *)error metrics:(nullable TIPImageFetchMetrics *)metrics final:(BOOL)final scaled:(BOOL)scaled progressive:(BOOL)progressive preview:(BOOL)preview placeholder:(BOOL)placeholder { if (gTwitterImagePipelineAssertEnabled) { TIPAssertMessage((0b11111110 & final) == 0b0, @"Cannot set a 1-bit flag with a BOOL that isn't 1 bit"); TIPAssertMessage((0b11111110 & preview) == 0b0, @"Cannot set a 1-bit flag with a BOOL that isn't 1 bit"); TIPAssertMessage((0b11111110 & progressive) == 0b0, @"Cannot set a 1-bit flag with a BOOL that isn't 1 bit"); TIPAssertMessage((0b11111110 & scaled) == 0b0, @"Cannot set a 1-bit flag with a BOOL that isn't 1 bit"); TIPAssertMessage((0b11111110 & placeholder) == 0b0, @"Cannot set a 1-bit flag with a BOOL that isn't 1 bit"); } self.fetchSource = source; _fetchedImageURL = URL; _flags.isLoadedImageFinal = final; _flags.isLoadedImageScaled = scaled; _flags.isLoadedImageProgressive = progressive; _flags.isLoadedImagePreview = preview; _flags.treatAsPlaceholder = placeholder; _loadedImageType = [type copy]; self.fetchError = error; self.fetchMetrics = metrics; if (metrics && imageContainer) { self.fetchResultDimensions = sourceDimensions; } else { self.fetchResultDimensions = CGSizeZero; } BOOL didUpdateProgress = NO; const float oldProgress = self.fetchProgress; if ((progress > oldProgress) || (progress == oldProgress && oldProgress > 0.f) || !self.didLoadAny) { self.fetchProgress = progress; didUpdateProgress = YES; } TIPImageFetchableSetImageContainer(self.fetchView, imageContainer); if (didUpdateProgress) { [self _didUpdateProgress:progress]; } if (imageContainer) { [self _didUpdateDisplayedImageContainer:imageContainer sourceDimensions:sourceDimensions isFinal:(final || scaled)]; } if (final || scaled) { [self _didLoadFinalImageFromSource:source]; } if (!self.didLoadAny) { [self _didReset]; } [self setDebugInfoNeedsUpdate]; } - (void)_resetToImageContainer:(nullable TIPImageContainer *)imageContainer { [self _cancelFetch]; [self _startObservingImagePipeline:nil]; [self _updateImageContainer:imageContainer sourceDimensions:CGSizeZero URL:nil source:TIPImageLoadSourceUnknown type:nil progress:(imageContainer != nil) ? 1.f : 0.f error:nil metrics:nil final:NO scaled:NO progressive:NO preview:NO placeholder:NO]; } - (void)_cancelFetch { [_fetchOperation cancelAndDiscardDelegate]; _fetchOperation = nil; } - (void)_refetchWithPeek:(nullable id<TIPImageFetchRequest>)peekedRequest { if (!_fetchOperation) { UIView<TIPImageFetchable> *fetchView = self.fetchView; if (TIPIsViewVisible(fetchView) || _flags.transitioningAppearance) { const CGSize size = fetchView.bounds.size; if (size.width > 0 && size.height > 0) { if (!TIPImageFetchableHasImage(fetchView) || !_flags.isLoadedImageFinal) { id<TIPImageViewFetchHelperDataSource> dataSource = self.dataSource; // Attempt static load first if ([dataSource respondsToSelector:@selector(tip_imageContainerForFetchHelper:)]) { TIPImageContainer *container = [dataSource tip_imageContainerForFetchHelper:self]; if (container) { [self setImageContainerAsIfLoaded:container]; return; } } if ([dataSource respondsToSelector:@selector(tip_imageForFetchHelper:)]) { UIImage *image = [dataSource tip_imageForFetchHelper:self]; if (image) { [self setImageAsIfLoaded:image]; return; } } // Attempt network load id<TIPImageFetchRequest> request = peekedRequest; if (!request) { request = [self _extractRequestFromDataSource:dataSource]; } if (request && [dataSource respondsToSelector:@selector(tip_imagePipelineForFetchHelper:)]) { self.fetchRequest = request; TIPImagePipeline *pipeline = [dataSource tip_imagePipelineForFetchHelper:self]; if (!pipeline) { self.fetchRequest = nil; return; } _fetchOperation = [pipeline operationWithRequest:request context:nil delegate:self]; if ([dataSource respondsToSelector:@selector(tip_fetchOperationPriorityForFetchHelper:)]) { const NSOperationQueuePriority priority = [dataSource tip_fetchOperationPriorityForFetchHelper:self]; _fetchOperation.priority = priority; } [self _startObservingImagePipeline:pipeline]; [pipeline fetchImageWithOperation:_fetchOperation]; } } } } } } - (nullable id<TIPImageFetchRequest>)_extractRequestFromDataSource:(nullable id<TIPImageViewFetchHelperDataSource>)dataSource { id<TIPImageFetchRequest> request = nil; if (!request && [dataSource respondsToSelector:@selector(tip_imageFetchRequestForFetchHelper:)]) { request = [dataSource tip_imageFetchRequestForFetchHelper:self]; } if (!request && [dataSource respondsToSelector:@selector(tip_imageURLForFetchHelper:)]) { NSURL *imageURL = [dataSource tip_imageURLForFetchHelper:self]; if (imageURL) { request = [self _createRequestWithURL:imageURL]; } } return request; } - (id<TIPImageFetchRequest>)_createRequestWithURL:(NSURL *)imageURL { return [[TIPImageViewSimpleFetchRequest alloc] initWithImageURL:imageURL targetView:self.fetchView]; } - (void)_startObservingImagePipeline:(nullable TIPImagePipeline *)pipeline { NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; if (!_opaqueNotificationObserver) { __weak typeof(self) weakSelf = self; _opaqueNotificationObserver = [nc addObserverForName:TIPImagePipelineDidStoreCachedImageNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { [weakSelf _tip_imageDidUpdate:note]; }]; } // save the pipeline identifier for use in `_tip_imageDidUpdate:` _observedPipelineIdentifier = [[pipeline identifier] copy]; } - (void)_tip_imageDidUpdate:(NSNotification *)note { if (!_fetchOperation) { if (![[note object] isKindOfClass:TIPImagePipeline.class]) { return; } TIPImagePipeline *pipeline = note.object; id<TIPImageFetchRequest> request = self.fetchRequest; NSString *requestIdentifier = TIPImageFetchRequestGetImageIdentifier(request); NSDictionary *userInfo = note.userInfo; NSString *identifier = userInfo[TIPImagePipelineImageIdentifierNotificationKey]; if ([requestIdentifier isEqualToString:identifier] && [pipeline.identifier isEqualToString:_observedPipelineIdentifier]) { const BOOL manuallyStored = [userInfo[TIPImagePipelineImageWasManuallyStoredNotificationKey] boolValue]; const BOOL placeholder = [userInfo[TIPImagePipelineImageTreatAsPlaceholderNofiticationKey] boolValue]; NSURL *URL = userInfo[TIPImagePipelineImageURLNotificationKey]; CGSize dimensions = [(NSValue *)userInfo[TIPImagePipelineImageDimensionsNotificationKey] CGSizeValue]; TIPImageContainer *container = userInfo[TIPImagePipelineImageContainerNotificationKey]; const BOOL shouldReload = [self _shouldReloadAfterDifferentFetchCompletedWithImageContainer:container dimensions:dimensions identifier:identifier imageURL:URL treatedAsPlaceholder:placeholder manuallyStored:manuallyStored]; if (shouldReload) { UIView<TIPImageFetchable> *fetchView = self.fetchView; if (!TIPIsViewVisible(fetchView)) { _flags.didCancelOnDisapper = 1; } [self _cancelFetch]; [self _startObservingImagePipeline:nil]; _flags.isLoadedImageFinal = 0; tip_dispatch_async_autoreleasing(dispatch_get_main_queue(), ^{ // Dirty the render cache, but async so other render cache stores can complete first [[TIPGlobalConfiguration sharedInstance] dirtyAllRenderedMemoryCacheImagesWithIdentifier:identifier]; // Async refetch [self _refetchWithPeek:nil]; }); } } } } - (void)_tip_didUpdateDebugVisibility { if ([TIPImageViewFetchHelper isDebugInfoVisible]) { [self _showDebugInfo]; } else { [self _hideDebugInfo]; } } - (void)_showDebugInfo { if (_debugInfoView != nil) { return; } UIView *fetchView = self.fetchView; UILabel *label = [[UILabel alloc] initWithFrame:fetchView.bounds]; label.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; label.textAlignment = NSTextAlignmentCenter; label.font = [UIFont boldSystemFontOfSize:12]; label.numberOfLines = 2; label.adjustsFontSizeToFitWidth = YES; label.minimumScaleFactor = 0.5; label.textColor = self.debugInfoTextColor ?: kDEBUG_TEXT_COLOR_DEFAULT; label.backgroundColor = self.debugImageHighlightColor ?: kDEBUG_HIGHLIGHT_COLOR_DEFAULT; _debugInfoView = label; [fetchView addSubview:_debugInfoView]; [self setDebugInfoNeedsUpdate]; } - (void)_hideDebugInfo { [_debugInfoView removeFromSuperview]; _debugInfoView = nil; } - (NSString *)_buildDebugInfoString:(nonnull out NSInteger *)lineCount { NSArray<NSString *> *infos = [self debugInfoStrings]; NSString *debugInfoString = [infos componentsJoinedByString:@"\n"]; *lineCount = (NSInteger)infos.count; return debugInfoString; } #pragma mark Retry Event - (void)_tip_retryFailedLoadsNotification:(NSNotification *)note { if (self.fetchError != nil && !self.isLoading) { [self _refetchWithPeek:nil]; } } @end @implementation TIPImageViewFetchHelper (Debugging) + (void)setDebugInfoVisible:(BOOL)debugInfoVisible { TIPAssert([NSThread isMainThread]); debugInfoVisible = !!debugInfoVisible; // isolate on 1-bit (0 or 1) if (sDebugInfoVisible != debugInfoVisible) { sDebugInfoVisible = debugInfoVisible; [[NSNotificationCenter defaultCenter] postNotificationName:TIPImageViewDidUpdateDebugInfoVisibilityNotification object:nil userInfo:@{ TIPImageViewDidUpdateDebugInfoVisibilityNotificationKeyVisible : @(sDebugInfoVisible) }]; } } + (BOOL)isDebugInfoVisible { TIPAssert([NSThread isMainThread]); return sDebugInfoVisible; } - (void)setDebugInfoTextColor:(nullable UIColor *)debugInfoTextColor { _debugInfoTextColor = debugInfoTextColor; if (_debugInfoView) { _debugInfoView.textColor = _debugInfoTextColor ?: kDEBUG_TEXT_COLOR_DEFAULT; } } - (nullable UIColor *)debugInfoTextColor { return _debugInfoTextColor; } - (void)setDebugImageHighlightColor:(nullable UIColor *)debugImageHighlightColor { _debugImageHighlightColor = debugImageHighlightColor; if (_debugInfoView) { _debugInfoView.backgroundColor = debugImageHighlightColor ?: kDEBUG_HIGHLIGHT_COLOR_DEFAULT; } } - (nullable UIColor *)debugImageHighlightColor { return _debugImageHighlightColor; } - (NSMutableArray<NSString *> *)debugInfoStrings { NSMutableArray<NSString *> *infos = [[NSMutableArray alloc] init]; NSString *loadSource = @"Manual"; NSString *loadType = @""; NSString *imageType = @""; NSString *imageBytes = @""; NSString *pixelsPerByte = nil; const BOOL loadedSomething = _flags.isLoadedImageScaled || _flags.isLoadedImagePreview || _flags.isLoadedImageProgressive || _flags.isLoadedImageFinal ; if (loadedSomething) { if (_flags.isLoadedImageFinal) { loadType = @"done"; } else { if (_flags.isLoadedImageProgressive) { loadType = @"scan"; } else if (_flags.isLoadedImagePreview) { loadType = @"preview"; } else { loadType = @"scaled"; } } switch (_fetchSource) { case TIPImageLoadSourceMemoryCache: loadSource = [self.fetchMetrics metricInfoForSource:_fetchSource].wasLoadedSynchronously ? @"RMem" : @"Mem"; break; case TIPImageLoadSourceDiskCache: loadSource = @"Disk"; break; case TIPImageLoadSourceAdditionalCache: loadSource = @"Other"; break; case TIPImageLoadSourceNetwork: loadSource = @"Network"; break; case TIPImageLoadSourceNetworkResumed: loadSource = @"NetResm"; break; case TIPImageLoadSourceUnknown: default: loadSource = @"???"; break; } if (_flags.isLoadedImageFinal || _flags.isLoadedImageProgressive) { if (_fetchSource >= TIPImageLoadSourceNetwork) { imageType = [NSString stringWithFormat:@" %@", (_loadedImageType ?: @"???")]; if (_fetchMetrics) { TIPImageFetchMetricInfo *info = [_fetchMetrics metricInfoForSource:_fetchSource]; if (info.networkImageSizeInBytes > 0) { imageBytes = [@" " stringByAppendingString:[NSByteCountFormatter stringFromByteCount:(long long)info.networkImageSizeInBytes countStyle:NSByteCountFormatterCountStyleBinary]]; } if (info.networkImagePixelsPerByte > 0) { pixelsPerByte = [NSString stringWithFormat:@"Pixels/Byte: %.3f", info.networkImagePixelsPerByte]; } } } } } else if (_fetchOperation != nil) { loadSource = @"Loading"; } [infos addObject:[NSString stringWithFormat:@"%3i%%%@%@", (int)(self.fetchProgress * 100), imageType, imageBytes]]; [infos addObject:[NSString stringWithFormat:@"%@ %@", loadSource, loadType]]; if (_fetchSource >= TIPImageLoadSourceNetwork && self.fetchMetrics.totalDuration > 0) { [infos addObject:[NSString stringWithFormat:@"Total: %.2fs", self.fetchMetrics.totalDuration]]; } if (self.fetchError) { [infos addObject:[NSString stringWithFormat:@"%@:%ti", self.fetchError.domain, self.fetchError.code]]; } if (pixelsPerByte) { [infos addObject:pixelsPerByte]; } id<TIPImageViewFetchHelperDataSource> dataSource = self.dataSource; if ([dataSource respondsToSelector:@selector(tip_additionalDebugInfoStringsForFetchHelper:)]) { NSArray<NSString *> *extraInfo = [dataSource tip_additionalDebugInfoStringsForFetchHelper:self]; if (extraInfo) { [infos addObjectsFromArray:extraInfo]; } } return infos; } - (void)setDebugInfoNeedsUpdate { if (_debugInfoView) { NSInteger lineCount = 1; NSString *info = [self _buildDebugInfoString:&lineCount]; _debugInfoView.numberOfLines = lineCount; _debugInfoView.text = info; } } @end @implementation TIPImageViewSimpleFetchRequest - (instancetype)initWithImageURL:(NSURL *)imageURL targetView:(nullable UIView *)view { if (self = [super init]) { _imageURL = imageURL; if (view) { _targetDimensions = TIPDimensionsFromView(view); _targetContentMode = view.contentMode; } } return self; } @end NS_ASSUME_NONNULL_END