Flutter - Key内部原理浅析

来源:www.jianshu.com 更新时间:2023-05-25 21:55

推荐此视频:https://space.bilibili.com/589533168/channel/detail?cid=149817

Key的内部原理

大部分时间用不到Key。加了也不会有什么副作用,不过也没必要消耗额外的空间。就像这样Map<Foo, Bar> aMap = Map<Foo, Bar>();初始化了一个变量扔着一样。但是,如果你要对一个同类型,有状态的widget集合添加、删除或者排序,那就要Key的的参与了

为了说明为什么你修改一个widget集合的时候需要用到key,我(作者)写了一个简单的例子。这个例子里面有两个widget,随机显示颜色。当你点击里面的一个按钮的时候,这两个组件会互换位置。:


image.png

在无状态版本里面,有两个无状态的组件分别显示随机颜色。这个两个无状态的widget包含在一个叫做PositionedTiles的有状态的wiget里。两个显示颜色的widget的位置也保存在里面。当FloatingActionButton被点击的时候,两个无状态颜色组件就会交换位置。代码如下:

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
 List<Widget> tiles = [
   StatelessColorfulTile(),
   StatelessColorfulTile(),
 ];

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: Row(children: tiles),
     floatingActionButton: FloatingActionButton(
         child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
   );
 }

 swapTiles() {
   setState(() {
     tiles.insert(1, tiles.removeAt(0));
   });
 }
}

class StatelessColorfulTile extends StatelessWidget {
 Color myColor = UniqueColorGenerator.getColor();
 @override
 Widget build(BuildContext context) {
   return Container(
       color: myColor, child: Padding(padding: EdgeInsets.all(70.0)));
 }
}

但是,如果我让ColorfulTiles变成有状态的,颜色都保存在状态里,当我点击按钮的时候,看起来什么都不会发生。


image.png
List<Widget> tiles = [
   StatefulColorfulTile(),
   StatefulColorfulTile(),
];

...
class StatefulColorfulTile extends StatefulWidget {
 @override
 ColorfulTileState createState() => ColorfulTileState();
}

class ColorfulTileState extends State<ColorfulTile> {
 Color myColor;

 @override
 void initState() {
   super.initState();
   myColor = UniqueColorGenerator.getColor();
 }

 @override
 Widget build(BuildContext context) {
   return Container(
       color: myColor,
       child: Padding(
         padding: EdgeInsets.all(70.0),
       ));
 }
}

但是,这个代码是有bug,点了“交换”按钮的时候,两个颜色的widget不会交换。只有在颜色widget里面加上key参数才可以达到这个效果。


image.png
List<Widget> tiles = [
  StatefulColorfulTile(key: UniqueKey()), // Keys added here
  StatefulColorfulTile(key: UniqueKey()),
];

...
class StatefulColorfulTile extends StatefulWidget {
  StatefulColorfulTile({Key key}) : super(key: key);  // NEW CONSTRUCTOR

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

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

但是,只有在修改有状态的子树的时候才是必须的。如果整个子树的widget集合都是无状态的,那么Key并不是必须的。

这些就是在Flutter里使用Key所需要知道的全部了。当然,如果你要知道这里面的原理的话,请继续往下看。。。

为什么Key有的时候是必须的

如你所知,每个widget都有一个对应的element。就如同构建一个widget树一样,Flutter也会构建一个对应的Element树。这个ElementTree非常简单,只保存了每个widget的类型和子element。你可以认为element树是Flutter app的骨架、蓝图。任何其他的信息都可以从element找到对应的widget然后拿到。

在上例的Row widget里保存了一个有序的子节点列表。当我们交换Row里颜色widget的顺序的时候,Flutter会遍历ElementTree,对比交换前后树的结构是否发生了改变。


2804658014-b90652fd7681cb13_articlex.gif

 

Flutter从RowElement开始,然后移动子节点。ElementTree检查新的widget类型和key与旧的节点是否有不同。如果有不同,它会把引用指向新的widget。在无状态版本里,widget并没有key,所以Flutter只是检查了类型。如果这样看起来信息量太大的话,可以直接看上面的动图。

在element树种,对有状态widget的处理略微不同。还是会有上文说到的widget和element,不过也会有保存状态的对象。颜色久保存在这些状态里,而不是widget里。


image.png

由于gif文件大 我就发个图片吧

在有状态,没有key的例子里,当交换widget的按钮按下的时候,Flutter会遍历ElementTree,检查Row的类型,之后更新引用。之后是颜色element,检查颜色widget是否为同样的类型,并更新引用。因为Flutter使用了ElementTree和它的state来决定什么东西可以显示在你的设备上。从用户的角度看,两个颜色widget并没有正确的互换。


image.png

在上面问题的修改版中,颜色widget里面多了一个key参数。现在再点击交换颜色的按钮的时候, Rowwidget还是和之前一样,但是两个颜色的element的key和widget的key是不同的,这样会导致Flutter在Row element从第一个key值不匹配的地方开始重构element子树。

之后Flutter会在Row子节点里找到key值匹配的element来重构子树。找到一个key值匹配的就更新它对widget的引用。知道整个element子树重构完成。这样Flutter就可以正确的显示颜色交换了。

言而言之,如果要修改一列状态widget的数量、顺序的时候Key就必不可少了。为了强调,在本例中颜色的值存在了state里。state存在有点时候很微小、不起眼,在动画,用户输入数据的显示和滚动的位置等地方都会用到。

Key放在哪

基本上,如果要在app里使用key的话,那么就应该放在存放state的widget子树的最顶端
一个经常会犯的错误是,很多人会把key放在第一个状态widget里面,但是这样是不对的。不信?来稍微修改一下上面的例子。现在Padding widget包在了颜色widget的外面,但是key还是放在颜色widget上面。

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
  // Stateful tiles now wrapped in padding (a stateless widget) to increase height 
  // of widget tree and show why keys are needed at the Padding level.
  List<Widget> tiles = [
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(key: UniqueKey()),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(key: UniqueKey()),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(children: tiles),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
    );
  }

  swapTiles() {
    setState(() {
      tiles.insert(1, tiles.removeAt(0));
    });
  }
}

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

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

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

