Flutter: Key! 它们有什么用处

2,018 阅读10分钟

原文:Keys! What are they good for?

作者:Emily Fortuna

基本上每个 widget 的构造方法都有 key 参数,但对它们的使用却不太常见。当 widgets 在 widget tree 上移动的时候,keys 可以保留 widget 的状态。在实际使用中,这意味着 keys 对于保留用户的滚动状态或在需要修改集合 (collection) 时保持状态很有用

本文改编自 Flutter 的官方视频: 何时使用密钥 - Flutter小部件 101 第四集

如果你更喜欢看视频,那么该视频涵盖了本文讲述的所有内容。

Key 的内幕信息

大多数情况下,我们并不需要 key!一般来说,添加 key 并没有什么坏处,但也没有什么用处。只是占用了不必要的空间。就像你在 Dart 中使用 new 关键字一样,或者,类似于在定义一个新变量的时候在表达式的左右两侧都声明了类型。但是,如果要在有状态的、类型相同的 widget 集合上进行添加、删除、排序等操作,可能需要使用到 key。

为了说明在修改 widget 集合的时候为什么需要 key,我编写了一个非常简单的应用程序,其中有两个随机背景色的 widget,点击按钮时它们会交换位置:

1_edgczyvaQRgGRy8yhht0QQ.gif

在无状态的版本里,一个 Row 中放置两个带有随机背景色的无状态 (stateless) 的 StatelessColorfulTile,使用继承自 StatefulWidgetPositionedTiles 存储这些 tile 的位置。当点击底部的 FloatingActionButton 时,可以正确地交换 tile 在列表中的位置:

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)));
 }
}

但是,当把 tile 替换成有状态 (stateful) 的 StatefulColorfulTile并将颜色存储在 State 中时,点击按钮,界面看上去并没有任何变化。

1_T7TBQx9DhaQ16gbX68XxVw.gif

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),
       ));
 }
}

提醒一下,上面显示的代码是有问题的。因为当用户按下“交换”按钮时,色块并没有交换。解决方法是向有状态 (stateful) 的 widgets 添加一个 key 参数。然后,widgets 开始按我们的预期正确交换位置:

1_3XbdhaQ9_lPfILdViiipeQ.gif

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),
        ));
  }
}

当子树 (subtree) 中存在有状态 (stateful) 的 widget 时,修改子树时才需要 key 来维护 widget 状态。如果整个 widget subtree 中都是无状态 (stateless) 的 widgets,key 是不需要的。

从技术上讲,这就是在 Flutter 中使用 key 所需要知道的全部内容。但是,如果您想了解这一切的根本原因……

为什么有时候 Key 是需要的

你还在这里,嗯?好吧,那就继续吧,学习 Widget 和 Element 树的本质,成为一名 Flutter 巫师!

众所周知,Flutter 为每个 widget 构建一个对应的 Element。就像构建 Widget 树一样,Flutter 也同时构建了 Element 树。ElementTree 很简单,仅仅保存 widget 的类型信息和对子元素的引用。可以认为 ElementTree 是 Flutter 应用程序的骨架。它显示了应用程序的结构,所有的其他附加信息都可以通过引用原始的 widget 来查找到。

上面示例中的 Row widget 实际上为它的每个子 widget 保存了一组有序的插槽 (slot)。当交换 Row 中 Tile widgets 的顺序时,Flutter将遍历ElementTree,以查看骨架结构是否与之相同。

1_sHDIVXBu9RpJYN9Zdn8iBw.gif

RowElement 开始,依次遍历子元素。RowElement 检查新 widget 的 类型 (type) 和 key 是否和持有的老 widget 相同,如果相同,则将引用指向新的 widget。在无状态 (stateless) 版本中,widget 并没有 key,所以 Flutter 仅仅检查类型 (type) 是否相同。(如果这看起来信息量太多,请仔细参考上面的动图)。

有状态 (stateful) widget 对应的 Element tree 的结构看起来有些不同。widgets 和 elements 都与无状态版本的时候一样,但多了一个相关联的状态对象 (state object)。有状态 (stateful) widget 的颜色信息是存储在 State 对象中,而不是存储在 widget 本身中。

1_noTkKudlGuaAkiGaubEcNA.gif

当使用有状态的 Tile ,且没有传入 key 的情况下,交换两个 widget 的顺序时,Flutter 会查看 ElementTree,检查 RowWidget 的类型并更新引用。随后 TileElement 会检查相应 widget 的类型 (type) 是否与之相同,并更新对 widget 引用。这里 widget 的类型显然是相同的。同样的事情在第二个 child 上也会做一遍。由于 Flutter 使用 ElementTree 和 与之对应的 state 对象来决定什么内容应该显示在你的设备上,从我们人类的视角看,weidget 并没有正确的进行交换。

1_7n-u4yexzRZDEtNvbrsG1g.gif

使用有状态 Tile 的修复了该问题版本中,我们将 key 添加到了 widget 中。现在再交换 widget,Row widgets 像之前版本一样得到匹配,但 Tile Element 的 key 和对应的 Tile Widget 的 key 并不匹配。这将导致 Flutter 从第一个不匹配的元素开始,停用 (deactivate) 这些不匹配的 elements,并将这些 Tile Element 的引用从 Element Tree 中移除。

1_AcBxC8IF_irZpFARt-Nqyw.gif

然后,Flutter 会在 Row 的不匹配的 child elements 中查找具有正确 key 的 element。找到匹配项后,更新 element 对 widget 的引用。然后,对第二个孩子做同样的事情。现在 Flutter 将按照我们所期望的显示,按下按钮时,widget 会交换位置并更新它们的颜色。

