Widget基础系列 - Key

在Flutter中,Widget可以说是第一基础概念。Widget是对用户界面的不可变描述,可被膨化为管理底层渲染树的Element。

理解Widget原理是掌握Flutter编程至关重要的一步,本系列主要介绍Widget的基础知识,本文是第四篇:

  • StatelessWidget
  • StatefulWidget
  • InheritedWidget
  • Key

引言

你应该已经注意到了,每个widget的构造函数都有一个key参数,这个参数的作用是什么呢?Key用于在widget的位置改变时保留其状态。比如,保留用户的滑动位置,或者在保留widget状态的情况下修改一个widget集合,如Row、Column等。本篇文章,我们会讨论以下几点:

  • Key是什么?
  • 何时需要使用Key?
  • 应该在何处使用Key?
  • 应该使用什么类型的Key?

Key是什么?

简而言之,Key是Widget、Element和SemanticsNode的标识符。

只有当新widget的key值与element当前关联widget的key值相等时,新widget才会被用于更新element。

拥有相同父节点的所有Element的key值必须是唯一的。

Flutter中常见的Key有:

- LocalKey
  - ObjectKey
  - UniqueKey
  - ValueKey
    - PageStorageKey
- GlobalKey
  - GlobalObjectKey
  - LabeledGlobalKey

何时需要使用Key?

大多数时候不需要,不过当需要在一个相同类型的、有状态的widget集合中添加、删除或调整顺序时,就需要使用Key了。比如,在一个待办事项App中,我们需要可以执行添加新事项、根据优先级调整事项顺序、在完成事项后移除它们等操作。

先通过一个简单的例子来了解一下为什么需要使用Key。下面是一个随机数列表:

random_num_stateless

目前RandomNum是一个StatelessWidget

class RandomNum extends StatelessWidget {
  final int num;
  RandomNum(): num = Random().nextInt(1000 * 1000), super();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(8),
      child: Text('$num'),
    );
  }
}

当我们点击Reorder按钮时,随机数列表会重新排序,一切正常。然后我们将RandomNum改为StatefulWidget

class RandomNum extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => RandomNumState();
}

class RandomNumState extends State<RandomNum> {
  int num;

  @override
  void initState() {
    num = Random().nextInt(1000 * 1000);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(8),
      child: Text('$num'),
    );
  }
}

此时,再点击Reorder按钮,屏幕上的数字没有变化。怎么回事呢?

我们知道,在Flutter中,每个Widget都对应一个Element。Element树其实是非常简单的,仅保存了关联的widget的类型以及指向子element的连接。可以将element树看作app的骨架,它展示了app的结构,但是所有的附加信息需要通过指向widget的引用来查找。

random_num_stateless_tree

这是无状态版本的widget和element树。当我们改变items的顺序时,Flutter会遍历element树以确认结构是否改变,从Column对应的element开始,一直到所有的子孙结点。对于每个element,Flutter会检查新widget的类型和key与当前引用的widget的类型和key是否一致,如果一致就将引用指向新的widget。对于RandomNum来说,由于没有key,因此Flutter只会检查其类型。

random_num_stateless_tree_swapped

当我们改变items的顺序后,widget树会重建,但由于结构与之前一致,所以element树结构并不会改变,只不过element指向的widget引用发生了变化。对于StatelessWidget来说,这并没有什么问题,所有的信息都保存在widget中,只要改变widget树就可以了。

random_num_widget_element_tree_stateful

RandomNum变为StatefulWidget后,widget树、element树与之前一样,但是增加了关联的State对象。此时,num不再保存在widget中,而是保存在State对象中。当我们调整widget的顺序后,Flutter依然会遍历element树,检查树结构是否改变,并更新指向widget的引用。

random_num_widget_element_tree_stateful_swapped

Flutter根据element树以及关联的State对象来确定屏幕上的实际显示内容。因此,屏幕显示内容不会改变。

现在,我们给RandomNum增加一个Key:

class _RandomNumAppState extends State<RandomNumApp> {
  List<RandomNum> items;
  
  @override
  void initState() {
    items = [
      RandomNum(key: UniqueKey()),
      RandomNum(key: UniqueKey()),
      RandomNum(key: UniqueKey()),
      RandomNum(key: UniqueKey()),
      RandomNum(key: UniqueKey()),
      RandomNum(key: UniqueKey()),
    ];
    super.initState();
  }
  ...
}

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

  @override
  State<StatefulWidget> createState() => RandomNumState();
}
random_num_widget_element_tree_stateful_key

当我们改变items的顺序时,Flutter遍历element树以检查是否需要更新。Column与之前一样,直接将widget引用指向新的widget即可。对于RandomNum的element来说,由于element的Key值与对应widget的Key值不同,因此Flutter会使这个element暂时失效,并移除对它的引用以及其对widget的引用。

random_num_widget_element_tree_no_reference

然后,从第一个不匹配的widget开始,Flutter会在所有失效的子结点中查找具有对应Key值的element,如果找到了,则将这个element的widget引用指向到这个widget。接着对第二个widget做相同的操作,以此类推。

random_num_widget_element_tree_with_reference

当遍历结束之后,更新element树的引用,此时widget、element、state就对应起来了。

random_num_widget_element_tree_stateful_updated

总结来说,当我们需要更新一个有状态的、同类型的widget组成的集合时需要使用Key来保留widget的状态。

应该在何处使用Key?

当我们需要使用Key时,应该将Key用在widget树的什么位置呢?需要保存状态的widget子树的顶层。我们刚才一直在谈论state,你或许会认为应该用在第一个StatefulWidget上,不过这是错误的!!

将上面的例子略作修改,我们将RandomNum这个StatefulWidget包裹在一个Padding里面:

class _RandomNumAppState extends State<RandomNumApp> {
  List<Padding> items;

  @override
  void initState() {
    items = [
      Padding(
        padding: EdgeInsets.all(4),
        child: RandomNum(key: UniqueKey()),
      ),
      Padding(
        padding: EdgeInsets.all(4),
        child: RandomNum(key: UniqueKey()),
      ),
      Padding(
        padding: EdgeInsets.all(4),
        child: RandomNum(key: UniqueKey()),
      ),
      Padding(
        padding: EdgeInsets.all(4),
        child: RandomNum(key: UniqueKey()),
      ),
    ];
    super.initState();
  }
}

再次运行程序,我们发现所有的数字每次都重新生成了一遍,怎么回事呢?先来看一下添加Padding之后的widget和element树。

random_num_padding_widget_element_tree

当我们改变RandomNum结点的位置时,Flutter的widget-to-element匹配算法每次在树中查找一级。我们先看第一层,即Padding层,暂时忽略其它结点,每次只看一层。

random_num_padding_only_widget_element_tree

可以看到,对于Padding这一层来说,调整RandomNum的顺序之后,匹配关系并没有发生什么变化,Padding还没有Key,因此只需要比较其类型,显然类型都一样,更新element指向widget的引用即可。

然后来看第二层,即第一个Padding对应的子树:

random_num_padding_subtree

Element的key值与widget的key值不匹配,因此Flutter会使这个element失效并移除对它的连接。我们在例子中使用的是本地Key,这意味着Flutter只会在一个层级中使用这个Key值来匹配widget和element。由于无法在同级找到拥有相同Key值的element,因此Flutter会重新创建一个新的element并赋予新的State对象。所以,我们看到所有的数字都重新创建了。

如果我们将Key用在Padding上,Flutter就会感知到变化并正确地更新连接,就像上面的例子一样。

class _RandomNumAppState extends State<RandomNumApp> {
  List<Padding> items;