点击交换按钮之后,两个颜色组件显示出了完全不同的颜色。


3719369945-16b55f3e09220a3f_articlex.gif

这是对应的element树的样子:


image.png

当我们交换两个子widget的位置之后,Flutter里element到widget的检查机制每次只会检查element树的一层。下图把叶子节点都灰化处理了,这样我们可以更加注意到底发生了什么。在Padding widget这一层,所有运作都是正确的。



image.png

在第二层,Flutter会发现颜色widget的key和element的key不匹配,它会移除掉这些element的引用。本例中使用的是LocalKeys。也就是说在element的对比中,Flutter只会查看树的某个范围内对比key的值是否匹配。

因为在这个范围内找不到匹配key值的element,那么它就会创建一个新的,所以会初始化一个新的状态。所以在本例中,widget显示的颜色是重新生成的随机色。


image.png

那么,如果在Padding widget上面加上key值呢?

void main() => runApp(new MaterialApp(home: PositionedTiles()));

class PositionedTiles extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState();
}

class PositionedTilesState extends State<PositionedTiles> {
  List<Widget> tiles = [
    Padding(
      // Place the keys at the *top* of the tree of the items in the collection.
      key: UniqueKey(), 
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(),
    ),
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile(),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(children: tiles),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.sentiment_very_satisfied), onPressed: swapTiles),
    );
  }

  swapTiles() {
    setState(() {
      tiles.insert(1, tiles.removeAt(0));
    });
  }
}

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

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

class ColorfulTileState extends State<ColorfulTile> {
  Color myColor;

  @override
  void initState() {
    super.initState();
    myColor = UniqueColorGenerator.getColor();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        color: myColor,
        child: Padding(
          padding: EdgeInsets.all(70.0),
        ));
  }
}

Flutter注意到了问题,并会正确的更新。

[图片上传失败...(image-791d4c-1590476859071)]

我应该用哪种Key

我们要用的key的类型主要看widget要做到什么特点的区分。下面要介绍四种key:ValueKey, ObjectKey, UniqueKeyPageStorageKey, GlobalKey.

ValueKey

比如下面的todo app,你可以对各条目重新排序。

[图片上传失败...(image-e466ce-1590476859071)]

在这个场景下,如果一个条目的文本可以认为是一个常量,并且是唯一的,那么就是用于ValueKey。文本就是“value”值。

return TodoItem(
  key: ValueKey(todo.task),
  todo: todo,
  onDismissed: (direction) => _removeTodo(context, todo),
);

Objectkey

另外的一个场景,比如你有一个地址簿app。里面保存了不同人的信息。在这个情况下,每个widget都保存了一个复杂的数据。每个单独的字段,比如姓名或者出生日期都可能和其他的数据是一样,但是这些数据组合起来就是唯一的。那么,这就很实用于ObjectKey

[图片上传失败...(image-d6240c-1590476859071)]

Uniquekey

如果多个widget有同样的值,或者你想要确保每个widget都不同,那么就可以使用UniqueKey。上面的例子中就使用了UniqueKey,因为在颜色widget里面并没有其他的值可以区分于其他的widget了。使用UniqueKey要小心。如果你在build方法里创建了一个新的UniqueKey,那么这个widget每次调用build方法之后都会得到一个不同的UniqueKey这样就把key的好处全部的抹煞了。

类似的,千万不要考虑使用随机数来作为你的key。每次一个widget调用了build方法就会生成一个随机数,那么多个帧的连续性也就被破坏了。那么,效果也就和一开始就没用key的效果是一样的了。

PageStoragekey

这是一个很特殊的key,它保存了用户滚动的位置,这样app可以保存用户滚动的位置给下次用户打开的时候直接到上次滚动的位置。


2207691807-b9315ce4858e1305_articlex.gif

GlobalKey

有两个用处:

  • 可以在app的任何地方更换父widget而不会丢失状态
  • 它可以用来从完全不同的widget树里面访问数据

第一种情况的一个例子是如果你要在不同的地方显示同一个widget,而且state也是相同的,那么GlobalKey就是最好的选择。

第二种情况,如果你想要验证一个密码,但是又不想在不同的widget之间共享状态。

GlobalKey也可以用于测试,使用一个key来访问某个特定的widget,然后查看里面的数据。


3116909959-c978614adce8a0c4_articlex.gif

通常(并不是全部),GlobalKey更像是一个全局变量。总是有其他的方法可以访问state,比如InheritedWidget,或者类似Redux的库或者BLoC模式的实现。