Skip to content

Fix keepalive for large jumps on tabs and lists.#21350

Merged
dnfield merged 25 commits intoflutter:masterfrom
dnfield:i11895
Sep 11, 2018
Merged

Fix keepalive for large jumps on tabs and lists.#21350
dnfield merged 25 commits intoflutter:masterfrom
dnfield:i11895

Conversation

@dnfield
Copy link
Contributor

@dnfield dnfield commented Sep 3, 2018

Fixes #11895.

Successor to #21207

The SliverMultiBoxAdaptorElement tries to keep track of the child it should be inserting children after when it needs to do an insertion. This breaks if the child it thinks it wants doesn't know what siblings it should have because it got thrown into the keepAliveBucket at some point - the insertion logic expects children to be connected and in the proper indexing order.

This pull makes sure that if the child you want to insert after is in the keep alive bucket, it's removed and reinserted to properly get connected to it's siblings. This case happens in, for example, a multi tab view where the first tab is kept alive and you immediately go to tab 4 (but not if you go from 1 to 2). It also manifests in a sliver list where you fling and then rebuild the list.

@dnfield dnfield requested a review from Hixie September 3, 2018 08:07
@marekchen
Copy link

@dnfield Does your pr solve this issue #18832

@dnfield
Copy link
Contributor Author

dnfield commented Sep 3, 2018

@marekchen - no. The bug you're reporting there is not related to this issue.

@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors. If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again. If the bot doesn't comment, it means it doesn't think anything has changed.

@googlebot googlebot added cla: no and removed cla: yes labels Sep 4, 2018
@googlebot
Copy link

CLAs look good, thanks!

@googlebot googlebot added cla: yes and removed cla: no labels Sep 4, 2018
@dnfield
Copy link
Contributor Author

dnfield commented Sep 4, 2018

/cc @goderbauer - I'm seeing behavior occasionally where code added in #16445 is getting called after context is null (presumably because by the time the frame has been updated the widget is no longer in the tree). Added some code here to check for nulls in these cases.

This should still be ok because of the code I introduced in sliver.dart, which should correct the bad parent data a bit later in the whole process if necessary.

@dnfield dnfield requested a review from goderbauer September 4, 2018 17:30
await tester.pumpAndSettle();
await tester.pump();
expect(controller.index, 3);
expect(find.text('4'), findsOneWidget);
Copy link
Member

Choose a reason for hiding this comment

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

maybe assert at the beginning that text('4') didn't find anything to ensure that the setup is and stays correct? And assert at the end that AlwaysKeepAlive still finds something?

return new Container(
child: new Text('$i'),
);
}
Copy link
Member

Choose a reason for hiding this comment

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

Revert the whitespace-only change? Adding a comma after } would be the correct Flutter-style.

log.clear();
});

