第一次在堆栈跟踪中看到“查找已禁用的组件的祖先是不安全的”这句话时,我真的不明白它的意思。我把这个错误信息输入到谷歌上搜索,找到了三个相互矛盾的Stack Overflow回答,尝试了每一个解决方法,直到其中一个方法奏效了,但当时我仍然不明白其中的原因。
这种事情在我身上发生过不止一次。每次解决方法都能解决问题,但我并没有真正理解其背后的原理——因为这些解决方法只是针对一些我尚未掌握的概念进行的补充:比如BuildContext到底是什么,以及Flutter是如何利用它来在组件树中查找所需信息的。
我花了相当长的时间才认真去学习Flutter所依赖的那些数据结构。一旦弄明白了这些,一大类错误就不再让我感到困惑了。我不再猜测为什么会出现某个错误,而是能够准确地知道它的成因——通常甚至在我运行应用程序之前就能明白原因。
这篇文章正是我早些时候就应该了解的内容。我们会深入探讨这些主题:不仅会介绍Flutter所依赖的那些数据结构,还会一步步讲解当你调用`setState`时会发生什么,从源代码的角度来理解BuildContext的本质,分析为什么有些查找操作能够成功,而有些则会失败,以及Key是如何影响Flutter决定保留哪些信息、丢弃哪些信息的。
读完这篇文章后,你应该能够几乎在任何与上下文相关的Flutter错误面前,在不查看堆栈跟踪的情况下就清楚地知道发生了什么。
目录
为什么这一点比看起来更重要
大多数Flutter开发者都是在不了解BuildContext具体含义的情况下学会使用它的。你会写`Theme.of(context)`或`Navigator.of(context)`,因为教程上是这么教的,这样做确实有效,所以你就继续使用了。在很长一段时间里,这样确实足够了。
然后有一天,你会遇到一些完全搞不懂的错误:
尝试查询已禁用的组件的父组件是不安全的。
或者:
在调用dispose()之后又调用了setState()
又或者,你编写了一些本应能够正常运行的代码,但数据就是没有出现在预期的位置上,而且根本没有任何错误提示——只有界面上的空白区域,什么反应都没有。更糟糕的是,在你删除了某些内容后,原本应该显示在列表中第三项的动画,却突然出现在了第一项上。
所有这些错误其实都源于同一个原因:人们并不了解Flutter在构建用户界面时究竟发生了什么。
在每次调用build()方法时,Flutter都会进行大量精心设计的操作,而这些操作几乎都不会被直接显示出来,除非你主动去查找它们。一旦你理解了这三类数据结构以及它们之间的协作方式,这些错误就不再令人困惑了。当你看到某个错误信息时,往往甚至还没来得及查看堆栈跟踪,就能立刻知道问题出在哪里。
Flutter所依赖的三大数据结构
这部分内容是大多数教程都会忽略的,但实际上却非常重要。
Flutter并不是只使用一种数据结构,而是使用了三种,而且它们各自承担着完全不同的功能。这三种数据结构会同时存在,并且以并行方式运行,它们的结构也是相互对应的。
组件树
组件树就是你编写代码所生成的结构。它代表了你对当前界面外观的具体要求。组件是不可变的——组件上的每一个字段都是final类型的。一旦创建了一个Text('Hello')组件,它就永远不可能变成Text('Goodbye');如果你想要替换它,就必须重新创建一个新的Text('Goodbye')组件。
// 这个Text组件仅仅是一种配置信息。
// 它只是说明了“这里应该有一个显示‘Hello’这个字符串的Text组件”。
// 它本身并不执行任何操作——
// 既不会测量自己的尺寸,也不会进行绘制,
// 甚至不知道自己最终会显示在屏幕上的哪个位置。
// 它纯粹就是一组不可变的配置数据。
const Text('Hello')
正是因为组件的不可变性,创建它们所需的成本才非常低。没有任何需要保护的可变状态,也没有任何生命周期管理的问题,只需要在内存中存储一些final类型的字段而已。在一个典型的应用程序运行过程中,Flutter会生成并销毁数以百万计的组件对象,而这种设计正是故意为之,并非效率低下的表现。
元素树
元素树是几乎没有人能够正确解释的部分,但恰恰它是解答“Flutter是如何知道哪些地方发生了变化的?”这个问题的关键所在。
当Flutter第一次需要渲染你的组件树时,它会遍历所有的组件,并为每一个组件创建一个对应的Element对象。Element是一种生命周期较长的对象,它的唯一作用就是负责管理某个特定组件在整体结构中的位置变化。
关键在于——也正是这个细节决定了其他所有事情的发展方向——当Widget树被重新构建时,Flutter并不一定会创建新的Element。相反,对于树中的每一个位置,它都会将新的Widget与之前由该Element管理的旧Widget进行比较,然后决定是直接更新现有的Element,还是将其丢弃并创建一个新的Element。
class _CounterState extends State
int count = 0;
@override
Widget build(BuildContext context) {
// 每次因为setState导致build()被执行时,
// 都会创建一个全新的Text Widget对象。
// 之前的Text Widget——也就是在上一次build()中创建的那个——会被完全丢弃;
// 再也没有任何东西保留着对它的引用。
//
// 但是,负责管理树中这个位置的Element并不会被丢弃。Flutter会查看新的Text Widget,
// 发现之前在这个位置上的Widget也是Text Widget,于是就会决定:类型相同、位置也相同——
// 就直接更新现有Element所引用的对象,而不是创建一个新的Element。
return Text('$count');
}
}
这就是为什么即使你的Widgets不断被重新创建,State对象却能够存活下来的原因:State对象实际上是由StatefulElement所拥有的,而不是由Widget本身所拥有的。每次Widget都会被丢弃并重新创建,但Element以及它所持有的State会在重建过程中继续存在,只要Flutter认为应该重用它们而不是替换它们即可。
RenderObject树
RenderObject树才是真正负责执行各种物理操作的场所——它负责测量尺寸、计算位置以及绘制像素。
你编写的大多数Widget并不会直接创建自己的RenderObject。相反,它们都是StatelessWidget或StatefulWidget》的子类,最终会组合成更基础的Widget,比如Padding、Container或Text。这些基础Widget每一个都对应着一个RenderObject》,而这个RenderObject》明确知道自己应该如何布局以及如何进行绘制。
这个RenderObject树是非常复杂的,也是导致性能问题的真正根源。布局过程就是每个RenderObject根据从其父节点继承到的约束条件来确定自己的大小,然后再告诉自己的子节点们它们需要遵守哪些约束规则。绘制过程则是每个RenderObject按照一定的顺序在画布上自己进行绘制,从而生成最终的图像。
用一句话来说:Widget用来描述你的需求,Element负责管理这种描述在整个生命周期中的状态变化,而RenderObject则负责实际的测量、定位和绘制工作,最终将像素显示在屏幕上。
调用setState时会发生什么?一步步来看
理解这三棵数据结构确实很有帮助,但只有当你真正了解在每次调用`setState`时究竟发生了什么,才能真正明白其中的原理——因为正是这个时候,这三棵数据结构才会发生交互。
步骤1 — 调用setState。
setState(() {
count++;
});
你传递给`setState`的闭包会立即且同步地执行。它所做的仅仅是修改`count`的值而已。真正的关键并不在于这个闭包本身,而在于`setState`在闭包执行完毕之后所做的事情。
步骤2 — 元素被标记为“脏”状态。
在闭包执行完毕后,`setState`会调用拥有该`State`对象的`Element`的`markNeedsBuild()`方法。此时并不会重新构建任何内容,只是将这个元素添加到Flutter需要在下一次帧绘制之前重新处理的“脏”元素列表中。
步骤3 — 下一帧到来,Flutter重新构建“脏”元素。
当引擎准备好生成下一帧时,Flutter会遍历所有被标记为“脏”状态的元素,并再次调用相应小部件的`build()`方法。
在我们的计数器示例中,这会调用我们的`build(BuildContext context)`方法,该方法会返回一个新的`Text('$count')`小部件对象。
步骤4 — 元素将新小部件与旧小部件进行对比。
这一步才是真正进行决策的关键环节,因此值得仔细研究。原本用于显示旧`Text`小部件的元素现在有了一个新的`Text`小部件来进行对比。Flutter的对比逻辑——有时非正式地被称为“差异检测算法”——会检查两件事:新小部件的`runtimeType`是否与旧小部件相同,以及(如果提供了键值对)新小部件的键是否与旧小部件的键相匹配。
如果这两项都符合条件,Flutter就会重用现有的元素。它会调用该元素的`update()`方法,将新的小部件赋值给它,此时该元素的`widget`属性就会指向新的`Text('1')`,而不是旧的`Text('0')`。此时不会创建新的元素,如果上层还有`State`对象存在,那么这些对象也不会被修改。
如果类型或键不匹配,Flutter会采取完全不同的处理方式:它会停用旧元素,将其从数据结构中移除,然后为新的小部件创建一个新的元素,并将这个新元素插入到数据结构的相应位置。旧元素所持有的所有`State`信息都会丢失,该元素也会被调用`dispose()`方法进行销毁,这些信息不会被传递给新元素。
// 当元素类型相同且位置相同时,该元素会被重用,其内部状态也会保留。
Text('0') → Text('1')
// 当元素类型不同但位置相同时,该元素会被丢弃,并创建一个新的元素,旧元素所持有的任何状态都会被清除。
Text('0') → Container(child: Text('0'))
步骤5 — 只有那些实际发生了变化的元素才会继续向下传递处理流程。
如果新的Text组件的字符串与旧的不一样,那么这个元素会通知与其关联的RenderObject,表示有关内容已经发生变化——在这个例子中,就是文本内容发生了变化,这样RenderObject就会被调度重新绘制。
如果某个组件的属性与之前完全相同(这种情况很少见,因为通常人们不会无缘无故地调用setState方法),但在一些较大的子树结构中,往往只有一部分状态发生了变化,此时Flutter就可以省去更多的处理步骤,因为在第4步进行的比较就可以直接判断是否需要重新绘制元素。
步骤6 — 布局和绘制操作在RenderObject树上进行,最终生成一个显示帧。
我们稍后会进一步详细讨论这个阶段。那些被标记为需要重新布局的RenderObject会重新计算自己的大小和位置;那些需要重新绘制的元素也会在相应的图层上重新绘制自己。然后,引擎会将这些图层组合在一起,最终将结果渲染成你在屏幕上看到的像素图像。
之所以整个讲解过程如此重要,是因为你在Flutter中听到的每一项优化技术——比如const组件、将某些组件提取出来以减少重新构建的范围、RepaintBoundary》机制——都是专门为了影响这六个步骤中的某一个或几个而存在的。
const组件的存在使得Flutter可以完全跳过与该组件相关的第3步和第4步处理流程,因为const组件的实例每次都是同一个对象,所以根本不存在需要比较的内容。将某个组件提取出来单独定义为一个类,也可以限制第3步的处理流程在树结构中传播的范围,因为setState方法只会将拥有该状态对象的元素标记为“需要重新构建”,而不会自动影响其下方的所有元素(不过,除非有特殊情况阻止它,否则Flutter还是会重新构建这个被标记为“需要重建”的元素的整个子树)。
BuildContext到底是什么
当我第一次了解到这一点的时候,我真的觉得这部分内容非常重要。我真希望当时有人能直接告诉我这些知识,而不是让我通过错误信息自己去慢慢理解。
BuildContext实际上就是一个元素。
就是这么简单。BuildContext在Flutter的源代码中被定义为一个抽象类,实际上它起着接口的作用,而Element则是实现这个接口的具体类。
当Flutter调用你的`build(BuildContext context)`方法时,它传递给你的`context`参数实际上就是那个在树结构中决定该组件位置的对象。你对`context`读取的任何属性或调用的任何方法,其实都是由这个对象自身的实现来处理的。
@override
Widget build(BuildContext context) {
// 这里的context并不是某个独立存在的辅助对象,
// 它本身就是那个负责管理该组件在树结构中位置的元素。
// 它通过`BuildContext`接口暴露给开发者,而不是直接使用`Element`类——这样做的目的之一是为了防止你在`build`方法内部意外地调用那些不应该被使用的`Element`内部方法。
return Container();
}
一旦理解了这一点,很多之前令人困惑的现象就会变得合情合理了。
为什么上下文能够了解元素的祖先信息?
因为元素们构成了一个树结构,而每个元素都会保留对其父元素的引用。当你调用像`Theme.of(context)`这样的方法时,其内部实现会从当前上下文所代表的元素开始,通过`_parent`属性逐层向上查找,直到找到一个其组件类型为`Theme`的祖先元素,然后返回该祖先元素所持有的数据。
这种查找机制之所以能够正常工作,是因为元素们在被插入树结构后就会保持这种父子关系。
// Theme.of(context)会从当前上下文所代表的元素开始,
// 逐层向上查找其祖先元素中那些组件类型为Theme的元素。
final theme = Theme.of(context);
为什么在异步操作间隔后使用上下文有时会出问题?
因为在你等待某个操作结果的过程中,你所引用的那个元素可能已经被从树结构中移除了。
当一个组件被从树结构中移除时,Flutter会调用该元素的`deactivate()`方法。被停用的元素就不再与活跃的树结构相连了——它的父元素引用可能会被清除,此时它就处于一种待处理的状态,要么会被重新插入到树结构中(比如在列表中使用`GlobalKey`移动组件),要么会被永久销毁。
如果你试图使用这个已被停用的元素的上下文来继续查找其祖先元素,Flutter会抛出我们在文章开头提到的那个错误:“尝试查询已停用的组件的祖先是不安全的”,因为此时你尝试遍历的父元素链可能已经不再反映任何有效的信息了。
Future〈void〉 _submit() async {
await someApiCall();
// 如果在上面的异步操作期间该组件被从树结构中移除了——比如用户返回到了之前的页面——
// 那么这个上下文所引用的元素就已经被停用了。
// 此时尝试使用它来获取Navigator.of(context)会引发错误,因为Flutter认为这条父元素链已经不可信任了。
Navigator.of(context).pop();
}
你可能已经使用过这个解决方法,但却并不完全了解它之所以有效的原因:
Future _submit() async {
await someApiCall();
// `mounted` 是 `State` 对象中的一个属性,用于判断持有该 `State` 对象的 `StatefulElement` 是否仍然属于活动视图树中——也就是说,它是否还没有被从视图中移除。如果在执行 `await` 操作期间该组件已被移除,那么 `mounted` 的值就会变为 `false`,此时我们应该立即返回而无需继续执行后续代码。
if (!mounted) return;
Navigator.of(context).pop();
}
现在你应该清楚地知道为什么这一行代码能够正常工作了,而不仅仅只是知道它确实有效。`mounted` 并不是被硬加到 `State` 对象上的某种“安全标志”;它实际上反映了底层组件是否仍然存在于视图树中。
“查找祖先元素”的工作原理
让我们再深入探讨一下“查找祖先元素”的机制,因为很多难以察觉的错误往往就源于这里:使用错误的上下文,或者假设某个上下文能够了解某些它实际上并不具备的信息。
你编写的每一个组件都会对应一个独立的 `Element`,这个元素在视图树中占据一个固定的位置。而这个元素只知道它上面的那些元素——也就是它的祖先元素链;它根本不知道自己的兄弟组件是什么,更不可能了解它下面的元素。
这意味着,在 `build` 方法内部可以访问的上下文,其作用范围始终局限于该组件在视图树中的具体位置,而且这种作用范围会一直持续到该元素被销毁为止。
在我真正理解这一原理之前,我曾经多次编写过这样的错误代码:
@override
Widget build(BuildContext context) {
return Scaffold(
body: ElevatedButton(
onPressed: () {
// 这个上下文实际上属于包含 `Scaffold` 的那个组件的 `build` 方法中——也就是说,在视图树中,这个上下文位于 `Scaffold` 的上一层。从这个上下文的角度来看,我们在同一个 `build` 方法中创建的 `Scaffold` 实际上应该是它的后代元素,而不是祖先元素。因此,`ScaffoldMessenger.of(context)` 需要向上搜索才能找到对应的 `Scaffold`,但在这个上下文中并没有这样的元素存在,只有下层的 `Scaffold`。在结构简单的单层应用程序中,这种情况可能不会导致错误;但在结构更复杂的视图中,这种方法要么会完全失败,要么会找到错误的 `Scaffold`。
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已保存')),
);
},
child: const Text('保存'),
),
);
}
正确的解决方法应该是获取一个实际上位于 `Scaffold` 下方的上下文,这样在向上搜索时才能正确地找到 `Scaffold`。
@override
Widget build(BuildContext context) {
return Scaffold(
// Builder是一种专门用于在构建树中的特定位置提供新的BuildContext对象的 widget。由于在这里Builder被作为Scaffold的子节点放置,因此它提供的BuildContext位于Scaffold的下方。当ScaffoldMessenger从这个上下文开始向上遍历时,它会正确地找到这个Scaffold。
body: Builder(
builder: (scaffoldContext) {
return ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(scaffoldContext).showSnackBar(
const SnackBar(content: Text('已保存')),
);
},
child: const Text('保存'),
);
},
),
);
}
这种错误在你不了解其内部机制之前看起来似乎是随机的,但一旦你了解了这个逻辑结构,它就会变得完全可预测:上下文查找的过程总是向上进行,从不会向侧面或向下移动,而你的上下文在树结构中的确切位置决定了它能够找到什么内容。
还有另一种相关的查找机制也值得了解,因为Theme.of、MediaQuery.of以及大多数.of(context)方法的内部实现都是基于这种机制的:InheritedWidget。InheritedWidget》是一种特殊的组件,当它被插入到树结构中时,任何它的子元素都可以注册为“依赖项”。
当你调用context.dependOnInheritedWidgetOfExactType时——实际上Theme.of(context)就是通过这种方式工作的——就会发生两件事:Flutter会沿着元素链向上查找,找到最接近的、类型匹配的InheritedWidget》;同时,它还会在那个父元素上记录下你的当前元素依赖于它这一信息。
第二部分的作用其实比听起来要重要得多。因为一旦某个元素被注册为依赖项,而对应的InheritedWidget》发生了变化,并且它的updateShouldNotify方法返回了true,Flutter就会自动安排所有注册的依赖项进行重新构建——而你根本不需要编写任何订阅或监听相关的代码。
这就是整个机制的核心所在,正是它让Theme.of(context)能够在应用程序的主题发生变化时自动更新用户的界面。这并不是通过轮询来实现的,也不是基于数据流来工作的,而是通过在查找过程中直接在元素上注册依赖关系来完成的。实际上,Provider以及其他几种状态管理方法也是建立在这一机制之上的。
RenderObjects:布局和绘制实际发生的地点
我们之前曾经简要提到过RenderObjects,但它们确实值得我们仔细研究,因为正是这些对象构成了生成最终视觉效果的树结构,同时也是直接影响应用程序性能的关键部分。
每一个RenderObject都会参与两个主要阶段的处理:布局和绘制。
布局是一个严格受限的过程。它从根节点的RenderObject开始,这个节点会接收到屏幕的完整尺寸作为约束条件。每个RenderObject都会根据父元素给出的约束条件(通常是宽度和高度的最小值和最大值)来确定自己的大小,然后再将这些约束条件传递给它的子元素,让子元素们依次确定自己的大小。当所有子元素都反馈了自己的尺寸后,父节点才会对它们进行定位,并最终确定自己的大小。
从概念上来说,这种布局过程是持续不断进行的,尽管你从未直接编写过相关的代码——Flutter的框架会根据你组合的组件自动完成这个过程。
child>“根据这些条件,我需要一个宽度为120像素、高度为40像素的区域。”
parent>“明白了。我会将你放置在我自己的坐标系中的(10, 20)位置上。”
正是这种先向下再向上的布局流程,使得Flutter的布局系统能够在处理复杂的组件树结构时也不会导致性能下降:在正常情况下,每个RenderObject每帧只会被布局一次。因此,布局操作的复杂度与树中RenderObject的数量成正比,而不需要进行重复的计算或回溯操作。
在布局完成之后,绘制过程才会开始。每个RenderObject都会获得一个Canvas对象——或者更准确地说,它会向PaintingContext提供绘制指令——然后自行进行绘制:RenderParagraph会绘制文字字符,RenderImage会绘制像素数据,而DecoratedBox的RenderObject则会绘制背景颜色或边框。
这些绘制指令会被组织成不同的图层,然后引擎会将这些图层合成在一起,最终生成出要显示在屏幕上的图像。
这也解释了为什么有些属性在性能上“不会造成负担”,而有些则会产生影响。修改Opacity或应用Transform>通常可以在合成阶段完成——GPU只需调整已经绘制好的图层的混合方式或位置,而Flutter完全不需要重新计算底层RenderObject的布局或重新进行绘制操作。
// 这个过程完全可以在合成阶段完成。
// myWidget的RenderObject不需要重新绘制,
// GPU只会调整已绘制图层的显示位置而已。
Transform.translate(
offset: const Offset(10, 0),
child: myWidget,
)
然而,如果修改Text组件中的文本内容,那么该RenderObject确实需要重新计算字符的布局并重新绘制文字,因为此时像素数据本身已经发生了变化,而不仅仅是位置或混合效果发生了改变。
正是为了解决这个问题,Flutter的新渲染后端Impeller在管线中的某个环节——即着色器编译阶段——对 Flutter框架所需的着色器程序进行了预先编译,从而避免了这类性能问题。
在基于Skia的旧版管线中,每当某种视觉效果首次出现在屏幕上时,GPU驱动程序都需要即时编译相应的着色器程序。这种编译过程可能会花费较长时间,从而导致一次性的性能波动——“着色器编译延迟”。
而Impeller通过在构建阶段预先编译这些着色器程序,有效地解决了这个问题。
Key类型:ValueKey、ObjectKey和GlobalKey的正确理解
在前面关于setState机制的讲解中,我们已经详细了解了数据匹配的过程,现在Key类型的概念应该会更加清晰了。事实上,Key类型正是Flutter用来确定对象身份的信息机制——当仅凭类型信息不足以区分对象时,Key类型就能提供必要的额外信息。
回想一下之前的第4步:当在给定位置比较新的部件和旧的部件时,Flutter会检查runtimeType,如果提供了key,也会进行对比。
如果没有key,Flutter就会仅根据部件在父组件子列表中的位置以及类型来进行对比。只要子组件的排列顺序不会发生变化,这种比较方式是可行的。但一旦你重新排序、在列表中间插入或删除某些部件,基于位置的匹配机制就会导致错误的旧部件与新的部件被配对在一起。
// 如果没有key,当你从这个包含三个ItemCard的列表中移除第一个元素时,Flutter会认为:原本位于位置0的ItemCard(item1)现在被替换成了同类型的ItemCard(item2)——由于类型相同,所以它会重用原来的Element,只是更新其对应的部件引用。但它无法知道,实际上应该是在位置1的ItemCard(item2)才应该被重新使用到位置0。Column( children: items.map((item) => ItemCard(item: item)).toList(), )
对于那些完全没有状态的数据展示部件来说,这种匹配错误通常不会产生明显的影响——因为这些部件本来就没有状态信息。
但一旦每个数据项都包含自己的内部状态,比如TextEditingController>、Dismissible的拖动偏移量,或者用于控制逐个数据项动画的AnimationController>,这种匹配错误就会导致严重的问题。例如,文本字段可能会显示别人的内容,或者淡入动画会在错误的数据项上触发。
ValueKey是一种非常适合在每个数据项都有一个简单、稳定且唯一的标识值时使用的工具——这类标识值通常就是ID。
Column(
children: items.map((item) {
// ValueKey会将一个单一的值封装起来,并在匹配时使用标准的相等运算符(==)来进行比较。两个封装了相同值的ValueKey本身也被视为相等的,而这正是我们所需要的——因为这样,无论数据项在列表中的位置如何变化,item.id总能可靠且唯一地标识它。
return ItemCard(
key: ValueKey(item.id),
item: item,
);
)
ObjectKey则适用于当你希望Flutter根据对象的身份来进行比较时——也就是说,要判断这两个对象在内存中是否确实是同一个对象。当你的数据项没有合适的唯一标识字段,或者你明确希望两个值相同但实际是不同对象的情况时,ObjectKey是非常实用的。
Column(
children: items.map((item) {
// ObjectKey使用`identical()`方法而不是`==`来进行比较。即使两个对象的所有字段值都完全相同,它们也会被视为不同的对象,因为它们在内存中是两个不同的实例。
return ItemCard(
key: ObjectKey(item),
item: item,
);
)GlobalKey是一种截然不同的工具,而且人们往往在不必要的情况下就会使用它。
ValueKey或ObjectKey只在与自身的子节点列表进行对比时才具有意义;在那种局部比较之外,它们没有任何作用。
GlobalKey则不同,它被注册在一个整个应用都会共享的全球性注册表中。这意味着,无论你当前位于代码结构的哪个位置,都可以通过这个全局键来获取某个组件的Element、State,甚至是RenderObject。
class _FormScreenState extends State〈FormScreen〉 {
// GlobalKey〈FormState〉会将这个键注册到全局注册表中,
// 并将其与整个应用中当前拥有这个键的任何Form组件关联起来。
final _formKey = GlobalKey〈FormState〉();
void _submit() {
// currentState会查询全局注册表,
// 找到与该GLOBAL KEY关联的Element,
// 然后返回它的State对象——在这个例子中,就是FormState对象。
// 无论(_submit)方法是在组件的哪个位置被调用的,这一过程都是如此。
// 这与普通的上下文查找机制截然不同,后者只能从固定的起点向上进行查询。
if (_formKey.currentState!.validate()) {
// 继续执行提交操作
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
validator: (value) =>
value!.isEmpty ? 'Required' : null,
),
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
);
}
}
GlobalKeys确实非常强大。目前没有其他内置的方法可以从组件所在的子树之外访问其内部状态。
但使用它们也会带来一定的代价。由于所有的GLOBALKey都存储在全局注册表中,因此每当代码结构发生变化时,Flutter就必须进行额外的处理来保持该注册表的一致性。此外,一个GLOBALKey在整个应用中必须是唯一的,而不仅仅是在某个特定的列表中唯一。例如,在一个较长的列表中的每个元素上都使用GLOBALKey,就会导致每次列表重新构建时,这种维护成本都会成倍增加。
我个人曾经多次选择使用GlobalKey>来解决某些问题,而这些问题其实可以通过正确放置ValueKey>,或者利用提供适当上下文的Builder>来更轻松、更低耦合度地解决。
正确的使用方式是:只有当你确实需要从组件所在的子树之外访问其状态时,才应该使用GlobalKey>——而不是每当遇到某个键看起来似乎有用时,就默认选择使用它。
常见的渲染错误及其避免方法
这些都是我亲自遇到过的错误,而这些错误全都与我们刚才讨论的某些机制直接相关。
在“dispose”之后调用“setState”
当某个异步操作的执行时间超过了启动它的组件的生命周期时,就会发生这种情况。该组件会被停用并被销毁,而对应的Future对象却仍然处于未完成状态。
解决这个问题的方法就是使用我们之前介绍过的mounted检查机制。现在我们可以彻底解释为什么这种方法有效了:mounted这个方法可以判断底层组件是否仍属于活动组件树的一部分,而这一点正是决定在何时调用setState才安全的关键。
使用错误的上下文进行查找操作
我们在前面通过ScaffoldMessenger的例子讨论过这个问题。其根本原因始终是一样的:你使用的上下文在元素树中的位置与你要查找的数据的位置并不对应,因为查找操作只能向上进行搜索。解决方法也始终是相同的:确保使用正确的上下文,通常可以通过Builder来实现这一点。
在重新排序列表时丢失或混淆状态信息
当列表中那些具有状态信息的相似组件没有唯一的键值对时,Flutter基于位置进行的重新排序机制就会在插入、删除或重新排序操作中错误地复用原有的组件。
解决这个问题的方法是为每个列表项添加一个基于稳定且唯一标识符的ValueKey,绝对不能使用列表索引作为键值对,因为当列表项被重新排序或删除时,索引值肯定会发生变化,而使用索引作为键值对就会违背设置键值对的初衷。
// 错误的做法——使用索引作为键值对会完全违背设置键值对的目的。
// 每当列表被重新排序或有项目被删除时,索引值都会发生变化,因此Flutter无法通过索引来区分不同的项目。
ItemCard(key: ValueKey(index), item: item)
// 正确的做法——使用项目自身的唯一标识符作为键值对,这样无论该项目在列表中的位置如何变化,这个唯一标识符都不会改变。
ItemCard(key: ValueKey(item.id), item: item)
动画意外重新启动,或在没有目标组件的情况下播放
这种情况通常与列表重新排序的问题密切相关。当AnimationController被放在每个项目对应的StatefulWidget>内部时,由于没有使用键值对进行匹配,系统会错误地将旧的组件与新的组件关联起来,从而导致动画意外重新启动或在错误的组件上播放。
不必要的重建操作比预期范围更广
这实际上与我们对setState功能的讲解相呼应:调用setState会使得该元素被视为“脏状态”,而Flutter默认会重新构建该元素的整个子树结构,除非有某些因素干扰这一过程——例如某个const widget的存在会阻止重新构建过程的进行,或者将相关状态提取到树结构中更低的层级、更具体的StatefulWidget对象中。
端到端示例
这里有一个完整的示例,展示了如何正确使用上下文以及各种组件如何协同工作——这个示例中包含了一个可关闭的任务列表,其中每个任务项都拥有自己的复选框状态。
import 'package:flutter/material.dart';
class Task {
final String id;
final String title;
bool isDone;
Task({required this.id, required this.title, this.isDone = false});
}
class TaskListScreen extends StatefulWidget {
const TaskListScreen({super.key});
@override
State createState() => _TaskListScreenState();
}
class _TaskListScreenState extends State {
final List _tasks = [
Task(id: '1', title: '撰写文章'),
Task(id: '2', title: '练习现场编码'),
Task(id: '3', title: '复习GDE备考题'),
];
void _removeTask(String id) {
setState(() {
_tasks.removeWhere((task) => task.id == id);
});
}
void _showSnackbar(BuildContext scaffoldContext, String message) {
// 这个上下文被正确地放置在Scaffold的下方,因为它是通过列表项内部的Builder传递进来的,而不是通过这个State自己的build方法传递的——后者位于Scaffold的上方。
ScaffoldMessenger.of(scaffoldContext).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('任务列表')),
body: ListView.builder(
itemCount: _tasks.length,
itemBuilder: (context, index) {
final task = _tasks[index];
// 使用任务的ID作为ValueKey,而不是索引。如果某个任务被删除,Flutter会利用这个键来将剩余的Dismissible元素及其携带的拖动偏移状态正确地关联到对应的任务上,而不会错误地关联到当前占据该索引位置的另一个任务。
return Dismissible(
key: ValueKey(task.id),
onDismissed: (_) {
_removeTask(task.id);
},
background: Container(color: Colors.red),
child: Builder(
// Builder提供的上下文位于Scaffold的下方,因此通过这个上下文,ScaffoldMessenger可以从这个子树中正确地找到对应的Scaffold。
builder: (itemContext) {
return CheckboxListTile(
title: Text(task.title),
value: task.isDone,
onChanged: (value) {
setState(() {
task.isDone = value ?? false;
});
_showSnackbar(
itemContext,
'\({task.title} 已标记为 \){value == true ? "已完成" : "未完成"}',
);
},
);
},
),
);
},
),
);
}
}
试着去掉ValueKey,然后以不同的顺序完成并关闭几项任务。你会开始发现一些细微的状态混乱现象出现,尤其是当你为每个项目都添加一个AnimationController时。
这篇文章所讨论的正是这种错误类型,而且你可以在自己正在运行的应用程序中直接观察到这些错误。
最后的思考
我以前总是把BuildContext看作是一个必须使用才能让Flutter API正常工作的“魔法参数”;但现在我认为,它其实就是它的本来面目:一个指向特定Element的引用,这个Element位于Flutter精心维护的树结构中的某个特定位置。Flutter通过这种树结构来管理我所描述的各个组件与实际显示在屏幕上的像素之间的关系。
这种认知上的转变不仅帮助我解决了某一类错误,还让其他那些理解得不够透彻的Flutter概念也变得清晰起来。
InheritedWidget、Theme.of、Navigator.of、mounted的检查逻辑、GlobalKey》,甚至为什么使用const声明的组件能够提升性能——这些都不是需要单独记忆的技巧。它们其实都是同一套底层系统所导致的不同结果罢了:这三棵树的结构彼此相似,每当有东西发生变化时,Flutter都会仔细地调整它们的关系。
如果从这篇文章中你能学到什么的话,那就是:下次当你遇到与上下文相关的错误时,不要仅仅去寻找解决方法,而应该思考一下:在你想使用这个上下文的那一刻,对应的Element在树结构中的位置是否仍然正确——它是否仍然被正确地挂载在了树上,是否仍然与其父组件保持连接。
一旦你能本能地回答这个问题,那么一大类Flutter错误就会不再神秘难懂,而会变成你在运行应用程序之前就能预测到的问题。