Flutter-key的探索

641 阅读3分钟

前言

众所周知,key Widget, Element 和 SemanticsNode标识符。 key出现在每一个widget的构造方法中,但是日常开发中却很少用到key。 本文主要记录对Flutter源码的断点调试从而了解key的作用。

semantics不作为本文的探索对象

key的应用场景

通过关键字“widget.key”对源码的暴力搜索,通过总结对key的应用主要有以下三种场景

  • 通过widet的 静态方法 canUpdate,参与到element的rebuild
static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }
  • 通过持有GlobalKey从而对使用该key的widget的element引用,拿到了element,就相当于拿到了state,renderObject。

  • 特定widget对key制定功能,如PageStorageKey等

使用key对于日常开发影响较大应该是第一种场景,所以决定重点探索

key如何参与到element的rebuild

我们都知道flutter更新复用机制:当widget变化时,flutter会通过 canUpdate来判断是要用新的widget更新element还是重新创建一个element。 下面通过几个场景,对关键代码下断点跟进来验证这个机制,并观察key在各个场景更新时发挥了怎样的作用。

单个子widget下的更新

class TestPage extends StatefulWidget {

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

class _TestPageState extends State<TestPage> {
  int _count = 0;
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Text('$_count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState((){
          _count++;
        }),
        child: Icon(Icons.add),
      ),
    );
  }
}

当点击FloatingActionButton后,页面上变化的只有Text,我们关心的对象也只是Text,(其他先的不管),因此只要观察父widget Center的element是如何处理新的new Text和 old Text。 我们直接来到Element类下的 updateChild 方法,下断点,点击floatingActionButton。

image.png 成功断下,但是发现一次简单 setState有非常的多的element会调用 updateChild,为了快速找到观察的对象,这个可以下一个条件断点:

image.png 通过判断newWidget的类型是不是Text来快速筛选。

newWidget?.runtimeType.toString() == 'Text'

顺利断下目标element.updateChild

image.png 继续往下走,发现来到了这个条件里(去掉了不相关的代码),这里只把 newWidget 交给child去update,接着child赋给newChild最后返回

      else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
        child.update(newWidget);
        newChild = child;
      } else {
        ...
      }
    } 
  
    return newChild;

接着我们将Center中的Text加上UniqueKey,接着保存运行

      Center(
        child: Text('$_count', key: UniqueKey(),),
      ),

再次断下,发现newWidget为新的Text,并带上了key

image.png 往下走,由于key不一样,canUpdatereturn为false,来到了else分支。

    ...
    else {
        //禁用当前的element,从element tree移除
        deactivateChild(child);
        //生成新element,加入element tree
        newChild = inflateWidget(newWidget, newSlot);
      }
    } else {
      ...
    }
    return newChild;

多个子widget下的更新

StatelessWidget

代码改变一下

class TestPage extends StatefulWidget {

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

class _TestPageState extends State<TestPage> {

  final _children = [
    Container(width: 100, height: 100, color: Colors.amberAccent,),
    Container(width: 100, height: 100, color: Colors.blueAccent,),
  ];

  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          children: _children,
        )
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState((){
          _children.insert(0, _children.removeAt(1));
        }),

        child: Icon(Icons.add),
      ),
    );
  }
}

Column下有两个颜色不一样的Container,点击按钮,Container交换位置,所有页面发生变化的只是ColumnColumnelementMultiChildRenderObjectElement,可以看到他的update并不一样,关键方法为updateChildren

  //MultiChildRenderObjectElement的update方法 
  void update(MultiChildRenderObjectWidget newWidget) {
    super.update(newWidget);
    _children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
    _forgottenChildren.clear();
  }
}