总而言之,当修改集合中有状态 widget 的顺序或数量时,key 很有用。为了便于说明,此示例中将颜色存储为状态。但是,状态往往比这要隐晦得多。播放动画、显示用户输入的数据、滚动位置都涉及到状态。

Key 应该放在哪里?

简单的说:如果需要给 app 添加 key,应该把 key 添加到需要维护状态的 widget subtree 的顶部

我见过的一个常见错误是:人们认为他们只需要在第一个有状态的 widget 上放一个 key,但“此处危险”。不相信我?为了展示会遇到什么样的麻烦,我用 Padding widget 包裹 colourfulTile widget,但把 key 留在了 tiles 上。

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),
        ));
  }
}

此时,单击按钮,Tiles 会变成完全不同的随机颜色!

1_uC-SRZpRkOZCEr_rGisF9g.gif

下图展示了包裹 Padding widget 后,WidgetTree 和 ElementTree 的情况:

1_0NNY0KOBQGCWvdrWvorOQA.jpeg

当交换子节点的位置时,Flutter 的 element-to-widget 匹配逻辑一次只会检查树的一个层次。下图中将“孙子节点”(子节点的子节点)置灰,方便我们一次只专注于一个层次。Padding elements 所在的第一层,一切都正确匹配。

1_vD86ZINBC-1Ctx9kudEGaw.gif

在第二层,Flutter 发现 Tile Element 的 key 和 Tile Widget 的 key 不一致,deactivate 了 Tile Element,并丢弃了这些连接。这个例子中使用的是 LocalKeys,这意味着在将 widget 和 element 做匹配时,Flutter 只在树的特定层级中寻找 key 的匹配关系。

由于在这一层找不到具有该 key 值的 tile element,所以创建了一个新的 Element,并初始化为一个新状态,在本例中,小部件变为了橙色!

1_JI1Ex87QRMTCJwBWmbNI5A.gif

如果在 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 正确地更新连接,就像我们在前面的示例中所做的那样。宇宙又恢复了秩序。

1_FkCvw_LCfQ2x02wj7cmrpA.gif

应该使用何种 Key?

优秀的 Flutter APIs 提供者准备了多个类型的 Key 供我们使用。使用哪种类型的 Key,依赖于需要这些 Key 的条目的特征。看一看在这些 widget 中存储的信息。这里,我们将讨论四种不同类型的 keys:ValueKeyObjectKeyUniqueKeyGlobalKey

请思考下面的 To-do list app^1 ,它可以根据优先级重新排列 TODO 列表中的条目,并且在完成后将 TODO 项删除。

1_wHJZnNPhMkePFEw1ihrbEA.gif

在这个场景中,假设 TODO 项的文本是不变的、且唯一的,那么,这是一个很好的使用 ValueKey 的场景。其中文本是 ValueKey 的“值”。

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

另一个场景:有一个地址簿 app,该应用列出了每个用户的信息。在这种情况下,每个子 widget 都存储了复杂的组合数据。任何单一的数据字段,如名字或生日,都可能与另一个条目的相应字段相同,但所有字段的组合是唯一的。在这种情况下,ObjectKey 可能是最合适的选择。

1_vZV_QjG1GEg7nJILMbhEkA.png

如果集合中有多个具有相同值的 widget,或者希望真正确保每个 widget 与其他 widget 都不同,可以使用 UniqueKey。在颜色切换示例 app 中使用了 UniqueKey,因为在 tile 中没有存储任何常量数据,并且在创建 widget 时并不知道颜色是什么。不过要小心使用 UniqueKey!如果在 build 方法体内创建 UniqueKey,那么,每次 build 方法重新执行的时候,widget 都会获得一个不同的 UniqueKey 实例。这将和不使用 key 没有什么区别!

类似的,你绝对不想使用一个随机数作为 Key。每次 widget 被构建时,都会生产一个新的随机数,帧与帧之间的一致性将丢失。那还不如一开始就不使用 Key。

PageStorageKey 是用来存储用户滚动位置的专用 key,以便 app 保留当前的滚动位置,供后续使用。

1_KgQeq1LDIPVuE2dwNzZbRQ.gif

GlobalKey 有两个用途:一是,允许 widget 在 app 的任何地方更改 parent 而不会丢失数据;另一个是,可以用于访问 widget tree 中完全不同部分的另一个 widget 的信息。第一个场景的一个例子是:如果想在不同的页面显示同一个 widget,且保持该 widget 的状态相同,可以使用 GlobalKey。第二种场景,假设想验证密码,但不想与树中的其他 widget 共享该状态信息。GlobalKey 也可用于测试,通过使用 key 来访问特定的 widget 并查询有关它的状态信息。

1_JIPjn-gM6OIG_TfPJvtuVA.gif

通常(但并非总是!),GlobalKey 有点像全局变量。Flutter 中有一种更好的方式来查看状态,即使用 InheritedWidget, 或 Redux、BLoC 模式之类的东西。

快速回顾

总之,当想要跨 widget 树保留状态的时候,请使用 Key。最常发生的场景是修改相同类型的 widget 的集合,例如列表。把 key 放在需要保留状态的 widget tree 的顶部,并且根据 widget 中存储的数据选择要使用的 Key 的类型。

恭喜,你正在成为 Flutter 巫师的道路上前进!哦,我说巫师 (sorcerer) 了吗?我的意思是 sourcerer,就像写应用程序源代码的人一样……这几乎一样好。 …几乎。 ⚡

参考

[1]: Code for the To-do app 受启发于Vanilla Example