iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 13
0
Mobile Development

Why Flutter why? 從表層到底層,從如何到為何。系列 第 13

days[12] = "key是如何影響updateChildren的?"

updateChildren將會是我們目前看過最複雜的函數,不論是國內外都找不到深入剖析它的文件。所以趕快來成為全世界少數真正瞭解它的運作方式的人吧!誰知道呢?搞不好你還能想出更好更簡潔的演算法,就可以去發PR了。


首先來看看我們今天的範例APP:

class ColorTile extends StatefulWidget {
  ColorTile({Key key}) : super(key: key);

  @override
  _ColorTileState createState() => _ColorTileState();
}

class _ColorTileState extends State<ColorTile> {
  Color color = Color(0xFF000000 + Random().nextInt(1 << 24));

  @override
  Widget build(BuildContext context) {
    return Container(color: color, height: 80);
  }
}

我們先建立一個ColorTile類別,這是一個StatefulWidget,裡面有一個隨機產生的State color,再用一個Container把color顯示出來。


void main() {
  runApp(App());
}

class App extends StatefulWidget {
  @override
  _AppState createState() => _AppState();
}

class _AppState extends State<App> {
  final tiles = [
    ColorTile(),
    ColorTile(),
    ColorTile(),
    ColorTile(),
    ColorTile(),
    ColorTile(),
    ColorTile(),
  ];

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(children: tiles),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.arrow_forward),
          onPressed: () {
            setState(() {
              final tile4 = tiles.removeAt(4);
              final tile2 = tiles.removeAt(2);
              tiles.insert(2, tile4);
              tiles.insert(4, tile2);
            });
          },
        ),
      ),
    );
  }
}

這裡我們用Column來顯示7個隨機顏色的ColorTile,當我們按下FloatingActionButton時把ColorTile2和ColorTile4交換。不過如果我們直接拿這段程式碼去跑的話,會發現怎麼按都沒有反應:


如果你有看上一篇,或官方介紹影片的話,聰明的你應該已經知道這是因為我們沒有設key的關係了。於是我們給ColorTile2,4加上UniqueKey:

  final tiles = [
    ColorTile(),
    ColorTile(),
    ColorTile(key: UniqueKey()),
    ColorTile(),
    ColorTile(key: UniqueKey()),
    ColorTile(),
    ColorTile(),
  ];

再來跑跑看吧:

這次我們發現ColorTile2,4順利交換了。可是等一下,為什麼中間那個ColorTile3顏色一直變?如果說是key造成的,但0,1,5,6同樣沒有key,為什麼只有3在變?


要解釋為什麼會發生這個奇怪的現象,我們終究還是必須瞭解updateChildren究竟是怎麼運作的。如果你這次也想在source code下中斷點,用debug mode逐步執行看看的話,可以這樣做:
https://ithelp.ithome.com.tw/upload/images/20200913/20129053tVdP5Vw4cb.png

  1. 我們在updateChildren裡下中斷點。
  2. 設定中斷點的條件,讓它只在children是ColorTile的時候停住。
  3. 停住的時候,MultiChildRenderObjectElement就是我們的Column,它呼叫update時就會連帶呼叫到RenderObjectElement.updateChildren了。
  4. 這裡可以看到我們設定的三把UniqueKey

然後讓我們來看看updateChildren函數本身的註解:

  /// Updates the children of this element to use new widgets.
  ///
  /// Attempts to update the given old children list using the given new
  /// widgets, removing obsolete elements and introducing new ones as necessary,
  /// and then returns the new child list.

整個函數的目的是,從Parent Element舊有的children(List<Element>),根據新進的Widgets(List<Widget>),產生出新的children(List<Element>)。首先我們當然希望盡可能重複使用原有的Element。如果Widgets有所增減了,Elements就要跟著增減。如果Widgets有key,Element就要根據key正確地改變順序並連接Widget。如何以超高的效率做到這些事,就是這個函數複雜的地方。


接下來我們稍微瞄一眼函數內部長長的註解,這裡不用詳細看,因為我懷疑它跟實際的程式碼有些出入。我們只要知道先知道整個updateChildren分成六大步驟,每個步驟都是一個迴圈就好了:
https://ithelp.ithome.com.tw/upload/images/20200913/20129053tcMhiosrDs.png
注意,註解裡的sync(同步)指的是執行updateChild,matching(匹配)指的是判斷canUpdate==true,如果忘記這兩個函數在做什麼的話,可以複習一下之前的篇章再繼續。我們接下來也會以"同步"和"匹配"來代表這兩個動作。


好來看實際的程式碼吧!首先是一些初始化:

int newChildrenTop = 0;
int oldChildrenTop = 0;
int newChildrenBottom = newWidgets.length - 1;
int oldChildrenBottom = oldChildren.length - 1;

final List<Element> newChildren = oldChildren.length == newWidgets.length ?
    oldChildren : List<Element>(newWidgets.length);