进入updateChildren方法,该方法写了详细的注释,详细说明了各种情况下处理,同样在方法开头下断点,点击按钮,交换了Container位置,并成功断下。贴上核心代码。

    int newChildrenTop = 0;
    int oldChildrenTop = 0;
    //此时length相等,newChildren为oldChildren
    final List<Element> newChildren = oldChildren.length == newWidgets.length ?
        oldChildren : List<Element>.filled(newWidgets.length, _NullElement.instance, growable: false);
  // Update the top of the list.
  //第一个循环,从顶部开始更新children
    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element? oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
      final Widget newWidget = newWidgets[newChildrenTop];
      //熟悉的canUpdate,一旦返回值为false 直接跳出循环
      //否则,就拿newWidget和当前插槽值给oldChild更新
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      final Element newChild = updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))!;
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }
    ...
    //期间的循序都不符合此时的条件
    return newChildren;

接着改动一下代码,给Container加上key,退出页面再次进入,更新一下_children。点击按钮,再次断下。

  final _children = [
    Container(key: ValueKey(0), width: 100, height: 100, color: Colors.amberAccent),
    Container(key: ValueKey(1), width: 100, height: 100, color: Colors.blueAccent,),
  ];

此时由于key不同,canUpdate返回false,直接退出了循序,接着继续往下走。

image.png

同样canUpdate返回false,退出了循序,继续往下走。 image.png

    // 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) {
        final Element? oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
        if (oldChild != null) {
          if (oldChild.widget.key != null)
            //将widget.key作为key,把oldChild放到map,后续食用
            oldKeyedChildren[oldChild.widget.key!] = oldChild;
          else
            deactivateChild(oldChild);
        }
        oldChildrenTop += 1;
      }
    }
    // Update the middle of the list.
    while (newChildrenTop <= newChildrenBottom) {
      Element? oldChild;
      final Widget newWidget = newWidgets[newChildrenTop];
      if (haveOldChildren) {
        final Key? key = newWidget.key;
        if (key != null) {
        //找到相同key的oldChild
          oldChild = oldKeyedChildren![key];
          if (oldChild != null) {
            if (Widget.canUpdate(oldChild.widget, newWidget)) {
              oldKeyedChildren.remove(key);
            } else {
              oldChild = null;
            }
          }
        }
      }
      //就拿newWidget和当前插槽值给oldChild更新
      final Element newChild = updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))!;
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
    }
    ...
    return newChildren;

StatefulWidget

再来改变一下代码,将Container改成_Box。接着点击按钮,结果如图

  final _children = [
    _Box(),
    _Box()
  ];
  ...
class _Box extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _BoxState();
}

class _BoxState extends State<_Box> {
  //color由state持有,生成随机色值
  final color = Color.fromARGB(Random().nextInt(255), Random().nextInt(255), Random().nextInt(255), 1);
  @override
  Widget build(BuildContext context) {
    return Container(width: 100, height: 100, color: color);
  }
}

image.png 并非贴了两张一样的图,那为什么位置没有发生变化呢? 这次直接用上面的原理去分析,按钮点击之后,页面更新最终来到了updateChildren, 此时newChildren[_Box2号, _Box1号], oldChildren[_Box1号, _Box2号], children同位置的_Box runtimeType一样,key同为null,所以在满足第一个循环的条件,也就是这里

    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element? oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
      final Widget newWidget = newWidgets[newChildrenTop];
    
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      final Element newChild = updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))!;
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

第一次循环时,_Box2号作为newWiget,复用了同位置上_Box1号elementoldChild),对应这句代码 final Element newChild = updateChild(oldChild, newWidget, slotFor(newChildrenTop, previousChild))!;

但其实_Box2号根本没有配置任何属性,此时的oldChild调用statebuild方法,(color是被state持有的),build了颜色跟之前一摸一样的Container,所以造成了交换了位置,但好像又没交换的现象。

我们都知道这里加上key,就可以视觉上交换位置啦,这里就不继续啰嗦了。

总结和心得

  • 通过几个场景,反证了flutter更新复用机制,和了解了key在更新时发挥的作用。
  • 看前人总结的结论,有时并不能完全的理解,通过阅读源码和打断点跟进,可以深入理解背后的原理,并加深印象。