diff --git a/AsyncDisplayKit.xcodeproj/project.pbxproj b/AsyncDisplayKit.xcodeproj/project.pbxproj index c1b1ac8b..a8fd8fff 100644 --- a/AsyncDisplayKit.xcodeproj/project.pbxproj +++ b/AsyncDisplayKit.xcodeproj/project.pbxproj @@ -1675,7 +1675,6 @@ 058D09B9195D04C000B7D73C /* Frameworks */, 058D09BA195D04C000B7D73C /* Resources */, 3B9D88CDF51B429C8409E4B6 /* Copy Pods Resources */, - BD5CC779F736EBA28F5313FB /* Embed Pods Frameworks */, ); buildRules = ( ); @@ -1805,21 +1804,6 @@ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-AsyncDisplayKitTests/Pods-AsyncDisplayKitTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; - BD5CC779F736EBA28F5313FB /* Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-AsyncDisplayKitTests/Pods-AsyncDisplayKitTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/AsyncDisplayKit/ASCollectionView.mm b/AsyncDisplayKit/ASCollectionView.mm index 83aff004..09994745 100644 --- a/AsyncDisplayKit/ASCollectionView.mm +++ b/AsyncDisplayKit/ASCollectionView.mm @@ -1040,20 +1040,12 @@ static NSString * const kCellReuseIdentifier = @"_ASCollectionViewCell"; - (void)clearContents { - for (NSArray *section in [_dataController completedNodes]) { - for (ASDisplayNode *node in section) { - [node exitInterfaceState:ASInterfaceStateDisplay]; - } - } + [_rangeController clearContents]; } - (void)clearFetchedData { - for (NSArray *section in [_dataController completedNodes]) { - for (ASDisplayNode *node in section) { - [node exitInterfaceState:ASInterfaceStateFetchData]; - } - } + [_rangeController clearFetchedData]; } #pragma mark - _ASDisplayView behavior substitutions diff --git a/AsyncDisplayKit/ASImageNode.mm b/AsyncDisplayKit/ASImageNode.mm index ec4141a5..94891a62 100644 --- a/AsyncDisplayKit/ASImageNode.mm +++ b/AsyncDisplayKit/ASImageNode.mm @@ -128,13 +128,15 @@ - (void)setImage:(UIImage *)image { - ASDN::MutexLocker l(_imageLock); + _imageLock.lock(); if (!ASObjectIsEqual(_image, image)) { _image = image; - ASDN::MutexUnlocker u(_imageLock); + _imageLock.unlock(); [self invalidateCalculatedLayout]; [self setNeedsDisplay]; + } else { + _imageLock.unlock(); // We avoid using MutexUnlocker as it needlessly re-locks at the end of the scope. } } diff --git a/AsyncDisplayKit/ASTableView.mm b/AsyncDisplayKit/ASTableView.mm index ce53236d..1cad0832 100644 --- a/AsyncDisplayKit/ASTableView.mm +++ b/AsyncDisplayKit/ASTableView.mm @@ -1037,20 +1037,12 @@ static NSString * const kCellReuseIdentifier = @"_ASTableViewCell"; - (void)clearContents { - for (NSArray *section in [_dataController completedNodes]) { - for (ASDisplayNode *node in section) { - [node recursivelyClearContents]; - } - } + [_rangeController clearContents]; } - (void)clearFetchedData { - for (NSArray *section in [_dataController completedNodes]) { - for (ASDisplayNode *node in section) { - [node recursivelyClearFetchedData]; - } - } + [_rangeController clearFetchedData]; } #pragma mark - _ASDisplayView behavior substitutions diff --git a/AsyncDisplayKit/Details/ASAbstractLayoutController.mm b/AsyncDisplayKit/Details/ASAbstractLayoutController.mm index 270d0b12..3ed6ccc5 100644 --- a/AsyncDisplayKit/Details/ASAbstractLayoutController.mm +++ b/AsyncDisplayKit/Details/ASAbstractLayoutController.mm @@ -51,6 +51,18 @@ extern BOOL ASRangeTuningParametersEqualToRangeTuningParameters(ASRangeTuningPar .trailingBufferScreenfuls = 2 }; + _tuningParameters[ASLayoutRangeModeVisibleOnly][ASLayoutRangeTypeDisplay] = { + .leadingBufferScreenfuls = 0, + .trailingBufferScreenfuls = 0 + }; + _tuningParameters[ASLayoutRangeModeVisibleOnly][ASLayoutRangeTypeFetchData] = { + .leadingBufferScreenfuls = 0, + .trailingBufferScreenfuls = 0 + }; + + // The Low Memory range mode has special handling. Because a zero range still includes the visible area / bounds, + // in order to implement the behavior of releasing all graphics memory (backing stores), ASRangeController must check + // for this range mode and use an empty set for displayIndexPaths rather than querying the ASLayoutController for the indexPaths. _tuningParameters[ASLayoutRangeModeLowMemory][ASLayoutRangeTypeDisplay] = { .leadingBufferScreenfuls = 0, .trailingBufferScreenfuls = 0 diff --git a/AsyncDisplayKit/Details/ASLayoutRangeType.h b/AsyncDisplayKit/Details/ASLayoutRangeType.h index 44d593cd..1b1e3a3c 100644 --- a/AsyncDisplayKit/Details/ASLayoutRangeType.h +++ b/AsyncDisplayKit/Details/ASLayoutRangeType.h @@ -29,9 +29,18 @@ typedef NS_ENUM(NSUInteger, ASLayoutRangeMode) { ASLayoutRangeModeFull, /** - * Low Memory mode is used when a range controller should limit the amount of work it performs to 0. - * Thus, it discards most of the views/layers that are created and it is trying to save as much system - * resources as possible. + * Visible Only mode is used when a range controller should set its display and fetch data regions to only the size of their bounds. + * This causes all additional backing stores & fetched data to be released, while ensuring a user revisiting the view will + * still be able to see the expected content. This mode is automatically set on all ASRangeControllers when the app suspends, + * allowing the operating system to keep the app alive longer and increase the chance it is still warm when the user returns. + */ + ASLayoutRangeModeVisibleOnly, + + /** + * Low Memory mode is used when a range controller should discard ALL graphics buffers, including for the area that would be visible + * the next time the user views it (bounds). The only range it preserves is Fetch Data, which is limited to the bounds, allowing + * the content to be restored relatively quickly by re-decoding images (the compressed images are ~10% the size of the decoded ones, + * and text is a tiny fraction of its rendered size). */ ASLayoutRangeModeLowMemory, ASLayoutRangeModeCount diff --git a/AsyncDisplayKit/Details/ASRangeController.h b/AsyncDisplayKit/Details/ASRangeController.h index 4169268d..bba570d0 100644 --- a/AsyncDisplayKit/Details/ASRangeController.h +++ b/AsyncDisplayKit/Details/ASRangeController.h @@ -13,8 +13,7 @@ #import #import -#define ASRangeControllerLoggingEnabled 1 -#define ASRangeControllerAutomaticLowMemoryHandling 1 +#define ASRangeControllerLoggingEnabled 0 NS_ASSUME_NONNULL_BEGIN @@ -59,6 +58,11 @@ NS_ASSUME_NONNULL_BEGIN - (ASRangeTuningParameters)tuningParametersForRangeMode:(ASLayoutRangeMode)rangeMode rangeType:(ASLayoutRangeType)rangeType; +// These methods call the corresponding method on each node, visiting each one that +// the range controller has set a non-default interface state on. +- (void)clearContents; +- (void)clearFetchedData; + /** * An object that describes the layout behavior of the ranged component (table view, collection view, etc.) * diff --git a/AsyncDisplayKit/Details/ASRangeController.mm b/AsyncDisplayKit/Details/ASRangeController.mm index 30e6b41d..c466d28f 100644 --- a/AsyncDisplayKit/Details/ASRangeController.mm +++ b/AsyncDisplayKit/Details/ASRangeController.mm @@ -73,6 +73,19 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; return ASLayoutRangeModeFull; } +- (ASInterfaceState)interfaceState +{ + ASInterfaceState selfInterfaceState = ASInterfaceStateNone; + if (_dataSource) { + selfInterfaceState = [_dataSource interfaceStateForRangeController:self]; + } + if (__ApplicationState == UIApplicationStateBackground) { + // If the app is background, pretend to be invisible so that we inform each cell it is no longer being viewed by the user + selfInterfaceState &= ~(ASInterfaceStateVisible); + } + return selfInterfaceState; +} + - (void)visibleNodeIndexPathsDidChangeWithScrollDirection:(ASScrollDirection)scrollDirection { _scrollDirection = scrollDirection; @@ -95,7 +108,7 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; if (_queuedRangeUpdate) { return; } - + // coalesce these events -- handling them multiple times per runloop is noisy and expensive _queuedRangeUpdate = YES; @@ -116,26 +129,37 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; { _layoutController = layoutController; _layoutControllerImplementsSetVisibleIndexPaths = [_layoutController respondsToSelector:@selector(setVisibleNodeIndexPaths:)]; + if (_layoutController && _queuedRangeUpdate) { + [self performRangeUpdate]; + } +} + +- (void)setDataSource:(id)dataSource +{ + _dataSource = dataSource; + if (_dataSource && _queuedRangeUpdate) { + [self performRangeUpdate]; + } } - (void)_updateVisibleNodeIndexPaths { ASDisplayNodeAssert(_layoutController, @"An ASLayoutController is required by ASRangeController"); - if (!_queuedRangeUpdate || !_layoutController) { + if (!_queuedRangeUpdate || !_layoutController || !_dataSource) { return; } - + // TODO: Consider if we need to use this codepath, or can rely on something more similar to the data & display ranges // Example: ... = [_layoutController indexPathsForScrolling:_scrollDirection rangeType:ASLayoutRangeTypeVisible]; NSArray *visibleNodePaths = [_dataSource visibleNodeIndexPathsForRangeController:self]; - + if (visibleNodePaths.count == 0) { // if we don't have any visibleNodes currently (scrolled before or after content)... _queuedRangeUpdate = NO; return; // don't do anything for this update, but leave _rangeIsValid == NO to make sure we update it later } - + [_layoutController setViewportSize:[_dataSource viewportSizeForRangeController:self]]; - + // the layout controller needs to know what the current visible indices are to calculate range offsets if (_layoutControllerImplementsSetVisibleIndexPaths) { [_layoutController setVisibleNodeIndexPaths:visibleNodePaths]; @@ -157,12 +181,7 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; // the network or display queues before preloading (offscreen) nodes are enqueued. NSMutableOrderedSet *allIndexPaths = [[NSMutableOrderedSet alloc] initWithSet:visibleIndexPaths]; - ASInterfaceState selfInterfaceState = [_dataSource interfaceStateForRangeController:self]; - if (__ApplicationState == UIApplicationStateBackground) { - // If the app is background, proceed as if all range controllers are invisible so that we inform each cell it is no longer being viewed by the user - selfInterfaceState &= ~(ASInterfaceStateVisible); - } - + ASInterfaceState selfInterfaceState = [self interfaceState]; ASLayoutRangeMode rangeMode = _currentRangeMode; if (!_didUpdateCurrentRange) { rangeMode = [ASRangeController rangeModeForInterfaceState:selfInterfaceState currentRangeMode:_currentRangeMode]; @@ -177,7 +196,7 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; rangeMode:rangeMode rangeType:ASLayoutRangeTypeFetchData]; } - + ASRangeTuningParameters parametersDisplay = [_layoutController tuningParametersForRangeMode:rangeMode rangeType:ASLayoutRangeTypeDisplay]; if (rangeMode == ASLayoutRangeModeLowMemory) { @@ -212,7 +231,7 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; if (!_rangeIsValid) { [allIndexPaths addObjectsFromArray:ASIndexPathsForMultidimensionalArray(allNodes)]; } - + // TODO Don't register for notifications if this range update doesn't cause any node to enter rendering pipeline. // This can be done once there is an API to observe to (or be notified upon) interface state changes or pipeline enterings [self registerForNotificationsForInterfaceStateIfNeeded:selfInterfaceState]; @@ -283,33 +302,24 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; } } } - + if (_didRegisterForNotifications) { _pendingDisplayNodesTimestamp = CFAbsoluteTimeGetCurrent(); } - + _rangeIsValid = YES; _queuedRangeUpdate = NO; #if ASRangeControllerLoggingEnabled - NSSet *visibleNodePathsSet = [NSSet setWithArray:visibleNodePaths]; - BOOL setsAreEqual = [visibleIndexPaths isEqualToSet:visibleNodePathsSet]; - NSLog(@"visible sets are equal: %d", setsAreEqual); - if (!setsAreEqual) { - NSLog(@"standard: %@", visibleIndexPaths); - NSLog(@"custom: %@", visibleNodePathsSet); - } - +// NSSet *visibleNodePathsSet = [NSSet setWithArray:visibleNodePaths]; +// BOOL setsAreEqual = [visibleIndexPaths isEqualToSet:visibleNodePathsSet]; +// NSLog(@"visible sets are equal: %d", setsAreEqual); +// if (!setsAreEqual) { +// NSLog(@"standard: %@", visibleIndexPaths); +// NSLog(@"custom: %@", visibleNodePathsSet); +// } [modifiedIndexPaths sortUsingSelector:@selector(compare:)]; - - for (NSIndexPath *indexPath in modifiedIndexPaths) { - ASDisplayNode *node = [_dataSource rangeController:self nodeAtIndexPath:indexPath]; - ASInterfaceState interfaceState = node.interfaceState; - BOOL inVisible = ASInterfaceStateIncludesVisible(interfaceState); - BOOL inDisplay = ASInterfaceStateIncludesDisplay(interfaceState); - BOOL inFetchData = ASInterfaceStateIncludesFetchData(interfaceState); - NSLog(@"indexPath %@, Visible: %d, Display: %d, FetchData: %d", indexPath, inVisible, inDisplay, inFetchData); - } + NSLog(@"Range update complete; modifiedIndexPaths: %@", [self descriptionWithIndexPaths:modifiedIndexPaths]); #endif } @@ -319,7 +329,7 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; { if (!_didRegisterForNotifications) { ASLayoutRangeMode nextRangeMode = [ASRangeController rangeModeForInterfaceState:interfaceState - currentRangeMode:_currentRangeMode]; + currentRangeMode:_currentRangeMode]; if (_currentRangeMode != nextRangeMode) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(scheduledNodesDidDisplay:) @@ -423,6 +433,31 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; }); } +#pragma mark - Memory Management + +// Skip the many method calls of the recursive operation if the top level cell node already has the right interfaceState. +- (void)clearContents +{ + for (NSArray *section in [_dataSource completedNodes]) { + for (ASDisplayNode *node in section) { + if (ASInterfaceStateIncludesDisplay(node.interfaceState)) { + [node exitInterfaceState:ASInterfaceStateDisplay]; + } + } + } +} + +- (void)clearFetchedData +{ + for (NSArray *section in [_dataSource completedNodes]) { + for (ASDisplayNode *node in section) { + if (ASInterfaceStateIncludesFetchData(node.interfaceState)) { + [node exitInterfaceState:ASInterfaceStateFetchData]; + } + } + } +} + #pragma mark - Class Methods (Application Notification Handlers) + (ASWeakSet *)allRangeControllersWeakSet @@ -446,36 +481,80 @@ static UIApplicationState __ApplicationState = UIApplicationStateActive; [center addObserver:self selector:@selector(willEnterForeground:) name:UIApplicationWillEnterForegroundNotification object:nil]; } +static ASLayoutRangeMode __rangeModeForMemoryWarnings = ASLayoutRangeModeVisibleOnly; ++ (void)setRangeModeForMemoryWarnings:(ASLayoutRangeMode)rangeMode +{ + ASDisplayNodeAssert(rangeMode == ASLayoutRangeModeVisibleOnly || rangeMode == ASLayoutRangeModeLowMemory, @"It is highly inadvisable to engage a larger range mode when a memory warning occurs, as this will almost certainly cause app eviction"); + __rangeModeForMemoryWarnings = rangeMode; +} + + (void)didReceiveMemoryWarning:(NSNotification *)notification { +#if ASRangeControllerLoggingEnabled + NSLog(@"+[ASRangeController didReceiveMemoryWarning] with controllers: %@", [self allRangeControllersWeakSet]); +#endif for (ASRangeController *rangeController in [self allRangeControllersWeakSet]) { - if (rangeController.dataSource == nil) { - continue; - } - - ASInterfaceState interfaceState = [rangeController.dataSource interfaceStateForRangeController:rangeController]; - if (ASInterfaceStateIncludesDisplay(interfaceState)) { - continue; - } - - [rangeController updateCurrentRangeWithMode:ASLayoutRangeModeLowMemory]; + BOOL isDisplay = ASInterfaceStateIncludesDisplay([rangeController interfaceState]); + [rangeController updateCurrentRangeWithMode:isDisplay ? ASLayoutRangeModeMinimum : __rangeModeForMemoryWarnings]; + [rangeController performRangeUpdate]; } } + (void)didEnterBackground:(NSNotification *)notification { + for (ASRangeController *rangeController in [self allRangeControllersWeakSet]) { + // We do not want to fully collapse the Display ranges of any visible range controllers so that flashes can be avoided when + // the app is resumed. Non-visible controllers can be more aggressively culled to the LowMemory state (see definitions for documentation) + BOOL isVisible = ASInterfaceStateIncludesVisible([rangeController interfaceState]); + [rangeController updateCurrentRangeWithMode:isVisible ? ASLayoutRangeModeVisibleOnly : ASLayoutRangeModeLowMemory]; + } + + // Because -interfaceState checks __ApplicationState and always clears the "visible" bit if Backgrounded, we must set this after updating the range mode. __ApplicationState = UIApplicationStateBackground; for (ASRangeController *rangeController in [self allRangeControllersWeakSet]) { + // Trigger a range update immediately, as we may not be allowed by the system to run the update block scheduled by changing range mode. [rangeController performRangeUpdate]; } + +#if ASRangeControllerLoggingEnabled + NSLog(@"+[ASRangeController didEnterBackground] with controllers, after backgrounding: %@", [self allRangeControllersWeakSet]); +#endif } + (void)willEnterForeground:(NSNotification *)notification { __ApplicationState = UIApplicationStateActive; for (ASRangeController *rangeController in [self allRangeControllersWeakSet]) { + BOOL isVisible = ASInterfaceStateIncludesVisible([rangeController interfaceState]); + [rangeController updateCurrentRangeWithMode:isVisible ? ASLayoutRangeModeMinimum : ASLayoutRangeModeVisibleOnly]; [rangeController performRangeUpdate]; } + +#if ASRangeControllerLoggingEnabled + NSLog(@"+[ASRangeController willEnterForeground] with controllers, after foregrounding: %@", [self allRangeControllersWeakSet]); +#endif +} + +#pragma mark - Debugging + +- (NSString *)descriptionWithIndexPaths:(NSArray *)indexPaths +{ + NSMutableString *description = [NSMutableString stringWithFormat:@"%@ %@", [super description], @" allPreviousIndexPaths:\n"]; + for (NSIndexPath *indexPath in indexPaths) { + ASDisplayNode *node = [_dataSource rangeController:self nodeAtIndexPath:indexPath]; + ASInterfaceState interfaceState = node.interfaceState; + BOOL inVisible = ASInterfaceStateIncludesVisible(interfaceState); + BOOL inDisplay = ASInterfaceStateIncludesDisplay(interfaceState); + BOOL inFetchData = ASInterfaceStateIncludesFetchData(interfaceState); + [description appendFormat:@"indexPath %@, Visible: %d, Display: %d, FetchData: %d\n", indexPath, inVisible, inDisplay, inFetchData]; + } + return description; +} + +- (NSString *)description +{ + NSArray *indexPaths = [[_allPreviousIndexPaths allObjects] sortedArrayUsingSelector:@selector(compare:)]; + return [self descriptionWithIndexPaths:indexPaths]; } @end diff --git a/AsyncDisplayKit/Details/ASRangeControllerUpdateRangeProtocol+Beta.h b/AsyncDisplayKit/Details/ASRangeControllerUpdateRangeProtocol+Beta.h index 209dd761..1a36e576 100644 --- a/AsyncDisplayKit/Details/ASRangeControllerUpdateRangeProtocol+Beta.h +++ b/AsyncDisplayKit/Details/ASRangeControllerUpdateRangeProtocol+Beta.h @@ -20,6 +20,15 @@ */ - (void)updateCurrentRangeWithMode:(ASLayoutRangeMode)rangeMode; +/** + * Only ASLayoutRangeModeVisibleOnly or ASLayoutRangeModeLowMemory are recommended. Default is ASLayoutRangeModeVisibleOnly, + * because this is the only way to ensure an application will not have blank / flashing views as the user navigates back after + * a memory warning. Apps that wish to use the more effective / aggressive ASLayoutRangeModeLowMemory may need to take steps + * to mitigate this behavior, including: restoring a larger range mode to the next controller before the user navigates there, + * enabling .neverShowPlaceholders on ASCellNodes so that the navigation operation is blocked on redisplay completing, etc. + */ ++ (void)setRangeModeForMemoryWarnings:(ASLayoutRangeMode)rangeMode; + @end diff --git a/AsyncDisplayKit/Private/ASWeakSet.m b/AsyncDisplayKit/Private/ASWeakSet.m index 9ac3020e..516d0563 100644 --- a/AsyncDisplayKit/Private/ASWeakSet.m +++ b/AsyncDisplayKit/Private/ASWeakSet.m @@ -73,4 +73,9 @@ return [_mapTable countByEnumeratingWithState:state objects:buffer count:len]; } +- (NSString *)description +{ + return [[super description] stringByAppendingFormat:@" count: %lu, contents: %@", self.count, _mapTable]; +} + @end diff --git a/AsyncDisplayKit/Private/_ASPendingState.mm b/AsyncDisplayKit/Private/_ASPendingState.mm index ddb827f2..51aa345e 100644 --- a/AsyncDisplayKit/Private/_ASPendingState.mm +++ b/AsyncDisplayKit/Private/_ASPendingState.mm @@ -291,6 +291,11 @@ static UIColor *defaultTintColor = nil; - (void)setBounds:(CGRect)newBounds { + ASDisplayNodeAssert(!isnan(newBounds.size.width) && !isnan(newBounds.size.height), @"Invalid bounds %@ provided to %@", NSStringFromCGRect(newBounds), self); + if (isnan(newBounds.size.width)) + newBounds.size.width = 0.0; + if (isnan(newBounds.size.height)) + newBounds.size.height = 0.0; bounds = newBounds; _flags.setBounds = YES; } @@ -359,6 +364,11 @@ static UIColor *defaultTintColor = nil; - (void)setPosition:(CGPoint)newPosition { + ASDisplayNodeAssert(!isnan(newPosition.x) && !isnan(newPosition.y), @"Invalid position %@ provided to %@", NSStringFromCGPoint(newPosition), self); + if (isnan(newPosition.x)) + newPosition.x = 0.0; + if (isnan(newPosition.y)) + newPosition.y = 0.0; position = newPosition; _flags.setPosition = YES; }