這裡有四個指針分別代表新/舊Element List的上/下指針。然後如果newWidgets和oldChildren長度一樣,直接用oldChildren作為newChildren,否則以newWidgets的長度建立一個Element List作為newChildren。
以我們的範例APP而言,會初始化成這樣:

newChildrenTop == 0
oldChildrenTop == 0
newChildrenBottom == 6
oldChildrenBottom == 6

newChildren == oldChildren

然後是第一個迴圈:

// Update the top of the list.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { // 5.
  final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
  final Widget newWidget = newWidgets[newChildrenTop];
  assert(oldChild == null || oldChild._debugLifecycleState == _ElementLifecycle.active);
  if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget)) // 2.
    break;
  final Element newChild = updateChild(oldChild, newWidget, IndexedSlot<Element>(newChildrenTop, previousChild)); // 3.
  assert(newChild._debugLifecycleState == _ElementLifecycle.active);
  newChildren[newChildrenTop] = newChild; // 4.
  previousChild = newChild;
  newChildrenTop += 1; // 1.
  oldChildrenTop += 1; // 1.
}
  1. 由上往下,同時移動新/舊的Top指針。
  2. 若遇到不匹配的就提前中止。
  3. 同步每個匹配的Element,產生newChild。
  4. 將newChild放入newChildren。
  5. 若沒有提前中止,則停在Bottom指針。

以我們的範例APP而言,ColorTile0,1會是匹配的,到了ColorTile2不匹配,因此最後指針會是:

newChildrenTop == 2
oldChildrenTop == 2
newChildrenBottom == 6
oldChildrenBottom == 6

迴圈二:

// Scan the bottom of the list.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { // 3.
  final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
  final Widget newWidget = newWidgets[newChildrenBottom];
  assert(oldChild == null || oldChild._debugLifecycleState == _ElementLifecycle.active);
  if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget)) // 2.
    break;
  oldChildrenBottom -= 1; // 1.
  newChildrenBottom -= 1; // 1.
}
  1. 這次是由下往上走。
  2. 同樣遇到不匹配就提前中止。
  3. 若沒有提前中止,走到遇上剛剛走下來的Top指針為止。
  4. 注意這裡沒有進行同步,只是移動Bottom指針而已,因為我們希望所有Element的同步是依照正確順序進行的。我們會在迴圈五回來進行這部份的同步。

以我們的APP而言,最後指針會是:

newChildrenTop == 2
oldChildrenTop == 2
newChildrenBottom == 4
oldChildrenBottom == 4

迴圈三:

// Scan the old children in the middle of the list.
final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
Map<Key, Element> oldKeyedChildren;
if (haveOldChildren) {
  oldKeyedChildren = <Key, Element>{};
  while (oldChildrenTop <= oldChildrenBottom) { // 2.
    final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
    assert(oldChild == null || oldChild._debugLifecycleState == _ElementLifecycle.active);
    if (oldChild != null) {
      if (oldChild.widget.key != null)
        oldKeyedChildren[oldChild.widget.key] = oldChild; // 3.
      else
        deactivateChild(oldChild);
    }
    oldChildrenTop += 1; // 1.
  }
}
  1. 這裡我們只走過oldChildren,由上往下。
  2. 只走過中間剛剛還沒走過的部份。
  3. 如果oldChild對應的wiget有key,就收集到oldKeyedChildren。
  4. 如果沒有就停用oldChild。

以我們的APP而言,會走過ColorTile2,3,4,其中2,4有key被收集起來,3則被停用了。最後指針會是:

newChildrenTop == 2
oldChildrenTop == 4
newChildrenBottom == 4
oldChildrenBottom == 4

迴圈四:

// Update the middle of the list.
while (newChildrenTop <= newChildrenBottom) { // 2. 
  Element oldChild;  // 3. 
  final Widget newWidget = newWidgets[newChildrenTop];
  if (haveOldChildren) {
    final Key key = newWidget.key;
    if (key != null) { // 6.
      oldChild = oldKeyedChildren[key];
      if (oldChild != null) { // 7.
        if (Widget.canUpdate(oldChild.widget, newWidget)) {  // 8. 
          // we found a match!
          // remove it from oldKeyedChildren so we don't unsync it later
          oldKeyedChildren.remove(key);
        } else { // 9.
          // Not a match, let's pretend we didn't see it for now.
          oldChild = null;
        }
      }
    }
  }
  assert(oldChild == null || Widget.canUpdate(oldChild.widget, newWidget));
  final Element newChild = updateChild(oldChild, newWidget, IndexedSlot<Element>(newChildrenTop, previousChild)); // 4.
  assert(newChild._debugLifecycleState == _ElementLifecycle.active);
  assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.active);
  newChildren[newChildrenTop] = newChild; // 5.
  previousChild = newChild;
  newChildrenTop += 1;  // 1.
}
  1. 這次我們走的是newChildren。
  2. 同樣只走沒走過的中間部份。
  3. 下面有好幾層的if/else,主要目的是找出一個oldChild(或null)。
  4. oldChild會搭配newWidget來進行同步,產生newChild。
  5. 最後把newChild放入newChildren中當前的位置。
  6. 如果newWidget沒有key,oldChild就直接是null,同步時就會產生新的Element。
  7. 如果newWidget有key,但在剛剛收集的oldKeyedChildren找不到對應的Element,一樣讓oldChild維持null,產生新的Element。
  8. 如果newWidget有key,也在oldKeyedChildren找到Element,而且可以同步,就把它從oldKeyedChildren移除,並以它作為oldChild,最後同步時就會把它跟newWidget連起來。
  9. 如果幫newWidget找到對應key的Element,卻無法同步,就表示Widget的runtimeType變了,這時一樣讓oldChild維持null,產生新的Element。

