flutter key

1,188 阅读10分钟

基本上每个 widget 构造函数都可以找到 key 参数,但它们的使用不太常见。当 widget 在 widget 树中移动时,key 会保留状态。在实践中,这意味着它们对于保留用户的滚动位置或在修改集合时保持状态很有用。

key 内幕消息

大多数时候……你不需要 key !一般来说,添加它们并没有什么坏处,但它也是不必要的,只是占用了不必要的空间,就像 new 关键字一样,或者在新变量的右侧和左侧声明类型(Map< Foo, Bar> aMap = Map<Foo, Bar>())。但是,如果发现自己添加、删除或重新排序一组具有某种状态的相同类型的 widget ,那么将来可能会使用 key!

如果发现自己添加、删除或重新排序一组具有某种状态的相同类型的 widget,那么将来可能会使用 key 。

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

image.png

在应用程序的无状态版本中,我有两个无状态的 Tiles,每个都有一个随机生成的颜色,在一个 Row 和一个名为 PositionedTiles 的 StatefulWidget 中,用于存储这些图块的位置。当我点击底部的 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 有状态而不是无状态,并将颜色存储在状态中,当我按下按钮时,它看起来就像没有发生任何事情。

class PositionedTiles2 extends StatefulWidget {
  @override
  PositionedTilesState2 createState() => PositionedTilesState2();
}

class PositionedTilesState2 extends State<PositionedTiles2> {

  List<Widget> tiles = [
    StatefulColorfulTile2(),
    StatefulColorfulTile2(),
  ];

  @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 StatefulColorfulTile2 extends StatefulWidget {
  @override
  ColorfulTileState2 createState() => ColorfulTileState2();
}

class ColorfulTileState2 extends State<StatefulColorfulTile2> {
  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 参数,然后 widget 交换我们想要的位置:

class PositionedTiles2 extends StatefulWidget {
  @override
  PositionedTilesState2 createState() => PositionedTilesState2();
}

class PositionedTilesState2 extends State<PositionedTiles2> {

