Widget 是配置描述,不可变、极轻量,只是告诉框架「我想要什么样的界面」,每次 build 都会重新创建一批。RenderObject 负责真正的布局和绘制,创建成本高,持有布局结果和绘制逻辑。Element 是两者之间的桥梁,代表 Widget 在树上某个位置的实例,它持有状态、管理生命周期,并且是唯一长期存活的那一层。
为什么需要中间这层?核心是性能。Widget 每次 setState 都全部重建,如果直接对应 RenderObject,就等于每一帧都在销毁重建所有渲染对象,成本无法承受。Element 树的作用是做新旧 Widget 的比对:新的 Widget 来了,Element 判断能不能复用自己对应的 RenderObject,如果能,就只更新 RenderObject 上变化的属性,树结构完全不动。
这样就形成了一个分工:Widget 层可以随便重建(因为它便宜),Element 层承担 diff 和状态保存,RenderObject 层保持稳定只做增量更新。State 对象挂在 Element 上,这也解释了为什么 Widget 是不可变的却能保存状态——状态压根不在 Widget 里。
顺带说 BuildContext 其实就是 Element 本身,Element 类实现了 BuildContext 接口。所以 context 能往上查找祖先节点、能拿到 RenderObject 的尺寸,因为它就是树上的那个节点。理解这一点,很多「为什么 context 在这里不能用」的问题就清楚了。
复用判断在 Widget.canUpdate,条件是新旧 Widget 的 runtimeType 相同、并且 key 相同(都为 null 也算相同)。满足就复用 Element 和 RenderObject,只更新配置;不满足就销毁旧的、创建新的,State 也跟着丢掉。
关键问题出在列表上。框架在同一层级里是按索引顺序比对的,如果不加 Key,把列表第一项删掉,原来第二项跑到了索引 0 的位置,框架发现类型相同就复用了原来第 0 个 Element,于是State 留在了原位置——表现出来就是:删掉第一个 item,结果消失的内容是对的,但某个 item 的勾选状态、输入框内容、动画进度串到别的 item 上去了。
所以规则是:列表里的元素是有状态的(StatefulWidget),并且列表会发生增删或重排时,必须加 Key,而且要用能唯一标识数据的业务 ID 做 ValueKey,不能用索引(用索引等于没加,索引本身就会变)。如果列表项是无状态的,不加也没问题。
Key 分两类。LocalKey(ValueKey、ObjectKey、UniqueKey)只在同一父节点下的兄弟之间比较。GlobalKey 是全局唯一的,能力更强——可以跨树位置保持 State(把一个 Widget 从树的一处移到另一处而不丢状态),还能通过它直接访问其他 Widget 的 State 和 RenderObject。但 GlobalKey 代价高:它要维护一张全局注册表,而且跨位置移动会触发额外的重建。所以只在真正需要时用,比如拿 Form 的状态做校验、获取某个组件的尺寸位置。不要用 GlobalKey 当作组件间通信的手段,那说明状态管理设计有问题。
一句话概括布局规则:约束往下传,尺寸往上返,位置由父节点决定。父节点给子节点一个 BoxConstraints(最大最小宽高),子节点在这个范围内决定自己的尺寸并返回给父节点,然后父节点决定把它放在哪。整个过程一次深度遍历完成,这是 Flutter 布局性能好的原因——不像 Web 那样可能多次回流。
约束分两种:紧约束(最大等于最小,尺寸被父节点定死)和松约束(最小为 0,子节点可以自己决定)。理解一个组件的行为,关键就是看它给子节点传的是什么约束。
unbounded 的意思是某个方向的最大约束是无限。典型场景是 Column 里嵌 ListView:Column 在主轴方向给子节点的是无限高约束(因为它想让子节点先说自己要多高),而 ListView 默认想占满父节点给的全部高度,于是它拿到无限高,就没法确定自己该多高了,直接报错。同类问题还有 SingleChildScrollView 里放 ListView、Row 里放横向滚动列表。
解法看意图。想让 ListView 占满剩余空间用 Expanded 或 Flexible,它会把无限约束转成紧约束。想让 ListView 按内容高度撑开用 shrinkWrap: true,但要注意这会让它提前计算所有子项的高度,失去懒加载优势,列表长了会卡,所以只适合项数很少的情况。给固定高度用 SizedBox。真正的长列表混合布局应该用 CustomScrollView 加各种 Sliver,这才是正确解法。
还有个相关知识点:LayoutBuilder 能拿到父节点传下来的约束,用来做响应式布局。但它会导致布局分两个阶段,用多了影响性能,而且不能在需要 intrinsic 尺寸的地方用。
RepaintBoundary 起什么作用?★★★一帧的流程是:动画回调 → build(构建)→ layout(布局)→ paint(绘制)→ composite(合成)→ 光栅化上屏。build 生成 Widget 树的变更,layout 确定每个 RenderObject 的尺寸位置,paint 把绘制指令记录到 Layer 上,composite 把 Layer 树合成,最后交给 GPU 光栅化。
关键是脏标记加边界的机制。某个节点需要重新布局时调 markNeedsLayout,这个标记会向上冒泡,直到遇到一个 relayout boundary 才停下。所谓 relayout boundary 是指「这个节点的尺寸不受子节点影响」的节点,比如父节点给的是紧约束时,子节点怎么变都不影响自己的尺寸,那就没必要往上传。这样布局的重算范围被限制在局部。
RepaintBoundary 是绘制层面的同类机制:它让子树拥有独立的 Layer,子树重绘时不会影响外部,外部重绘也不影响它。典型用法是给频繁重绘的区域包一层,比如一个不停转的动画、一个视频播放区域,这样它每帧重绘时,周围静态的内容不用跟着一起重绘。
但不能滥用。每个 RepaintBoundary 都要额外分配一块图层内存,还增加合成阶段的开销。加得太多反而更慢。判断该不该加,用 DevTools 的 debugRepaintRainbowEnabled,开启后每次重绘的区域会闪色框,能直观看出哪些区域在不必要地重绘。
补充一个实践点:ListView 默认已经给每个 item 加了 RepaintBoundary(addRepaintBoundaries 默认 true),所以列表项不用自己再包。反过来如果 item 特别简单,关掉它反而更快。
setState 做了什么,为什么它可能很贵?★★★setState 本身只做两件事:执行你传进去的回调改状态,然后调用 Element.markNeedsBuild 把当前 Element 标记为脏,并注册到下一帧的重建列表里。它不是同步立即重建,而是等下一帧统一处理,所以连续调用多次只会重建一次。
它「贵」的地方在于重建的范围是整个 build 方法。如果你在一个大页面的顶层 State 里调 setState,那整个页面的 Widget 树都会重新构建一遍——即使实际变化的只是角落里一个数字。虽然 Element 层会做 diff、大部分 RenderObject 能复用,但Widget 对象的创建和 diff 本身就有成本,树很大时这个成本很可观,尤其是 build 方法里还包含了复杂的条件判断或者数据计算。
所以优化的思路是缩小重建范围。几个具体手段:把变化的部分拆成独立的小 StatefulWidget,setState 只影响那一小块;用 const 构造 Widget,const 实例在 canUpdate 时是同一个对象,可以直接跳过;用 ValueListenableBuilder、Selector 这类只重建局部的组件;把不变的子树提取成变量,避免在 build 里重复创建。
还有几个常见错误值得提:在 build 里调 setState 会导致无限循环;在 dispose 之后调 setState 会报错,异步回调里要先判断 mounted;在 initState 里调 setState 没必要也可能报错,直接赋值就行。异步请求返回后 setState 忘记判断 mounted,是实际项目里最常见的报错来源之一。
Impeller 是 Flutter 自研的渲染引擎,用来替代原来的 Skia。它现在已经是 Android、iOS 的默认渲染器,桌面端(Windows、Linux、macOS)在 Flutter 3.47 也转为默认,Skia 只在少数不支持 Vulkan 的老设备上作为回退。
它解决的核心问题是着色器编译卡顿。Skia 的着色器是运行时按需编译的,当界面上出现一个新的绘制组合(某种渐变、某种模糊、某个新的动画效果),就要现场编译对应的着色器,这个过程可能耗时几十甚至上百毫秒,直接导致掉帧。表现出来就是首次进入某个页面、首次播放某个动画时明显卡一下,第二次就流畅了。以前的缓解手段是「着色器预热」,先跑一遍把用到的着色器录下来打进包里,但这个方案很脆弱,录不全或者版本一变就失效。
Impeller 的做法是把着色器集合固定下来并在编译期预先编译,运行时不再需要现场编译,从根上消除了这类卡顿。同时它针对现代图形 API 重新设计,iOS 上用 Metal、Android 上用 Vulkan,能更好地利用 GPU 特性,并且渲染时间的可预测性更好——这一点对动画流畅度比「平均更快」更重要。
实际影响:新项目不用再折腾着色器预热了。要注意的是历史上 Impeller 刚切换默认时,一些自定义绘制、特殊的 BackdropFilter 用法、以及部分第三方渲染插件出现过表现差异或性能回退,遇到渲染异常可以用 --no-enable-impeller 临时回退验证,但这不是长期方案。
两套协议传递的东西完全不同。Box 协议父节点传 BoxConstraints(最大最小宽高),子节点返回一个 Size。Sliver 协议父节点传 SliverConstraints,子节点返回 SliverGeometry。
SliverConstraints 里最关键的几个字段是滚动相关的:scrollOffset 表示我已经被滚出去多少了,remainingPaintExtent 表示视口里还剩多少可见空间给我,remainingCacheExtent 是含预加载区域的剩余量。返回的 SliverGeometry 里,scrollExtent 是「我总共要占多长的滚动距离」,paintExtent 是「我当前实际画了多长」。
这两个值可以不相等,这就是懒加载的根基。Box 协议下一个节点必须知道自己完整的尺寸才能返回,一千条数据就得全部布局出来才知道总高。而 Sliver 可以说「我总共占十万像素的滚动距离,但我现在只画了屏幕内这 800 像素」——只对可见区域做布局和绘制,剩下的仅用一个数字声明存在。所以 ListView.builder 底层就是 SliverList 配 SliverChildBuilderDelegate,只处理可见范围加 cacheExtent 那一圈。
由此也能解释为什么混合布局必须用 CustomScrollView 而不是 Column 里套几个 ListView。CustomScrollView 里所有 Sliver 共享同一个 Scrollable 和 Viewport,也就是一个滚动上下文,它们能感知彼此的滚动位置,所以 SliverAppBar 才能正确吸顶、SliverList 才能正确懒加载。而 Column 里的每个 ListView 都是独立的滚动上下文,一是手势归属会打架,二是为了在 Column 里确定高度往往被迫 shrinkWrap,直接把懒加载优势废掉了。
实践上知道这套协议的意义在于:遇到「头部折叠加多个列表加吸顶标签」这种需求,第一反应就该是拆成若干 Sliver 塞进一个 CustomScrollView,而不是嵌套滚动组件再想办法压住冲突。
Dart 是单线程加事件循环的模型。一个 Isolate 里有两个队列:microtask 队列和 event 队列。事件循环的规则是:先把 microtask 队列全部清空,然后才处理 event 队列里的一个事件,处理完再回头检查 microtask 队列。
也就是说 microtask 优先级更高,而且是插队执行。microtask 来自 scheduleMicrotask 和一部分 Future 的内部实现;event 队列装的是 IO 完成、定时器到期、用户手势、以及 Future 的回调。
实际影响很直接:如果在 microtask 里不断产生新的 microtask,event 队列就永远得不到执行,界面会完全卡死。所以业务代码里基本不该用 scheduleMicrotask。
另一个常考的点是执行顺序。Future(() {}) 是把任务扔进 event 队列,而 Future.value() 或者 Future.sync 后面的 then 会走 microtask。所以下面这段的输出顺序是 1、4、3、2:
print(1);
Future(() => print(2)); // event 队列
Future.microtask(() => print(3)); // microtask 队列
print(4);
还有一个要点是 await 之后的代码相当于 then 里的回调,会被放进队列而不是继续同步执行。这解释了很多「为什么我 await 之后拿到的状态变了」的问题——因为 await 期间让出了执行权,别的代码跑过了。
最后强调:单线程意味着任何耗时的同步计算都会卡住 UI,因为没有别的线程能接手。这也是为什么下一题的 Isolate 那么重要。
每个 Isolate 有独立的内存堆和独立的事件循环,之间不共享任何内存,只能通过 SendPort 发消息通信,消息内容会被拷贝过去。
这么设计的好处是彻底没有数据竞争,不需要锁、不需要考虑内存可见性,也就不会有死锁。而且因为堆是独立的,GC 可以各自独立进行,一个 Isolate 做垃圾回收不会暂停其他 Isolate,这对保持 UI 流畅很有价值。代价就是通信要序列化拷贝,传大数据有开销。
需要用它的场景是CPU 密集的计算:JSON 解析大数据、图片处理、加解密、复杂的列表排序或过滤。判断标准很简单——如果一段同步代码执行超过十几毫秒,就会让当帧超时掉帧,应该扔到 Isolate 里。
注意网络请求和文件 IO 不需要 Isolate,因为它们本来就是异步的,底层由引擎的线程池处理,不会阻塞 Dart 线程。把 IO 放进 Isolate 是常见的误用,纯属浪费。
用法上,简单的一次性任务用 Isolate.run(Dart 2.19 之后,比老的 compute 更简洁),它自动处理创建和销毁。需要长期通信就自己 Isolate.spawn 加 ReceivePort 建立双向端口。要注意传给 Isolate 的函数必须是顶层函数或静态方法,不能是捕获了外部变量的闭包(因为闭包的上下文没法拷贝)。
大数据传输可以用 TransferableTypedData 做零拷贝转移(所有权转移而不是复制),这在处理图片字节流时能省下明显的开销。另外要清楚创建 Isolate 本身有成本(几毫秒和一块内存),高频小任务频繁开销反而不划算,这种情况应该复用一个常驻 Isolate。
async/await 的本质是什么,Future 里的代码什么时候开始执行?★★★async/await 是语法糖,编译器把它转换成状态机加回调的形式,本质上等价于 then 链,但可读性好得多。它不创建新线程,全程还是在同一个 Isolate 的单线程上跑,只是在 await 的地方让出执行权,等结果就绪后从队列里被调度回来继续。
关于执行时机有几个容易搞错的点。async 方法体不是立刻异步的:调用一个 async 函数,函数体会同步执行,直到遇到第一个 await 才返回一个未完成的 Future 给调用方。所以 async 函数开头的代码是同步跑的,这经常被误解。
Future(() { ... }) 构造出来的任务是放进 event 队列下一轮才执行,而 Future.value(x) 是立刻完成的 Future,它的 then 走 microtask。Future.sync(() {...}) 会同步执行函数体,只有返回值被包成 Future。
实践中几个坑。一是忘记 await,代码看起来没问题但实际没等结果就往下走了,这类 bug 很隐蔽,开启 unawaited_futures 这条 lint 规则能提示。二是循环里串行 await,几十个请求一个个等,总耗时是累加的,应该用 Future.wait 并发发起。三是异常处理,async 函数里抛的异常会变成 Future 的错误状态,如果没有人 catch,就成了未捕获的异步异常,可能悄悄丢掉,所以要么 try-catch 要么加 catchError。
还有一个和 UI 相关的:await 之后一定要检查 mounted,因为等待期间用户可能已经离开这个页面,Widget 被 dispose 了,这时候 setState 或者用 context 都会报错。
didUpdateWidget 和 didChangeDependencies 什么时候触发?★★顺序是 createState → initState → didChangeDependencies → build,之后每次父级重建走 didUpdateWidget → build,最后 deactivate → dispose。
initState 只执行一次,用来创建 controller、订阅 Stream、发起首次请求。注意这里不能调 Theme.of、MediaQuery.of 这类依赖 InheritedWidget 的方法,因为依赖关系还没建立。
didChangeDependencies 在 initState 之后立刻执行一次,之后每当依赖的 InheritedWidget 发生变化时都会再执行。所以「需要根据主题或屏幕尺寸做的初始化」应该放这里,而不是 initState。
didUpdateWidget 是最容易被忽略、也最容易出 bug 的一个。它在父级重建并传入了新的 Widget 配置时触发,参数是 oldWidget。关键点在于:这时候 State 对象被复用了,但 widget 已经换成新的了。如果你在 initState 里根据 widget.userId 发了请求,父级把 userId 改了之后 initState 不会再执行,页面就一直显示旧用户的数据。正确做法是在 didUpdateWidget 里比对新旧值,变了才重新请求或者重建 controller:
@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_loadData(); // 只在真的变了的时候才重做
}
}
所以有个配套习惯:凡是 initState 里用到了 widget.xxx 的逻辑,就要想一下 didUpdateWidget 里要不要同步一遍。
deactivate 是从树上被移除时调用,但它可能被重新插入(比如带 GlobalKey 的 Widget 换了位置),所以清理工作不能放这里。dispose 才是真正销毁,controller 和订阅都在这里释放。
BuildContext 到底能做什么,为什么 await 之后不能直接用它?★★context 就是 Element 本身,代表 Widget 在树上的那个位置。所以它的全部能力都是基于位置的:往上查找祖先(Theme.of、Navigator.of、Provider.of),以及拿到自己对应的 RenderObject(获取尺寸和坐标)。理解「它是位置而不是对象引用」,下面几个坑就都通了。
async 之后不能直接用,因为等待期间用户可能已经返回、页面被销毁,这个 Element 已经从树上摘掉了。此时再拿它去 Navigator.of(context).pop() 或者 showDialog,要么报错要么操作到一棵失效的树。所以 await 之后必须先判断:
final data = await api.fetch();
if (!mounted) return; // 关键的一行
Navigator.of(context).pop(data);
Dart 3 之后 BuildContext 自己也有 mounted,在非 State 的地方可以用 context.mounted。lint 规则 use_build_context_synchronously 打开能自动提示这类问题,强烈建议开着。
第二个坑是拿到的祖先不是你想的那个。最典型的是 Scaffold.of(context) 在同一个 build 方法里取不到刚写的 Scaffold——因为你手上的 context 位于 Scaffold 之上,而查找只能往上走。解法是用 Builder 包一层,制造一个位置更深的 context。
第三个坑是 dialog 的 context 和页面的 context 是两个不同的位置。在 dialog 里想关掉 dialog 应该用 dialog builder 给的 context,用页面的 context 去 pop 会把整个页面弹掉。这个错误特别常见,表现是「点确认按钮结果整页退回去了」。
最后一个是前面说过的 initState 里不能查 InheritedWidget,要放到 didChangeDependencies。
InheritedWidget 的原理是什么,为什么它的查找是 O(1)?★★★InheritedWidget 是 Flutter 内置的数据向下共享机制,Theme、MediaQuery、Provider 全都基于它。用法是子孙节点通过 dependOnInheritedWidgetOfExactType 拿到它,同时自动建立依赖关系,数据变化时只有这些依赖它的节点会重建。
查找之所以是 O(1) 而不是逐层往上遍历,关键在于每个 Element 都持有一张祖先 InheritedWidget 的哈希表(_inheritedWidgets)。这张表在 Element 挂载时从父节点继承过来,如果自己是 InheritedWidget 就往里加一项。因为大多数节点只是把父节点的表原样引用,所以维护成本很低。查找时直接按类型在哈希表里取,一步到位。
这个设计很值得说,它体现了用空间换时间加共享不变数据的思路:如果每次查找都往上走,深层节点访问 Theme 就是 O(树高),而界面嵌套几十层很常见。
更新机制上,updateShouldNotify 决定要不要通知依赖者。返回 false 就一个都不通知,这是个重要的优化点——Provider 的 Selector 就是靠精细控制这个来减少重建。
两个实践要点:一是 context.watch 和 context.read 的区别,watch 会建立依赖(数据变了会重建),read 只取一次值不建立依赖,在事件回调里应该用 read,在 build 里用 watch,用错了会导致「点按钮后整个页面重建」或者「数据变了界面不刷新」。二是不能在 initState 里用 dependOnInheritedWidgetOfExactType,因为那时候依赖关系还没建立好,需要的话放到 didChangeDependencies 里。
Provider 是对 InheritedWidget 的封装,把「往树上放数据」和「取数据」变得简单。它的特点是依赖 Widget 树——数据的作用域由它在树上的位置决定,取数据必须有 context。这带来的问题是:树外面(比如一个纯 Dart 的 service 层)拿不到状态,而且容易遇到「找不到 Provider」这类运行时错误。
Riverpod 是同一个作者对 Provider 的重新设计,核心改变是脱离 Widget 树:Provider 定义成全局变量,取值不需要 context。好处是编译期安全(不会有找不到的运行时错误)、可以在任何地方读写状态、天然支持组合和依赖注入、测试也更容易(直接覆盖 provider)。代价是概念比较多,上手曲线陡一些。
Bloc 强调事件驱动和单向数据流:UI 发出 Event,Bloc 处理后输出 State,UI 根据 State 渲染。它的价值在于状态变更过程是显式且可追溯的,每一次变化都有对应的事件,天生适合做日志、时间旅行调试、以及复杂的状态机逻辑。代价是样板代码多,一个简单的开关也要定义 Event 和 State 类。
我的选择逻辑是按项目复杂度和团队规模来定,而不是追新。小项目、状态简单,ValueNotifier 加 ValueListenableBuilder 就够了,压根不用引入框架。中等规模用 Riverpod,平衡最好。大型项目多人协作、业务流程复杂,Bloc 的强约束反而是优点——它让所有人写出结构一致的代码,新人接手也容易看懂数据流向。
要避免的是在一个项目里混用多套方案,这比选了个不那么完美的方案糟糕得多。
const 构造器为什么能提升性能,怎么做局部刷新?★★const 构造的 Widget 在编译期就创建好并被规范化(canonicalize),相同参数的 const Widget 在整个程序里是同一个实例。这带来两个好处:一是不用每次 build 都重新分配对象,减少 GC 压力;二是 diff 的时候,新旧 Widget 是同一个对象,框架可以直接判定无变化跳过整棵子树,连递归比对都省了。
所以尽量给不含变量的 Widget 加 const,尤其是列表项里的图标、分割线、静态文本这些。开启 prefer_const_constructors lint 规则让工具自动提示。
局部刷新的手段按精细程度排:
拆分 Widget 是最基础也最有效的。把会变的部分单独抽成一个 StatefulWidget,setState 的影响范围就自动缩小到那一块。很多性能问题只要做好组件拆分就解决了,不需要任何框架。
ValueListenableBuilder 配合 ValueNotifier,只有 builder 里的那点内容会重建,不需要 setState,也不引入第三方库,做单个值的响应式更新特别合适。
Selector(Provider)或者 select,从一个大的状态对象里只挑出关心的字段,只有这个字段变了才重建。这解决了「Model 里改了任意字段导致所有监听者都重建」的问题。
还有个常被忽略的技巧:把不变的子树提取成 final 变量或者作为参数传入。因为传进来的 Widget 实例在父节点重建时是同一个对象,diff 直接跳过。Builder 组件也可以用来隔离重建范围。
AnimationController 是怎么驱动动画的,为什么需要 vsync?★★★动画的本质是每一帧改一次值,然后触发重建或重绘。驱动这个「每一帧」的东西是 Ticker,它注册到 SchedulerBinding 上,跟屏幕的 vsync 垂直同步信号对齐,每帧回调一次并带上距离动画开始的时长。AnimationController 拿到这个时长,按 duration 算出当前进度(0 到 1),通知所有监听者。
vsync 参数要的就是一个 TickerProvider,一般用 SingleTickerProviderStateMixin(一个 controller)或 TickerProviderStateMixin(多个)混入 State。
为什么必须绑到 State 上?因为这样 Ticker 的生命周期就和 Widget 绑定了:页面被切到后台或者不可见时,框架会把 Ticker 静音(muted),动画自动停下来,不会在用户看不到的地方白烧 CPU 和电量。如果不绑,一个跑在后台的无限循环动画会一直占着每帧的时间。所以 vsync 不是形式参数,它是性能和续航的保障机制。
值的组织是这样:AnimationController 本身是 Animation<double>,范围 0 到 1;用 Tween 映射到实际范围(颜色、尺寸、偏移);用 CurvedAnimation 套上缓动曲线。
隐式动画和显式动画怎么选:AnimatedContainer、AnimatedOpacity、TweenAnimationBuilder 这类隐式动画只要改目标值,框架自动补间,代码最少,适合简单的状态过渡。需要精确控制播放、暂停、反向、循环,或者动画由手势驱动,就得用 controller 加显式动画。
最后一个必须知道的性能点:不要在 addListener 里调 setState。那意味着每帧都重建整个 build 方法,一秒六十次,很容易掉帧。正确做法是用 AnimatedBuilder 或者各种 XxxTransition(FadeTransition、SlideTransition),它们只重建 builder 内部那一小块,外面的 Widget 完全不动。这是动画性能问题里最高频的一个原因。当然 controller 也必须在 dispose 里释放。
分两个阶段:命中测试找出候选者,手势竞技场裁决赢家。
命中测试(HitTest)从 RenderView 开始,拿着触摸点坐标递归往下问每个 RenderObject「这个点在你范围内吗」。父节点会先问子节点,命中的节点被依次加进 HitTestResult,所以最终这条链的头部是最深、视觉上最上层的节点,尾部是根节点。之后事件沿这条链分发。
这里有个实用知识点:GestureDetector 的 behavior 决定空白区域算不算命中。deferToChild(默认,有子节点被命中才算)、opaque(整个区域都算命中)、translucent(自己命中同时让下面的也能收到)。给一个只有文字的可点击区域加不上点击响应,通常就是因为空白部分没被命中,改成 opaque 就好了。
手势竞技场(GestureArena)解决的是「多个识别器都对这一次触摸感兴趣」。链上每个 GestureRecognizer 都会通过 addPointer 报名进入竞技场,然后各自根据后续的移动事件判断要不要争。结束方式有两种:某个识别器主动宣布胜出,或者其他都主动放弃,最后只剩一个自动获胜。竞技场只能有一个赢家,这是理解所有手势冲突的关键。
典型的裁决过程:手指按下时 Tap 和 Drag 都报名,Tap 会先等着,一旦移动距离超过阈值,Tap 主动放弃,Drag 胜出;如果手指没怎么动就抬起,Tap 胜出。这也解释了一个体验现象——可滑动区域里的按钮点击会有一点点延迟感,因为要先确认你不是想滑动。
另外两个常用的东西:Listener 是更底层的原始指针事件,不参与竞技场,所以它能拿到全部事件不会被别人抢走;IgnorePointer 让子树完全不参与命中测试(点击穿透过去),AbsorbPointer 是自己吞掉事件但不让子树收到。
先明确一帧的预算:60 帧是 16.6 毫秒,120 帧只有 8.3 毫秒。DevTools 的 Performance 面板会把每一帧拆成两段耗时,这两段指向完全不同的问题,先分清再动手。
UI 线程超时(图上是蓝色)说明 Dart 代码本身太慢:build 方法里做了复杂计算、一次 setState 触发了巨大的子树重建、在 build 里同步解析 JSON、或者布局层级太深。排查用 Timeline 的火焰图看具体是哪个方法耗时,也可以在代码里插 Timeline.startSync 自己打点。
Raster 线程超时(绿色)说明绘制和光栅化太重:图片太大需要缩放、用了 saveLayer 的操作(Opacity、ClipRRect、ShaderMask 这些都会触发)、复杂的阴影和模糊效果、或者图层太多合成压力大。这里的优化方向完全不同:换用更轻的实现,比如用 Container 的 color 代替 Opacity、用带圆角的 decoration 代替 ClipRRect、静态复杂图形考虑预先转成图片。
几个实用工具开关:debugProfileBuildsEnabled 看每个 Widget 的 build 耗时,debugRepaintRainbowEnabled 看哪些区域在重绘,debugPaintLayerBordersEnabled 看图层划分。性能测试必须用 profile 模式,debug 模式下有大量断言和额外检查,数据完全不能反映真实情况,这一点经常有人搞错。
另外要区分持续卡顿和偶发掉帧。持续卡顿一般是渲染或计算问题;偶发的大幅掉帧要怀疑 GC(Memory 面板能看到 GC 事件)、图片解码、或者首次加载着色器(不过 Impeller 之后这个基本没了)。
几乎都是该 dispose 的没 dispose,也就是 State 销毁了但它创建的资源还挂在别的地方被引用着。
AnimationController 没 dispose 最常见,它注册在 Ticker 上会被 SchedulerBinding 持有,不释放的话不仅泄漏内存,动画还会一直在后台跑消耗 CPU。StreamSubscription 忘记 cancel,Stream 会一直持有回调闭包,而闭包捕获了 State,整个页面都释放不掉。Timer 特别是 Timer.periodic,不 cancel 就永远在跑。TextEditingController、ScrollController、FocusNode 这些也都要 dispose。
还有几类不那么明显的。全局单例或静态变量持有 context 或 State,比如把 context 存进一个全局的工具类里,页面关了也释放不掉,而且 context 一失效就会引发各种奇怪报错。事件总线注册了没注销,跟 Stream 是同一个问题。图片缓存,ImageCache 默认上限是 1000 张或 100MB,大量大图会把它撑满。闭包意外捕获,一个长生命周期的回调里引用了 State 的成员,就把整个 State 拖住了。
排查手段:DevTools 的 Memory 面板,反复进出同一个页面然后看某个类的实例数是否在增长,这是判断泄漏最直接的方法。Snapshot 加 reachable path 能看到对象被谁引用着。另外 Flutter 有个 leak_tracker 工具(新版本 DevTools 集成了泄漏检测),能自动报告没有被 dispose 的可释放对象。
预防上有个很实用的习惯:写 initState 里创建资源的同一时刻,就把 dispose 里的清理代码写上,不要等后面补。另外 lint 规则里有 cancel_subscriptions 和 close_sinks,打开能拦住一部分。
长列表第一原则是必须用 ListView.builder 而不是 ListView(children: [...])。后者会一次性构建所有子项,一千条数据就是一千个 Widget 全部创建,内存和首屏时间都撑不住。builder 是按需构建,只创建可见区域附近的项。
进一步的优化点。给 itemExtent 或者 prototypeItem,如果每项高度固定,告诉框架这个高度,它就不需要逐项测量来计算滚动位置,滚动性能提升明显,跳转到指定位置也变成 O(1)。控制 cacheExtent,它决定视口外预构建多少像素的内容,调大滚动更顺滑但内存占用高,默认值一般够用。item 内部结构尽量扁平,避免深层嵌套和不必要的 Stack。避免在 itemBuilder 里做计算,数据要在进列表前处理好。
复杂的混合布局(顶部横幅加吸顶标签加多个列表)不要用嵌套滚动加 shrinkWrap,那会失去懒加载。正确做法是 CustomScrollView 加 SliverAppBar、SliverList、SliverPersistentHeader 组合,整个页面只有一个滚动上下文,性能和体验都对。
图片是内存的头号消耗者,关键认识是一张图占的内存跟文件大小无关,只跟解码后的像素数有关:宽乘高乘 4 字节。一张 4000×3000 的照片解码后是 48MB,哪怕文件只有 2MB。所以列表里放几张大图就能把内存打爆。
控制手段最重要的一条是用 cacheWidth 和 cacheHeight 指定解码尺寸,让图片按显示需要的尺寸解码,而不是原图尺寸。显示区域 100 逻辑像素宽,就没必要解码成 4000 像素。ResizeImage 也是同样作用。其次是服务端下发合适尺寸的图,这比客户端处理更彻底。然后是管理 ImageCache,可以调整 maximumSize 和 maximumSizeBytes,在内存告警时调 clear。最后是用 cached_network_image 这类库做磁盘缓存,避免重复下载和解码。
三种。MethodChannel 最常用,一次调用一次返回,适合「调个原生方法拿结果」,比如获取设备信息、调起支付。EventChannel 是原生向 Dart 持续推送,基于 Stream,适合传感器数据、电池状态、下载进度这类连续事件。BasicMessageChannel 是双向的自由消息传递,用得少,需要自定义编解码时才用。
底层都是通过 BinaryMessenger 传二进制数据,用 StandardMessageCodec 把 Dart 对象和原生对象互相转换,支持的类型有限(基本类型、List、Map),复杂对象要自己序列化成 JSON 或者拆成 Map。
要注意Channel 调用是异步的,而且有序列化开销。所以不适合高频调用——比如每帧都通过 Channel 取一次数据,那个开销会直接吃掉帧预算。这种场景要么把数据批量传,要么改用别的机制。另外要注意原生侧必须在主线程回调,在子线程调 result.success 会出问题(Android 上要切回 UI 线程)。
FFI 是直接调用 C/C++ 函数,走的是同步调用、零序列化,性能接近原生调用。适合两类场景:一是调用现成的 C/C++ 库,比如 SQLite、FFmpeg、OpenCV、加解密算法库,不用为它写一层 Channel 桥接;二是高性能计算,需要频繁调用或者传大块内存数据的场景,可以直接共享指针操作同一块内存,避免拷贝。
选择上很清楚:要调平台 SDK 和系统能力(相机、蓝牙、推送)用 Channel,要调 C/C++ 库或者追求极致性能用 FFI。FFI 的代价是要处理内存管理(手动 malloc 和 free,容易泄漏或崩溃),而且要为每个平台编译好动态库,工程配置更麻烦。
用 PlatformView,典型场景是嵌入地图、WebView、视频播放器、原生广告这些自己实现代价太高的组件。
难点在于Flutter 的界面是自己绘制在一块画布上的,而原生 View 由系统渲染,两套渲染体系要合成到一起。历史上有几种实现方式。早期 Android 用 VirtualDisplay,把原生 View 渲染到一块离屏纹理再交给 Flutter 合成,问题是触摸事件要手动转发、键盘和无障碍功能有各种异常。后来推出 HybridComposition,把原生 View 真正插进视图层级,交互和文本输入都正常了,代价是会打断 Flutter 的图层合成,可能引起性能下降,尤其是在原生 View 上面又叠 Flutter 内容时。iOS 侧的机制类似但细节不同。
实际会遇到的坑:手势冲突,原生 View 和外层 Flutter 的滚动组件会抢手势,需要用 gestureRecognizers 明确声明谁优先,把 WebView 放进 ListView 里几乎必然遇到这个问题。层级和圆角,给 PlatformView 加圆角、阴影、透明度这些效果经常不生效或者表现异常,因为它不是 Flutter 自己画的。性能,一个页面里放多个 PlatformView 会明显掉帧,长列表里更不能这么干。生命周期,Flutter 页面销毁时要确保原生 View 也被正确释放,不然容易泄漏。
所以我的实践原则是能不用就不用,优先找纯 Flutter 的实现方案。确实必须用(地图、WebView),就把它单独放一个页面,不要嵌在复杂布局或者列表里,能规避大部分问题。
接入方式是把 Flutter 作为一个模块编译成产物(Android 是 aar,iOS 是 framework),原生工程依赖它,然后通过 FlutterActivity 或 FlutterViewController 打开 Flutter 页面。
核心难题是两套导航栈的协同。原生有自己的页面栈,Flutter 内部也有 Navigator 的路由栈,两边互不知情。如果简单地「每个 Flutter 页面都用一个原生容器承载」,就会创建多个 FlutterEngine,每个引擎都要几十兆内存,还各自持有独立的 Dart 堆,页面间共享状态也很麻烦。反过来如果只用一个引擎,那么原生 A → Flutter B → 原生 C → Flutter D 这种交错跳转时,Flutter 侧的栈状态就会错乱,返回时页面对不上。
具体表现出来的问题:页面生命周期不同步(Flutter 页面被原生页面覆盖时,它不知道自己不可见了,动画和定时器还在跑)、返回手势和物理返回键行为异常、页面栈内存泄漏、转场动画不一致。
解决方案主流是Flutter Boost(闲鱼开源)这类混合栈框架,思路是共用一个 FlutterEngine,但把 Flutter 的页面容器和原生页面栈做统一管理:由原生栈作为唯一真相来源,Flutter 侧维护一个与之对应的容器映射,并正确分发 appear 和 disappear 生命周期。官方后来也提供了 FlutterEngineGroup,可以让多个引擎共享同一份代码和部分内存,把新增引擎的成本降到几兆,这是官方推荐的方向。
实践建议:如果只是接一两个独立的 Flutter 页面(比如活动页),用官方方案加单引擎最简单。如果要做大规模混合、几十个页面交错跳转,才值得引入 Boost 这类框架,它的接入和维护成本不低,而且和 Flutter 版本升级经常有兼容问题。
热重载利用的是 Dart VM 的增量编译加代码替换能力:只把改动的代码编译成增量的 kernel 文件推送给运行中的 VM,VM 替换掉对应类的方法实现,然后触发一次整棵树的重建。因为不重启进程、不重置内存状态,所以能保留当前页面和数据,这是它比重启快得多的原因。
它只能替换方法体,不能改变程序结构和已有对象的内存布局。所以下面这些改动必须重启:
改 initState 或构造函数。因为这些代码只在对象创建时执行一次,State 对象已经存在了,替换方法实现不会让它重新执行一遍。这是最常遇到的情况——改了初始化逻辑但界面没反应,其实是热重载生效了,只是那段代码不会再跑。
改全局变量和静态字段的初始值。它们已经初始化过了,热重载不会重新赋值。
改类的结构:加减字段、改继承关系、把 StatelessWidget 改成 StatefulWidget、改枚举定义。这些会改变对象的内存布局,已有实例无法适配。
改 main 函数,它只在启动时执行。改原生代码(Java/Kotlin/Swift/OC)、改依赖或者 pubspec.yaml、改 assets 的声明,这些压根不在 Dart VM 的管辖范围内,必须完整重新构建。
还有个实践细节:热重载不会重新执行当前页面的进入逻辑,所以改了页面初始化相关的代码,最快的验证方式是退出这个页面再进来一次,而不是直接重启整个应用。热重启(hot restart)是重置 Dart 状态但不重新编译原生部分,比完整重启快,改结构性代码时用它。
debug 模式用 JIT,代码在运行时编译,所以支持热重载和各种调试断言,代价是执行慢、包体积大。release 模式用 AOT,提前编译成机器码,启动快、运行效率高,但不能热重载。profile 模式是 AOT 加上性能追踪工具,做性能测试必须用它,debug 模式的数据毫无参考价值。
包体积方面,Flutter 应用的基础体积主要来自引擎(几兆到十几兆,各平台不同)加上你的 Dart 代码编译产物和资源。优化手段:
按 ABI 拆包,Android 上用 --split-per-abi 分别出 arm64 和 armv7 的包,能省一半左右;上 Google Play 用 App Bundle 会自动处理。压缩资源,图片用 WebP 代替 PNG,音视频不要打进包里改成远程加载,检查有没有误打进包的多倍图和废弃资源。去掉调试符号,--split-debug-info 把符号表单独输出,既减小体积又保留了解析崩溃堆栈的能力。代码混淆 --obfuscate 也能省一点。
Tree shaking 是自动的,但有前提:动态用到的东西摇不掉。典型的是图标字体,如果用了 IconData 动态构造图标,整个图标字体就没法裁剪,会白占一两兆。反射和 dart:mirrors 同理(Flutter 里本来也不支持)。
还有一个 2026 年比较重要的变化:Material 组件正在从 Flutter 核心里拆出去,独立成 material_ui 这样的包。这个改动的意义是不用 Material 的项目不再被迫带上这部分代码,对包体积和引擎精简都有好处,但老项目升级时要注意导入路径和依赖声明的变化。
本质区别在渲染方式。React Native 是桥接原生组件:你写的 <View> 最终对应 Android 的 ViewGroup、iOS 的 UIView,界面由系统渲染。Flutter 是自绘:只向平台要一块画布,所有像素自己画,不使用任何原生控件。
这个差别决定了各自的优劣。Flutter 的跨端一致性极好,因为渲染完全由自己控制,两端表现几乎像素级相同,不用为平台差异写适配;性能也更可控,没有 JS 和原生之间的通信瓶颈(RN 的旧架构里这个桥是主要性能问题,新架构 Fabric 和 JSI 改善了很多)。代价是组件外观不跟随系统,系统更新了控件样式或者出了新的交互规范,Flutter 需要自己跟进实现;而且要用系统原生控件(比如 WebView、地图)就得走 PlatformView,前面说过那一堆坑。
RN 的优势是前端生态复用,React 开发者几乎无缝上手,npm 生态可用,动态更新(热更新)方案成熟——这一点在国内很关键,因为 Flutter 的代码是 AOT 编译的,官方不支持热更新。
选型上我的判断依据:团队背景是首要因素,前端团队为主选 RN 的落地成本明显更低,客户端团队为主 Flutter 更顺;UI 复杂度,需要大量自定义视觉效果、动画、图表的选 Flutter,它的绘制能力和性能优势明显;是否强依赖热更新,如果业务要求频繁改版不走审核,这是 Flutter 的硬伤;是否需要深度使用系统能力,涉及大量原生控件混排的场景 Flutter 会比较吃力。
另外一个现实因素是招聘和长期维护。选一个团队能持续招到人、社区活跃的方案,比技术指标上略优要重要得多。
早期是 Navigator 1.0 的命令式写法,push 和 pop 直接操作栈。简单直观,但有几个做不到的事:没法直接声明「当前应该是什么栈」,只能一步步操作;深链接和 Web URL 同步困难,用户直接访问一个深层 URL 时,你得手动把中间的页面栈拼出来;浏览器的前进后退按钮没法正确响应。
Navigator 2.0 引入声明式模型:你提供一个 Pages 列表描述「当前栈应该是什么样」,框架负责算出差异并执行转场。这样路由状态就变成了应用状态的一部分,可以被序列化、被 URL 表达、被前进后退驱动。代价是原生 API 相当繁琐,要实现 RouterDelegate、RouteInformationParser 一堆东西,直接用它写业务代码非常痛苦。
所以实践中基本都用封装库,go_router 是官方推荐的,它把声明式的能力包成了简洁的路径配置写法,支持嵌套路由、路由守卫(做登录拦截)、参数解析、深链接,同时还兼容 Web 的 URL。auto_route 用代码生成,类型安全更好,参数传递不用手写字符串 key。
实际项目里我的做法:用 go_router 做集中式路由表,所有路径定义在一个地方,配合常量或者生成的路由名避免硬编码字符串。登录校验、权限控制统一放在 redirect 里,而不是散落在各个页面的 initState 里。需要传复杂对象时传 ID 而不是整个对象,页面自己去取数据,这样深链接和页面恢复才能正常工作。
还有一个容易忽略的点:路由传参用 extra 传对象在 Web 刷新后会丢,因为它不在 URL 里。移动端不明显,Web 端会出 bug,做多端时要注意。
没有匹配的题目,换个关键词试试。
模块 06 · 共 27 题 · 题目与答案分离,建议先自答再展开