呼!最複雜的函數中最複雜的區塊就是它了。
這裡的8.就是我們會在官方介紹影片中看到,那個Widget交換對應的Element,實際發生的地方。
另外6.就是為什麼我們的範例會出現ColorTile3顏色不斷變化的現象,因為它沒有key,因此在產生新Element和State時,也隨機產生了新的顏色。
一切的謎團終於解開了!

這時候,我們APP的指針是:

newChildrenTop == 4
oldChildrenTop == 4
newChildrenBottom == 4
oldChildrenBottom == 4

迴圈五:

// We've scanned the whole list.
assert(oldChildrenTop == oldChildrenBottom + 1);
assert(newChildrenTop == newChildrenBottom + 1);
assert(newWidgets.length - newChildrenTop == oldChildren.length - oldChildrenTop);
newChildrenBottom = newWidgets.length - 1; // 1.
oldChildrenBottom = oldChildren.length - 1; // 1.

// Update the bottom of the list.
while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { // 4.
  final Element oldChild = oldChildren[oldChildrenTop];
  assert(replaceWithNullIfForgotten(oldChild) != null);
  assert(oldChild._debugLifecycleState == _ElementLifecycle.active);
  final Widget newWidget = newWidgets[newChildrenTop];
  assert(Widget.canUpdate(oldChild.widget, newWidget));
  final Element newChild = updateChild(oldChild, newWidget, IndexedSlot<Element>(newChildrenTop, previousChild)); // 3.
  assert(newChild._debugLifecycleState == _ElementLifecycle.active);
  assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.active);
  newChildren[newChildrenTop] = newChild; // 3.
  previousChild = newChild;
  newChildrenTop += 1; // 2.
  oldChildrenTop += 1; // 2.
}
  1. 經過迴圈一到四我們已經走完全部的Children,指針都碰在一起了,但我們其實還有下半部沒有進行過同步,所以我們再次把Bottom指針移到最下方。
  2. 同樣由上往下。
  3. 這裡就不再須要任何判斷了,我們在迴圈二已經判斷過這些都是可以同步的newWidget和oldChild,就直接同步下去吧。
  4. 再次跑完剩下的部份。

到這裡整個newChildren的更新就全部完成了。這時的指針:

newChildrenTop == 6
oldChildrenTop == 6
newChildrenBottom == 6
oldChildrenBottom == 6

迴圈六:

// Clean up any of the remaining middle nodes from the old list.
if (haveOldChildren && oldKeyedChildren.isNotEmpty) {
  for (final Element oldChild in oldKeyedChildren.values) {
    if (forgottenChildren == null || !forgottenChildren.contains(oldChild))
      deactivateChild(oldChild);
  }
}

return newChildren;

最後只剩下一些清理工作,還記得我們剛剛收集了有key的oldChild,然後又把有被newWidget匹配到的移除了嗎?剩下沒被匹配的就把它停用吧。


最後再次總結一下六大步驟:

  1. 由上往下,同步匹配的節點,直到遇到第一個不匹配的節點就中止。中止時紀錄當前位置(上界)。
  2. 由下往上,直到遇到第一個不匹配的節點就中止,注意這裡不執行同步。中止時紀錄當前位置(下界)。
  3. 由上界到下界,走過oldChildren,將有key的child收集到另一個List,將沒有key的child停用。
  4. 由上界到下界,走過newChildren,根據newWidget和oldChild進行更新。
  5. 同步在2.中沒同步過的節點。
  6. 停用在3.收集的,有key但沒被newWidget匹配的oldChild。

大功告成啦!!其實有點耐心一步一步走下來的話,也不是真的那麼複雜,而且其實還挺有趣的不是嗎?同時我們也不得不敬佩Flutter團隊,為了盡可能的幫我們壓榨出多一點的效能,付出了多大的努力。如果你完全弄懂了這部份,下一步或許就可以思考看看,有沒有什麼可以再改進的地方,例如更多的效能,或是更簡潔的寫法。如果你成功的發了一個PR被merge,到時候所有人都要叫你Flutter大神了!想一想是不是很吸引人呢?


上一篇
days[11] = "為什麼要有key?"
下一篇
days[13] = "IntelliJ/AS做得比VSCode好的幾件事"
系列文
Why Flutter why? 從表層到底層,從如何到為何。30

尚未有邦友留言

立即登入留言