  List<Widget> tiles = [
    StatefulColorfulTile2(key: UniqueKey(),),
    StatefulColorfulTile2(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 StatefulColorfulTile2 extends StatefulWidget {
  const StatefulColorfulTile2({Key? key}) : super(key: key);

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

class ColorfulTileState2 extends State<StatefulColorfulTile2> {
  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 时,才需要这样做。如果集合中的整个 widget 子树是无状态的,则不需要 key 。

为什么有时需要 key

学习 Widget 和 Element 树的本质!!!

在幕后,Flutter 为每个 widget 构建了一个相应的 Element 。就像构建 Widget 树一样,Flutter 也构建了 Element 树。 ElementTree 非常简单,仅保存有关每个 Widget 类型的信息和对子元素的引用。可以将 ElementTree 视为 Flutter 应用程序的骨架。它显示了应用程序的结构,但所有其他信息都可以通过参考原始 Widget 来查找。

上面示例中的 Row 小部件本质上为它的每个子级保存一组有序插槽。当我们交换 Row 中 Tile Widget 的顺序时,Flutter 会遍历 ElementTree 以查看骨架结构是否相同。

image.png

它从 RowElement 开始,然后移动到其子项。 ElementTree 检查新 Widget 是否与旧 Widget 的类型和 key 相同,如果是,则更新其对新 Widget 的引用。在无状态版本中,Widget 没有 key ,因此 Flutter 只检查类型。

有状态 Widget 的底层元素树结构看起来有些不同。像以前一样有 Widget 和 Element ,但也有一对状态对象,颜色信息存储在那里,而不是 Widget 本身。

image.png

在没有 key 的有状态 Tile 情况下,当我交换两个 Widget 的顺序时,Flutter 会遍历 ElementTree,检查 RowWidget 的类型,并更新引用。然后 TileElement 检查相应的 Widget 是否是同一类型(TileWidget)并且它是,因此它更新引用。同样的事情也发生在第二个孩子身上。因为 Flutter 使用 ElementTree 及其相应的状态来确定在您的设备上实际显示的内容,所以从我们的角度来看,Widget 似乎没有正确交换!

image.png

在带有状态 Tiles 的固定版本中,我向 Widget 添加了 key 属性。现在,如果我们交换 Widget ,Row Widget 会像以前一样匹配,但是 Tile Element 的 key 与相应的 Tile Widget 的 key 不匹配。这会导致 Flutter 停用这些元素并删除对元素树中 Tile 元素的引用,从第一个不匹配的元素开始。

image.png

然后,Flutter 会通过 Row 的不匹配子项查找具有正确对应 key 的元素。它找到一个匹配项,并更新其对相应 Widget 的引用。然后 Flutter 对第二个孩子做同样的事情。现在 Flutter 将显示我们所期望的,当我按下按钮时,Widget 会交换位置并更新它们的颜色。

因此,总而言之,如果要修改集合中有状态 Widget 的顺序或数量,key 很有用。为了便于说明,我在本例中将颜色存储为状态。但是,状态通常要微妙得多。播放动画、显示用户输入的数据、滚动位置都涉及到状态。

我把它们放在哪里?

简短的回答:如果需要向应用程序添加 key ,应该将它们添加到 Widget 子树的顶部,并带有您需要保留的状态。

我见过的一个常见错误是人们认为他们只需要在第一个有状态的 Widget 上放一个 key.为了展示我们会遇到什么样的麻烦,我用填充 Widget 包裹了我的 colourfulTile 小部件,但我把 key 留在了 tiles 上。

class PositionedTiles3 extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState3();
}

class PositionedTilesState3 extends State<PositionedTiles3> {
  // 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: StatefulColorfulTile3(key: UniqueKey()),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile3(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 StatefulColorfulTile3 extends StatefulWidget {
  StatefulColorfulTile3({Key? key}) : super(key: key);

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

class ColorfulTileState3 extends State<StatefulColorfulTile3> {
  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 会变成完全不同的随机颜色!

这是添加了填充 Widget 的 WidgetTree 和 ElementTree 的样子:

image.png

当我们交换孩子的位置时,Flutter 的 Element 到 Widget 匹配算法一次只查看树中的一个级别。该图表使图表中的孩子的孩子变灰,因此我们可以一次专注于一个级别。在具有 Padding 元素的第一级子级中,一切都正确匹配。

image.png

在第二个级别,Flutter 注意到 Tile Element 的 key 与 Widget 的 key 不匹配,因此它停用了该 Tile Element,删除了这些连接。我们在这个例子中使用的 key 是 LocalKeys。这意味着在将 Widget 与 Element 匹配时,Flutter 仅在树中的特定级别内查找 key 匹配项。

由于在该级别找不到具有该 key 值的 tile Element,因此它创建一个新 Element ,并初始化一个新状态,在这种情况下,使 Widget 变为橙色!

image.png

如果我们在填充 Widget 级别添加键:

class PositionedTiles4 extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PositionedTilesState4();
}

class PositionedTilesState4 extends State<PositionedTiles4> {
  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: StatefulColorfulTile4(),
    ),
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulColorfulTile4(),
    ),
  ];

  @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 StatefulColorfulTile4 extends StatefulWidget {
  StatefulColorfulTile4({Key? key}) : super(key: key);

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

class ColorfulTileState4 extends State<StatefulColorfulTile4> {
  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.png

我应该使用什么样的 key ?

Flutter API 的优秀供应商为我们提供了多种 Key 类可供选择。应该使用的 Key 类型取决于需要 Key 的项目的区别特征。查看存储在这些 widgets 中的信息。这里我将讨论四种不同类型的键:ValueKey、ObjectKey、UniqueKey 和 UniqueKey。

请考虑以下待办事项列表应用,可以在其中根据优先级重新排列待办事项列表中的项目,然后在完成后将其删除。

image.png

在这种情况下,可能希望待办事项的文本保持不变且唯一。如果是这种情况,它可能是一个很好的 ValueKey 候选者,其中文本是“值”。

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

在另一种情况下,也许有一个地址簿应用程序,其中列出了有关每个用户的信息。在这种情况下,每个子 widgets 都存储更复杂的数据组合。任何单个字段,例如名字或生日,都可能与另一个条目相同,但组合是唯一的。在这种情况下,ObjectKey 可能是最合适的。

image.png

如果集合中有多个具有相同值的 widgets ,或者如果想真正确保每个 widgets 与所有其他 widgets 不同,则可以使用 UniqueKey。我在示例颜色切换应用程序中使用了 UniqueKey,因为我们没有任何其他常量数据存储在我们的图块中,而且我们不知道构建 widgets 时的颜色是什么。不过要小心使用 UniqueKey!如果在构建方法中构造一个 new UniqueKey,那么每次重新执行构建方法时,使用该键的 widgets 将获得一个不同的唯一 key 。这将消除使用 key 的任何好处!

同样,绝对不想使用的一件事是 key 的随机数。每次构建 widgets 时,都会生成一个新的随机数,并且将失去帧之间的一致性。那么你可能一开始就没有使用过 key !

PageStorageKeys 是存储用户滚动位置的专用 key ,以便应用程序可以保留它以供以后使用。

image.png

GlobalKeys 有两个用途:它们允许 widgets 在应用程序中的任何位置更改父级而不会丢失状态,或者它们可用于访问 widgets 树中完全不同部分中的另一个 widgets 的信息。第一个场景的一个例子可能是如果你想在两个不同的屏幕上显示相同的 widgets ,但保持所有相同的状态,你会想使用 GlobalKey。在第二种情况下,也许您想验证密码,但不想与树中的其他 widgets 共享该状态信息。 GlobalKeys 也可用于测试,通过使用键来访问特定的 widgets 并查询有关其状态的信息。

image.png

通常(但并非总是如此!),GlobalKeys 有点像全局变量。通常有更好的方法来查找该状态,使用 InheritedWidgets 或 Redux 或 BLoC 模式之类的东西。

快速回顾

总之,当您想要跨 widgets 树保留状态时,请使用 key 。这最常发生在您修改相同类型的 widgets 集合时,例如在列表中。将 key 放在您要保留的 widgets 树的顶部,并根据您在 widgets 中存储的数据选择您使用的 key 类型。