-
Notifications
You must be signed in to change notification settings - Fork 29.2k
Refactor the RenderTable class #69670
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
Conversation
@@ -595,7 +595,7 @@ class RenderTable extends RenderBox { | |||
/// children are simply moved to their new location in the table rather than | |||
/// removed from the table and re-added. | |||
void setFlatChildren(int columns, List<RenderBox?> cells) { | |||
if (cells == _children && columns == _columns) | |||
if (listEquals(cells, _children) && columns == _columns) |
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.
This is an optimization of duplicate check logic
The List
operator ==
only equal to themselves.
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.
Have you verified that this is actually faster? It trades a pointer comparison for an O(N) comparison during updates to avoid an O(N) cost in layout, but it's not clear to me how common it would be for the child list to be a new instance but holding the same contents.
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.
Currently, the caller of setFlatChildren
always generate a new List instant so this is a dead code never reachable.
Next, let's analyze different scenarios:
- Update
TableRow
widgets without changing its Type, so thecells
parameter will have the same renderObjects and Will achieve optimized results. - Change the
TableRow
widgets count will have no effect becauselistEquals
will compares length first. - Update ``TableRow` widgets with a different renderObjectWidget and keep the same count,like you said will increase the time complexity.
In summary, I believe that the benefits of such a modification are greater than the costs, Of course, also need to listen to your suggestions :)
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 we always call with a new list, then this is converting an O(1) equality test with an O(N) equality test, which is slower, in order to avoid the code below, which is O(N) (but when avoided becomes O(1), so faster). This seems like it should be better... but my question is, is it?
The way to find out would be to benchmark it.
@Hixie hi, |
cc @goderbauer who is more familiar with IndexedSlot. |
@goderbauer Hi, Would you like to take a look at this patch? Thanks very much :) |
} | ||
|
||
@override | ||
void insertRenderObjectChild(RenderObject child, dynamic slot) { | ||
void insertRenderObjectChild(RenderObject child, IndexedSlot<Element?> slot) { |
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.
It appears that you don't really need an IndexedSlot here. Wouldn't it be more convenient to just define a _TableSlot with an x and y property representing the row/column of the child?
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 we do that, I am wondering whether we still need the TableCellParentData, which contains the same information and appears to only be used in removeRenderObjectChild)
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.
In this bug scenario, we must use IndexedSlot, we need the position info to decide where to insert the new RO.
@@ -292,21 +292,34 @@ class _TableElement extends RenderObjectElement { | |||
@override |
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 line 287 about slots not being used seems outdated?
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.
Removed.
? (widget.children[0].children != null ? widget.children[0].children!.length : 0) | ||
: 0; | ||
final int _index = slot.index; | ||
renderObject.setChild(_index % _columns, _index ~/ _columns, child as RenderBox); | ||
} | ||
|
||
@override |
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.
Now that slots have meaning, do we need an implementation for moveRenderObjectChild?
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.
Nice catch, Under your reminder, I found some bugs, the current implementation is a bit strange, I need to continue working on this.
Currently, the |
It seems that we need to refactor @goderbauer @Hixie Do you guys have some thoughts to resolve this issue? |
The root cause of this issue in one sentence is that when only the subtree below |
This update has made a big change to |
Please ignore the test case I temporarily commented out |
@@ -376,87 +376,34 @@ class RenderTable extends RenderBox { | |||
ImageConfiguration configuration = ImageConfiguration.empty, | |||
TableCellVerticalAlignment defaultVerticalAlignment = TableCellVerticalAlignment.top, | |||
TextBaseline? textBaseline, | |||
List<List<RenderBox>>? children, |
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.
Why remove children here? Most RenderObjects with children have an option to specify them in the constructor.
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.
Also: update the doc comment above.
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.
comes back again.
int get columns => _columns; | ||
int _columns; | ||
set columns(int value) { |
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.
Why's there no setter for columns/rows anymore? Since it is a constructor argument, you should be able to change it after the object is constructed.
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 be changed by setDimensions
if (_children[xyOld] != null && (x >= columns || xyNew >= cells.length || _children[xyOld] != cells[xyNew])) | ||
lostChildren.add(_children[xyOld]!); | ||
} | ||
/// If `after` is null, then this inserts the child at the start of the list. |
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.
This makes it sound as if there's only one dimension to the children. However, it's not a linear list.
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.
Maybe expand the comment to describe what the model for the children is here?
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.
Or can we change this to an API that's more explicit about the actual child model, maybe something like:
void insertChild(RenderBox child, {required int row, required int column});
Since the element has access to the index of the child, could it use that to calculate what column and row the child needs to be in?
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.
Updated the comment below.
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.
Now, _children
is variable length, use row
or column
is not suitable.
_rows = 0; | ||
cells.forEach(addRow); | ||
/// Update the number of row and column of this table. | ||
void updateInfo(int rows, int columns) { |
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: this name is very generic.
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.
Maybe call this setDimensions? And move it closer to the column and row getter? Also update the docs of those getters to link to this method.
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.
Also document that all the children need to be inserted first before calling this.
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.
} | ||
|
||
/// Adds a row to the end of the table. | ||
/// Move the given `child` in the child list to be after another child. |
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.
Same comment as on insertChild.
for (final TableRow row in newWidget.children) { | ||
List<Element> oldChildren; | ||
if (row.key != null && oldKeyedRows.containsKey(row.key)) { | ||
oldChildren = oldKeyedRows[row.key]!; | ||
taken.add(oldChildren); | ||
} else if (row.key == null && oldUnkeyedRows.moveNext()) { | ||
} else if (oldUnkeyedRows.moveNext()) { | ||
oldChildren = oldUnkeyedRows.current.children; |
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.
maybe turn the removed row.key == null
condition into an assert here.
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.
It's possible that add a new keyed row here,
It’s a bit difficult to handle here because we have to ensure that one-time update of children.
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp(
home: Center(
child: SizedBox(
width: 100,
// height: 100,
child: W(),
),
),
),
);
}
class W extends StatefulWidget {
const W({Key key}) : super(key: key);
@override
_State createState() => _State();
}
class _State extends State<W> {
void _onTap() {
setState(() => _toggle = !_toggle);
}
List<TableRow> _getRows() {
if (_toggle) {
return [
TableRow(key: Key('1'), children: [Text('ke1')]),
TableRow(children: [Text('unkey')]),
TableRow(key: Key('2'), children: [Text('ke2')]),
];
} else {
return [
TableRow(key: Key('2'), children: [Text('ke2')]),
// TableRow(key: Key('1'), children: [Text('ke1')]),
TableRow(children: [Text('unkey')]),
TableRow(key: Key('3'), children: [Text('ke3')]),
];
}
}
bool _toggle = true;
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(onTap: _onTap, child: Text('Tap me')),
Table(
children: _getRows(),
)
],
);
}
}
For the code above, I think the old key1
should be dropChild
, and then the new key3
should be adoptChild
.
But our current processing is to reuse the old RO and I think there will be no problem with this processing(the real RO(Text) can be reused), what do you think?
Probably the TableRow should not have a local key I think.
} | ||
while (oldUnkeyedRows.moveNext()) | ||
allOldChildren.addAll(oldUnkeyedRows.current.children); | ||
oldKeyedRows.values.where((List<Element> list) => !taken.contains(list)).forEach(allOldChildren.addAll); |
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 probably do this smarter since we know what has been taken. Maybe above in line 412/413 instead of adding them to the extra "taken" list just remove them from oldKeyedRows, so in the end oldKeyedRows contains everything that's not taken?
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
assert(columns * rows == updatedChildren.length); | ||
|
||
int rowIndex = 0; | ||
for (final TableRow row in newWidget.children) { |
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.
since row isn't used in this for loop maybe just fo for (int row = 0; row < rows; row++)
?
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
lostChildren.add(_children[xyOld]!); | ||
} | ||
/// If `after` is null, then this inserts the child at the start of the list. | ||
void insertChild(RenderBox? child, { RenderBox? after }) { |
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.
Why do we allow child to be null? What does it mean here to insert a null-child?
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 unit test before this change inserts a lot of null-child into _children
, Which is not allowed now.
table.insertChild(null); | ||
table.insertChild(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.
This is odd, what does this mean?
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.
(here and elsewhere)
RenderTable table; | ||
layout(RenderPositionedBox(child: table = RenderTable( | ||
columns: 5, | ||
rows: 5, |
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.
No longer allow the existence of null RO for _children
@@ -121,52 +132,225 @@ void main() { | |||
' │ size: Size(100.0, 10.0)\n' | |||
' │ additionalConstraints: BoxConstraints(w=30.0, h=10.0)\n' | |||
' │\n' | |||
' ├─child (3, 0) is null\n' |
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.
No longer allow the existence of null RO for _children
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.
isn't that a problem?
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.
Refer to #69670 (comment)
@goderbauer Hi, please review this if you have time, thanks. |
It has been more than a month since the last review, try ping again, in case someone receives the message and just has time! |
Sorry about the delay. This is a complicated PR and reviewing it will take time, so we've all been delaying until we have the time. I understand that this can be frustrating. |
@@ -348,7 +348,7 @@ void main() { | |||
expect(boxA2, isNotNull); | |||
expect(boxG2, isNotNull); | |||
expect(boxA1, equals(boxA2)); | |||
expect(boxG1, isNot(equals(boxG2))); | |||
expect(boxG1, equals(equals(boxG2))); |
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.
this seems concerning, there shouldn't be any logic changes right?
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.
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.
aah, i understand now. this is literally the change this PR is about. Sorry I misunderstood the test before.
Those APIs are intended for people who directly use the RenderTable API without going through widgets. |
If the goal is just to make RenderTable handle changes of child render objects correctly, I wouldn't expect so many changes on the tests, just new tests that check the behavior that is fixed. Can you elaborate on what the changes are about? |
Okay, no problem. I will organize the language and will reply to you ASAP. |
@@ -348,39 +348,44 @@ class _TableElement extends RenderObjectElement { | |||
@override | |||
RenderTable get renderObject => super.renderObject as RenderTable; | |||
|
|||
// This class ignores the child's slot entirely. | |||
// Instead of doing incremental updates to the child list, it replaces the entire list each frame. | |||
|
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.
@Hixie
It’s weird to deal with it this way. We should not recreate(drop and new create) the cell elements because their keys and types have not changed, right?
For example, We should not rebuild element and RO in the following situation,which you concerning
https://github.com/flutter/flutter/pull/69670/files/60b124b9a86717f4e0aeaf203e23c8d0c1e3944e#r565894797
before rebuild: [Text('A'), Text('B'), Text('C'), Text('D'), Text('E'), ]
with (column:3, row:2)
after update: [Text('a'), Text('b'), Text('c'), Text('d'), Text('e'), ]
with (column:2, row:3)
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.
Ignore the child slot is the root cause of the related issue.
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 agree that it would be better to not replace the elements in that case
}).toList(growable: false), | ||
); | ||
}).toList(growable: false); | ||
_updateRenderObjectChildren(); |
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.
After the slot is introduced, we can't add RO children here uniformly, we can only add children to RO through void insertRenderObjectChild
.
This is exactly why we need to refactor the render. I don't want to modify it on a large scale, but we have to. RenderTable
is really different from other similar multi-child RO implementations. I don't know if the author at the time had a special purpose. My modification is to make its implementation not so special, and then the issue is solved naturally.
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.
@Hixie Hi, |
Also needs @goderbauer 's thoughts :) |
allOldChildren.addAll(oldUnkeyedRows.current.children); | ||
oldKeyedRows.values.forEach(allOldChildren.addAll); | ||
|
||
final List<Element> updatedChildren = updateChildren(allOldChildren, allNewChildren, forgottenChildren: _forgottenChildren); |
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 we want to use the slot index, we must update all the children (call updateChildren()
) one-time, otherwise, the calculation of the slot is wrong.
This is why make the change here.
I don't know when I'll have the time to study this carefully enough to review it, sorry. I really need to just walk through the code locally to figure out what's going on, and that will need more time than I have available at the moment. (If @goderbauer has the time and reviews this PR then I'm happy to defer to his expertise, don't feel you must get my review.) I'll keep this tab open though, in case I have some time available. |
(maybe we can trade reviews, though, on our new #review-trading-floor channel!) |
I'll post this on the channel after I finish review someone else's pr. It's a little embarrassing to keep making requests. |
if (children != null) { | ||
final List<RenderBox> allChildren = <RenderBox>[]; | ||
for (final List<RenderBox> rows in children) | ||
rows.forEach(allChildren.add); |
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 do allChildren.addAll(rows)
?
children?.forEach(addRow); | ||
if (children != null) { | ||
final List<RenderBox> allChildren = <RenderBox>[]; | ||
for (final List<RenderBox> rows in children) |
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.
rows
makes it sound like each entry in children
is multiple rows, but if i understand correctly, they are each a single row, so row
would be more appropriate
/// | ||
/// Changing the number of columns is an expensive operation because the table | ||
/// needs to rearrange its internal representation. | ||
/// This can be updated by [setDimensions] after updating children is finish. |
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.
not sure what "is finish" means here
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.
(repeated other times below)
assert(value >= 0); | ||
if (value == rows) | ||
|
||
/// Update the number of row and column of this table. |
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.
number of rows and columns of...
_rows = value; | ||
_children.length = columns * rows; | ||
_columns = columns; | ||
_rows = rows; | ||
markNeedsLayout(); |
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.
this doesn't seem to handle _children being too long compared to the number of rows and columns any more
/// If the new cells contain any existing children of the table, those | ||
/// children are simply moved to their new location in the table rather than | ||
/// removed from the table and re-added. | ||
void setFlatChildren(int columns, List<RenderBox?> cells) { |
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.
there's a chance that this and some of the other API changes will break someone. I would feel much more comfortable if the API was backwards compatible.
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 older API (setFlatChildren, addRow, setChild at x,y) is also potentially much more efficient than the new API
markNeedsLayout(); | ||
return; | ||
/// If `after` is null, then this inserts the child at the start of the list. | ||
/// Children are stored in row-major order, you should call [setDimensions] |
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.
avoid "you" in documentation; see https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#use-the-passive-voice-recommend-do-not-require
/// If `after` is null, then this inserts the child at the start of the list. | ||
/// Children are stored in row-major order, you should call [setDimensions] | ||
/// after updating children is finish to set the table dimensions. | ||
void insertChild(RenderBox child, { RenderBox? after }) { |
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.
it would be good to say in the documentation here that using the after
argument is extremely expensive (potentially O(N^2) depending on how List is implemented, and potentially O(N^3) if used in a loop, which seems like a common usage).
Unless there's a really strong reason for having after
(I haven't checked the rest of the PR yet) it might be better to just not have that argument available.
for (final List<RenderBox> rows in children) | ||
rows.forEach(allChildren.add); | ||
|
||
allChildren.reversed.forEach(insertChild); |
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.
given that all that this does is insert(0) into _children, why not just add all the children to _children directly instead of adding to a local variable and then reversing the list (expensive) and then inserting all the children into the other list (expensive)?
adoptChild(cell); | ||
/// More efficient than removing and re-adding the child. Requires the child | ||
/// to already be in the child list at some position. Pass null for `after` to | ||
/// move the child to the start of the child list. |
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.
it's more efficient, but it's still really expensive (potentially O(N) for the remove, O(N) for the insert, O(N) for the contains assert, O(N) for the indexOf...)
I'm not really convinced this is the right approach here. I agree with the goal of fixing the bug described in the original PR description. But I think actually the test where I think the right solution here is for the Table to use slots with X/Y coordinates so that insertRenderObjectChild can correctly place the new child in the right place, without needing to change anything in the render object. |
re-work at #76375 |
Description
This is the minimal reproducible sample
After Tapping
Tap me
, the new RenderObject ofSizeBox
was not adopted by theRenderTable
How does it happen?
_State.context
is marked dirty.GestureDetector
was replaced by a widget of a different type,_State.context.updateChild
method calls_State.context.deactivateChild
._TableElement
to detach it from_TableElement.renderObject
, calling_TableElement.removeChildRenderObject
.renderObject.setChild(childParentData.x!, childParentData.y!, null)
.Text
asks_TableElement
to attach it to_TableElement.renderObject
, calling_TableElement.insertRenderObjectChild
, but this method do not let_TableElement.renderObject
adopt it._TableElement.mount
and_TableElement.update
, therefore, in the above scenario, the new RO has no chance to be adopted.My solution
_TableElement.insertRenderObjectChild
._TableElement
children withIndexedSlot
so that we know where to insert when we adopt a child in_TableElement.insertRenderObjectChild
.Related Issues
Fixes #69395
Tests
I added the following tests:
See files.