Skip to content
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

Merged
merged 28 commits into from
Feb 13, 2018

Conversation

wsdwsd0829
Copy link
Contributor

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.

@ghost
Copy link

ghost commented Feb 5, 2018

🚫 CI failed with log

@appleguy
Copy link
Member

appleguy commented Feb 5, 2018

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.

@@ -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];
Copy link
Member

@appleguy appleguy Feb 5, 2018

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);
Copy link
Member

@appleguy appleguy Feb 5, 2018

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.

@wsdwsd0829 wsdwsd0829 mentioned this pull request Feb 5, 2018
@ghost
Copy link

ghost commented Feb 6, 2018

🚫 CI failed with log

Copy link
Member

@Adlai-Holler Adlai-Holler left a 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];
}
}
Copy link
Member

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:?)

Copy link
Contributor Author

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
Copy link
Member

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?

Copy link
Member

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.

if (CFRunLoopContainsSource(_runLoop, _runLoopSource, kCFRunLoopCommonModes)) {
CFRunLoopRemoveSource(_runLoop, _runLoopSource, kCFRunLoopCommonModes);
}
CFRelease(_runLoopSource);
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* 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;
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@interface ASCATransactionQueue : ASAbstractRunLoopQueue

@property (nonatomic, readonly) BOOL isEmpty;
@property (nonatomic, readonly) BOOL disabled;
Copy link
Member

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.

Copy link
Contributor Author

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;
Copy link
Member

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).

Copy link
Contributor Author

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.

return;
}

if (_disableInterfaceStateCoalesce == YES) {
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

/**
* @abstract Apply a node's interfaceState immediately rather than adding to the queue.
*/
- (void)disableInterfaceStateCoalesce;
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@appleguy
Copy link
Member

appleguy commented Feb 8, 2018

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:

  • Normal use of the visibility call to create an expensive child and add it, catching the layout pass (before layout).
  • The impression logging case where it needs to look at layout information.

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].

@wsdwsd0829
Copy link
Contributor Author

Thanks for reviewing. @Adlai-Holler
I changed most suggests. I am thinking to do a new pr on extracting common behavior to AbstractQueue because it may need to take a closer look. The two queues are different in that the ASRunloopQueue is call back/block based while ASCATransaction queue is delegate based.
Also a test fail in CI should be resolved here #790.

@ghost
Copy link

ghost commented Feb 8, 2018

🚫 CI failed with log

@nguyenhuy
Copy link
Member

nguyenhuy commented Feb 8, 2018

@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, didEnterVisibleState should be called afterward.

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).

Copy link
Member

@Adlai-Holler Adlai-Holler left a 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. ✅

Copy link
Member

@nguyenhuy nguyenhuy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

@ghost
Copy link

ghost commented Feb 9, 2018

🚫 CI failed with log

Copy link
Contributor

@maicki maicki left a 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.

@ghost
Copy link

ghost commented Feb 9, 2018

🚫 CI failed with log

@ghost
Copy link

ghost commented Feb 9, 2018

🚫 CI failed with log

@wsdwsd0829
Copy link
Contributor Author

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.
thanks.

@ghost
Copy link

ghost commented Feb 10, 2018

🚫 CI failed with log

@ghost
Copy link

ghost commented Feb 11, 2018

🚫 CI failed with log

// 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];
}
Copy link
Contributor

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.

Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, fixed.

@ghost
Copy link

ghost commented Feb 13, 2018

2 Warnings
⚠️ This is a big PR, please consider splitting it up to ease code review.
⚠️ Please ensure license is correct for ASRunLoopQueueTests.m:

//
//  ASRunLoopQueueTests.m
//  Texture
//
//  Copyright (c) 2014-present, Facebook, Inc.  All rights reserved.
//  This source code is licensed under the BSD-style license found in the
//  LICENSE file in the /ASDK-Licenses directory of this source tree. An additional
//  grant of patent rights can be found in the PATENTS file in the same directory.
//
//  Modifications to this file made after 4/13/2017 are: Copyright (c) through the present,
//  Pinterest, Inc.  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//

    

Generated by 🚫 Danger

@Adlai-Holler
Copy link
Member

Adlai-Holler commented Feb 13, 2018

@wsdwsd0829 One more thing is just to update the license header. It should skip CI and we can land today.

Actually that danger message is in error. Landing!

@TextureGroup TextureGroup deleted a comment Feb 13, 2018
@Adlai-Holler Adlai-Holler merged commit 2618c50 into TextureGroup:master Feb 13, 2018
garrettmoon added a commit that referenced this pull request Mar 14, 2018
Adlai-Holler pushed a commit that referenced this pull request Mar 28, 2018
bernieperez pushed a commit to AtomTickets/Texture that referenced this pull request Apr 25, 2018
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants