-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New runloop queue to coalesce Interface state update calls. #788
New runloop queue to coalesce Interface state update calls. #788
Conversation
…nsaction commit. This will avoid duplicate interface state delegate calls caused by view repeatly added/removed to/from hierarchy during controller animation transition.
This reverts commit f807efa.
🚫 CI failed with log |
Thanks for posting this, Max! @maicki, @Adlai-Holler, @nguyenhuy — this is a significant enough change that it would be great to have multiple eyes on it. We are running with this internally for the past few weeks. Still, it is possible it can break some unit tests (e.g. at the app layer) where they are not very realistic in simulating the UIKit environment, where a CATransaction commit does not occur. This can be most easily fixed by disabling interfaceState coalescing in the test setup method, or for more accurate tests, by ensuring a key & visible window is used and the runloop is allowed to turn before test expectations are verified. |
Source/ASDisplayNode.mm
Outdated
@@ -2741,7 +2741,9 @@ - (void)setHierarchyState:(ASHierarchyState)newState | |||
// Entered or exited range managed state. | |||
if ((newState & ASHierarchyStateRangeManaged) != (oldState & ASHierarchyStateRangeManaged)) { | |||
if (newState & ASHierarchyStateRangeManaged) { | |||
[self enterInterfaceState:self.supernode.interfaceState]; | |||
if (self.supernode) { | |||
[self enterInterfaceState:self.supernode->_pendingInterfaceState]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wsdwsd0829 In order to do this, we would need to grab the instanceLock of the supernode, which shouldn't be done manually.
To correctly fix this case, we'll need to add an accessor method for - (ASInterfaceState)pendingInterfaceState which uses a MutexLocker and returns the _pendingInterfaceState.
This method can be declared in ASDisplayNode+FrameworkPrivate.h, so it can be used by various internal subclasses without being made public right away (we may eventually want to make this public, but not until there is a use case — I'm optimistic that there will never be a need to do this).
}; | ||
|
||
if ([[ASCATransactionQueue sharedQueue] disabled]) { | ||
dispatch_async(dispatch_get_main_queue(), exitVisibleInterfaceState); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For reviewers: Note that this change intentionally alters the behavior of the -disabled state as well. After the change, the -didExitVisibleState call is held for a dispatch_async to confirm the node is still invisible, for both range-managed and not range-managed nodes (previously there was a check distinguishing these).
This will decrease the accuracy of the call slightly, compared to before the change and also compared to interfaceState coalescing which guarantees the call is done on the same runloop / implicit transaction.
However it will also address a very common issue where visibility calls thrash to invisible and visible twice during the UIViewController transition process, and this feels like a better / safer behavior to prefer rather than the utmost accuracy of the invisibility call.
The visibility call remains un-delayed, and that one is much more important. Gating the invisibility is sufficient to avoid the thrashing since the visibility call won't re-fire if the interfaceState remains visible.
🚫 CI failed with log |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We've been waiting for a feature like this for a long time. I love that it's battle-tested and it brings clear benefits.
There is a lot of duplicated code in our runloop queue classes now. We should work harder to elevate common behavior into the superclass, but I won't block this diff for that.
_pendingInterfaceState = newState; | ||
[[ASCATransactionQueue sharedQueue] enqueue:self]; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the lock at the top of this method. It isn't needed and applyPendingInterfaceState
asserts that we aren't locked. (_unlocked_applyPendingInterfaceState:
?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is a mis-merge, thanks for spotting that.
|
||
#if ASRunLoopQueueLoggingEnabled | ||
NSTimer *_runloopQueueLoggingTimer; | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we not move the duplicated ivars up into ASAbstractRunLoopQueue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on _internalQueue
doesn't apply to this class; we always use it in strong mode.
Source/ASRunLoopQueue.mm
Outdated
if (CFRunLoopContainsSource(_runLoop, _runLoopSource, kCFRunLoopCommonModes)) { | ||
CFRunLoopRemoveSource(_runLoop, _runLoopSource, kCFRunLoopCommonModes); | ||
} | ||
CFRelease(_runLoopSource); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can unconditionally remove the source – documentation says that if the source isn't present the function does nothing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Source/ASRunLoopQueue.h
Outdated
* to get last chance of updating/coalesce info like interface state. | ||
* Each node will only be called once per transaction commit to reflect interface change. | ||
*/ | ||
+ (ASCATransactionQueue *)sharedQueue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declare this as an atomic readonly class property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Source/ASRunLoopQueue.h
Outdated
@interface ASCATransactionQueue : ASAbstractRunLoopQueue | ||
|
||
@property (nonatomic, readonly) BOOL isEmpty; | ||
@property (nonatomic, readonly) BOOL disabled; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These properties need to be atomic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess ASRunloopQueue's isEmpty needs to be atomic also.
|
||
// CoreAnimation commit order is 2000000, the goal of this is to process shortly beforehand | ||
// but after most other scheduled work on the runloop has processed. | ||
static int const kASASCATransactionQueueOrder = 1000000; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can always update this later, since the current version of the diff brings obvious benefits and has been productionized, but in an ideal world would we not want to do this work at the end of the layout phase of the commit?
For example, collection views will not update their visible cells until the layout pass, so we'd have to wait for the next go-round for the interface state to be updated to reflect the truth. For a ridiculous case, imagine I have a cell node that is red whenever it's invisible and blue whenever it's visible. The current implementation would show red for 1 frame and then jump to blue.
One weird but viable option is for us to do something like this in the observer:
while (true) {
// Layout visible windows (CA is about to do this anyway)
for (window in app.windows) {
if (!window.hidden) {
[window layoutIfNeeded];
}
}
// If we have no work to do, break.
if ([self isEmpty]) {
break;
}
// Otherwise notify observers and loop again.
[self processQueue];
}
That way any layout invalidations that happen due to interface states, and any interface state changes due to layout, are all settled. We would also use a loop counter to limit the maximum recursive dirtying (just like CA).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this suggest trying to solve the case when user trying to adjust layout in interfaceState change methods (e.g. didEnterVisible), so that we can do all work in one runloop instead of delaying layout work to next loop?
May be some comment in api will let the user to call layoutIfNeed on their changes? My concern is that if we are doing to much work here, it may cause frame drop and user cannot do much about it.
Source/ASRunLoopQueue.mm
Outdated
return; | ||
} | ||
|
||
if (_disableInterfaceStateCoalesce == YES) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: remove == YES
, I know it's used in other places but we can stop the madness.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Source/ASRunLoopQueue.h
Outdated
/** | ||
* @abstract Apply a node's interfaceState immediately rather than adding to the queue. | ||
*/ | ||
- (void)disableInterfaceStateCoalesce; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would just call this method -disable
, since this queue isn't supposed to know anything about interface states per-se.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Thanks for the feedback @Adlai-Holler ! BTW @nguyenhuy, regarding the commit message in ea54727 -- something in the direction of this change could help. However the trouble is that there are two use cases, one that may require layout to be complete, and the other which doesn't:
One thing I wonder is whether the impression logging case could be changed to read .calculatedLayout instead. If not, we may need two calls: willEnterVisibleState and didEnterVisibleState (but that would be a breaking semantic change), or didEnterVisibleState[...with layout complete]. |
Thanks for reviewing. @Adlai-Holler |
🚫 CI failed with log |
@wsdwsd0829 Thanks for the diff. Very nice work! I have to agree with @Adlai-Holler that it'd be much better to fire off visibility events after the layout phase of the CA transaction. I have first-hand experience dealing with Pinterest problems, and this is the best approach I can think of to fix them once and for all. I think the most important agreement we need to make now is that our definition of "visible" should be consistent with CA's, that is "after the layout pass". And with that, I do understand the use case for handling an expensive child upon visibility. It wouldn't be terrible to add another event type, say "pre visible" or "will be visible", and provide a new API that will be called before the layout pass for any changes that should be performed in response to such event. Having said that, it seems to me that such API will add complexity without much benefits. If an expensive child needs to be prepared on main, it will cause frame drops regardless of whether the preparation happens before or after the layout phase. Perhaps, users should minimize the work on main and/or split it up across multiple interface states (preload and render). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scott persuaded me that since the current diff is production-tested we ought to land it and then make a separate experiment to improve the visibility notification on top of it.
Lets land this and enjoy the reduced thrash, then go from there. ✅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👌
…ed and before next runloop started.
🚫 CI failed with log |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM - Seems like CI has some problems running the tests from time to time, I restarted it and they should succeed in a bit and than ready to land.
🚫 CI failed with log |
🚫 CI failed with log |
Hi, I made some final changes by applying interface immediately for any interface updates during processing of queue. Please talk another look at and then we should ready to ship. |
🚫 CI failed with log |
🚫 CI failed with log |
Source/ASRunLoopQueue.mm
Outdated
// Inserting a NULL here ensures the compaction will take place. | ||
// See http://www.openradar.me/15396578 and https://stackoverflow.com/a/40274426/1136669 | ||
[_internalQueue addPointer:NULL]; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the queue only holds strong references, this trick shouldn't be necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cesteban awesome catch, thanks for reviewing! @wsdwsd0829 this would be a good change to make, since we expect this queue to always be strong (otherwise the node could dealloc before getting the invisible state) - so I think we can just delete these few lines.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, fixed.
Generated by 🚫 Danger |
Actually that danger message is in error. Landing! |
…roup#788) * fix SIMULATE_WEB_RESPONSE not imported TextureGroup#449 * Coalesce interface state updates to ASCATransactionQueue before CATransaction commit. This will avoid duplicate interface state delegate calls caused by view repeatly added/removed to/from hierarchy during controller animation transition. * fix tests for new run loop queue * Support for disabling ASCATransactionQueue * Fix didExitHierarchy to use ASCATransactionQueue. * merge range managed and none range managed for didExitHierarchy * Revert "merge range managed and none range managed for didExitHierarchy" This reverts commit f807efa. * merge range managed and none range managed for didExitHierarchy * remove metadata * abstract queue to impl class methods * Add tests * Fix test fail because of shared object. * guard _pendingInterfaceState access with lock * name refactor * Refactor from comments https://github.com/TextureGroup/Texture/pull/788/\#pullrequestreview-94849919 * Apply InterfaceState immediately after ASCATranactionQueue is processed and before next runloop started. * refactor * no op to start CI build * remove unused var and kick off tests * change lisence * remove code for weak ref * add change log and adjust license
Coalesce Interface state update calls.
This PR introduce a new run loop queue that will execute before CATransaction commit.
Each node will only be called once per transaction commit to reflect interface change.
This will help reducing calls to update to interfaceState like didEnterPreload, didEnterVisible during controller transitions like push and help apps that use these api to accurately log view visible events.
For example:
Before changes, ASCollectionView example will call didEnterVisibleState 47 times when “Push Another Copy” clicked, while only 27 times with new queue.