[ASRangeController] Improvements to application state change and memory warning handling.

Introduces ASLayoutRangeModeVisibleOnly, allowing the preservation of decoded backing stores without any extra padding to
strictly minimize memory usage while supporting immediate re-display of content.  Set visible range controllers to this mode
upon app suspend / memory warning, while more aggressively clearing others to the ASLayoutRangeModeLowMemory mode.

By default, when the app is running and recieves a memory warning, we set the range mode for non-visible controllers to
ASLayoutRangeModeVisibleOnly.  This is because, unlike in the app suspend case where on app resume we can restore controllers
from LowMemory to VisibleOnly, the memory warning doesn't provide a good opportunity to do this.

A new +Beta API to control this behavior is called +setRangeModeForMemoryWarnings:, as some apps may prefer to use LowMemory
in the memory warning scenario.  For these apps, optimal user experience will require manually setting the range mode back
to some larger value as the user navigates the app, or they will encounter controllers that are temporarily blank and need
a moment to re-display their contents as they start to become visible.
This commit is contained in:
Scott Goodson
2016-03-09 19:36:45 -08:00
parent 27b8bcc3c6
commit 21bebb9d29
11 changed files with 185 additions and 87 deletions

View File

@@ -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 */

View File

@@ -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

View File

@@ -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.
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -13,8 +13,7 @@
#import <AsyncDisplayKit/ASLayoutController.h>
#import <AsyncDisplayKit/ASLayoutRangeType.h>
#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.)
*

View File

@@ -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<ASRangeControllerDataSource>)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<NSIndexPath *> *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<NSIndexPath *> *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<NSIndexPath *> *)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<NSIndexPath *> *indexPaths = [[_allPreviousIndexPaths allObjects] sortedArrayUsingSelector:@selector(compare:)];
return [self descriptionWithIndexPaths:indexPaths];
}
@end

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}