testWidgets('ListView large scroll jump and keepAlive (first child not keepAlive', (WidgetTester tester) async {
Copy link
Member

Choose a reason for hiding this comment

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

nit: missing closing )

child: new Directionality(
textDirection: TextDirection.ltr,
child: new ListView(
itemExtent: null,
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 itemExtent as null is the default?

@override
Widget build(BuildContext context) {
return new GestureDetector(
onTap: () => setState,
Copy link
Member

Choose a reason for hiding this comment

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

Why this? No state seems to be changing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Forces a rebuild.

The issue is that on rebuild, an attempt at fixing this was dropping leading widgets with a keepAlive. Rebuild here checks to make sure we're retaining those widgets.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. Probably worth adding a comment here explaining the strange usage of setstate.

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

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

LGTM

if (after != null) {
final SliverMultiBoxAdaptorParentData afterParentData = after.parentData;
final int index = afterParentData.index;
if (_keepAliveBucket.containsKey(index)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

rather than looking up the index in the map, i think you can just check _keptAlive on the child's parentData (then assert that it's in the map)

Copy link
Contributor

Choose a reason for hiding this comment

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

(while you're at it, we should probably change _createOrObtainChild to do that too)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

_createOrObtainChild gets an index rather than a child. Not sure that one makes sense to change to me. But this one does make sense.

@Hixie
Copy link
Contributor

Hixie commented Sep 7, 2018

This change seems wrong to me. Suppose you keep alive almost every child. When you try to insert a child that used to be after another child that is now itself kept alive, this code is going to insert every child back into the tree in an O(N^2) loop (each insert calls insert for every previous child, and does a loop over the children to find where to insert it).

Why are we trying to insert something after a child that is being kept alive? That seems like the real problem here. We're not supposed to ever be passing in an after to insertAndLayoutChild that isn't itself already laid out and ready.

@dnfield
Copy link
Contributor Author

dnfield commented Sep 7, 2018

Suppose you keep alive almost every child.

That's not a good idea for a sliverlist anyway - the whole point there is to be efficient with memory usage. Keeping almost every child alive won't offer you that benefit.

When you try to insert a child that used to be after another child that is now itself kept alive, this code is going to insert every child back into the tree in an O(N^2) loop (each insert calls insert for every previous child, and does a loop over the children to find where to insert it).

That is a worst case scenario, yes.

Why are we trying to insert something after a child that is being kept alive? That seems like the real problem here. We're not supposed to ever be passing in an after to insertAndLayoutChild that isn't itself already laid out and ready.

The basic problem is that the Sliver widget tries to keep track of an anchor point (_currentBeforeChild). It then tries to use that anchor point whenever someone wants to insert, so it doesn't have to go through the O(N^2) search. That all works fine until that _currentBeforeChild got moved to the KeepAlive bucket by SliverMultiBoxAdaptor (and this happens for sure when there's a flick scroll or multi-tab jump). SliverMultiBoxAdaptor has no way to tell Sliver that it messed up its cached child, and Sliver has no way to tell SliverMultiBoxAdaptor that it needs to find the siblings of its cached child.

The only way I can think of, short of the potentially costly search, is to have SliverMultiBoxAdaptor keep a Map of its RenderBoxes, which would ruin the memory savings.

The bottom line seems to be that KeepAlive is costly in Slivers, and if you use it too much you're going to incur a lot of memory or processing overhead.

@dnfield
Copy link
Contributor Author

dnfield commented Sep 7, 2018

One small enhancement might be to check if the index on the parent data is closer to _firstChild.parentData.index or _lastChild.parentData.index and potentially search backwards from the _lastChild - but it's still an expensive algorithm in the pathological case.

@dnfield
Copy link
Contributor Author

dnfield commented Sep 7, 2018

macOS failure is a Dart VM garbage collection bug - dart-lang/sdk#33111

@dnfield
Copy link
Contributor Author

dnfield commented Sep 7, 2018

After offline conversation, this is the better fix @Hixie was looking for.

  • Ensure that the _childElements map is properly traversed as a sparse list and not inflated with garbage collected children
  • Fixed test to actually ensure rebuild was actually happening with the tap. Test now fails with incorrect fix (where it was passing before).
  • @Hixie - a couple additions beyond what we discussed to correctly handle underflow and make sure we don't end up trying to concurrently iterate and modify _childElements.

@Steelsmasher
Copy link

Has this fix been implemented? Which version of flutter will I need?

@dnfield
Copy link
Contributor Author

dnfield commented Sep 10, 2018

@Steelsmasher it should be in master before much longer, and dev a little while after that.


/// Whether the widget is currently being kept alive, i.e. has [keepAlive] set
/// to true and is offscreen.
bool get keptAlive => _keptAlive;
Copy link
Contributor

Choose a reason for hiding this comment

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

normally we put the public getter above the private field and only have docs for the public getter. your call as to whether this case warrants an exception.

// build of this subtree. Wait until the end of the frame to update
// the child when the child is guaranteed to be present.
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
// The element is no longer alive
Copy link
Contributor

Choose a reason for hiding this comment

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

End comments with a period. Also since the comment is outside the if it should be a question ("Is the element still alive?"), or you can put it inside the block, or you can just remove it, the code speaks for itself here and the comment isn't really adding anything.

@Hixie
Copy link
Contributor

Hixie commented Sep 11, 2018

LGTM

@dnfield dnfield merged commit 98ad574 into flutter:master Sep 11, 2018
@dnfield dnfield deleted the i11895 branch September 17, 2018 20:44
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 9, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_debugUltimatePreviousSiblingOf(after, equals: _firstChild) is not true.

7 participants