  @override
  void initState() {
    items = [
      Padding(
        key: UniqueKey(),
        padding: EdgeInsets.all(4),
        child: RandomNum(),
      ),
      Padding(
        key: UniqueKey(),
        padding: EdgeInsets.all(4),
        child: RandomNum(),
      ),
      Padding(
        key: UniqueKey(),
        padding: EdgeInsets.all(4),
        child: RandomNum(),
      ),
      Padding(
        key: UniqueKey(),
        padding: EdgeInsets.all(4),
        child: RandomNum(),
      ),
    ];
    super.initState();
  }
  ...
}
random_num_padding_widget_element_tree_key

应该使用什么类型的Key?

我们已经知道何时需要使用Key,以及应该在何处使用Key。不过,如果我们看一下Flutter的文档,就会发现有很多类型的Key,那么应该使用什么类型的Key呢?

LocalKey

当我们修改一个widget集合时,就像上面的将一组数字重新排序那样,只需要与其它widget的key区分开来即可。对于这种情况,可以根据widget中保存的信息来做选择。

ValueKey

相等性由其value属性确定。

在一个待办事项列表中,如果每个列表项的文字是唯一的,那么ValueKey是不错的选择,将文字作为其值:

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

ObjectKey

相等性由其Object类似的value属性确定。

如果widget中保存的是复杂信息的组合呢?比如在一个通讯录App中,每个人的信息有很多项:

AddressBookEntry:
  FirstName: Hob
  LastName: Reload
  Birthday: July 18
  
AddressBookEntry:
  FirstName: Ella
  LastName: Mentary
  Birthday: July 18
  
AddressBookEntry:
  FirstName: Hob
  LastName: Thyme
  Birthday: February 29

任何一项信息可能都不是唯一的,姓名、出生日期都可能重复,不过信息的组合是唯一的。对于这种情况,ObjectKey或许是最合适的。

UniqueKey

只与自己相等。

如果多个widget的值相同,或者想要确保每个Key的唯一性,可以使用UniqueKey。在上面的例子中使用的就是UniqueKey,因为我们没有在widget中保存任何不变且唯一的数据,数字需要等到RandomNum构建或initState时才能确定。

PageStorageKey

定义PageStorage的值存放在何处的ValueKey

滚动列表(ScrollPosition)使用PageStorage保存滚动位置,每次滚动停止时都会更新PageStorage中保存的值。

void didEndScroll() {
  activity.dispatchScrollEndNotification(copyWith(), context.notificationContext);
  if (keepScrollOffset)
    saveScrollOffset();
}

@protected
void saveScrollOffset(){
  PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels);
}

PageStorage用于保存和恢复比widget生命周期长的数据,这些数据保存在一个per-route的Map中,Key由widget的PageStorageKey和其祖先结点来决定。要使widget重建时能够找到保存的值,key值的identity在每次widget构建时必须保持不变。

例如,为了确保TabBarView重建时每个MyScrollableTabView的滚动位置都能被恢复,我们为其指定了PageStorageKey

TabBarView(
  children: myTabs.map((Tab tab) {
    MyScrollableTabView(
      key: PageStorageKey<String>(tab.text), // like 'Tab 1'
      tab: tab,
    ),
  }),
)

GlobalKey

在整个App中唯一的Key。

GlobalKey唯一标识了一个element,通过GlobalKey可以访问与此element关联的其它对象,比如BuildContext。对于StatefulWidget来说,可以通过GlobalKey访问其State

与上面介绍的LocalKey不同,含有GlobalKey的widget在移动位置时可以改变父结点。与LocalKey相同的是,位置的移动必须在一个动画帧内完成。

例如,我们想在不同的页面展示同一个widget,同时保持其状态,就需要使用GlobalKey。

GlobalKey的成本比较高,如果不是为了上面的两个目的,即在保持widget状态的情况下更换父节点,或者需要访问在widget树中完全不同部分的widget中的信息,可以考虑使用上面介绍的LocalKey。

总结

当在widget树中更换位置时,使用Key来保持状态。最常见的场景是修改一个相同类型widget组成的集合,例如一个列表。将Key放在希望保持其状态的widget子树的顶部,根据widget中保存的信息类型选择合适的Key。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容