乐趣区

Flutter-Key

原文在这里。这篇文章是油管视频的总结。视频地址是这里。

基本上每个 widget 都有 key 参数,但是使用的方法确各有不同。在 widget 从 widget 树的一个地方移动到另一个地方的时候,key 会保存状态。在实际使用中,Key 可以用来保存用户滚动的位置或者保存集合修改的状态。

Key 的内部原理

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

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

在无状态版本里面,有两个无状态的组件分别显示随机颜色。这个两个无状态的 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 变成有状态的,颜色都保存在状态里,当我点击按钮的时候,看起来什么都不会发生。

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 参数才可以达到这个效果。

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,对比交换前后树的结构是否发生了改变。

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

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

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

在上面问题的修改版中,颜色 widget 里面多了一个 key 参数。现在再点击交换颜色的按钮的时候, Row widget 还是和之前一样,但是两个颜色的 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),
        ));
  }
}

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

这是对应的 element 树的样子:


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

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

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


那么,如果在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 注意到了问题,并会正确的更新。

我应该用哪种 Key

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

ValueKey

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

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

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

Objectkey

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

Uniquekey

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

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

PageStoragekey

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

GlobalKey

有两个用处:

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

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

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

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

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

总结

总而言之,在 widget 树里面保存 state 就要考虑用 key 了。一般要修改一列同样类型的 widget 的时候,比如一个列表。把 key 值放在要保存 state 的子树的顶层 widget 上。根据要展现的数据和使用的场景选择合适的 key 类型。

Todo app 的代码在这里。

退出移动版