ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Flutter状态边界:UI树才是决定setState刷新范围的关键

Flutter状态边界:UI树才是决定setState刷新范围的关键 有人问我一个很经典的问题setState明明调了数据也变了界面就是不动到底哪里出了问题我听完他的代码描述第一反应不是去看状态管理库配没配好而是反问他一句你这段状态到底挂在 UI 树的哪个节点上了在 Flutter 里聊状态管理聊到最后基本都会回到同一个问题上。你可以换掉 Provider也可以换掉 Bloc但绕不开的是所有状态最终都要通过 UI 树才能变成用户看得见的东西。很多人在这个框架里写了很久却不清楚状态其实是有“坐标”的——它一定挂在 UI 树的某个位置。你放的位置对了刷新范围、性能、可维护性都舒服放错了就会陷入“数据变了但 UI 没变”“状态动不动就丢”“一刷新就整页闪”的泥潭。所以这篇我想从一个稍微反常识的角度去聊在 Flutter 里UI 树才是最真实的状态边界。状态管理库只是工具真正决定状态生命周期、作用域、刷新成本的是 UI 树的结构本身。1. Flutter 的三棵树与状态的真实栖身之所聊状态边界之前得先把 Flutter 框架里最基础的一件事说透Widget、Element、RenderObject 这三棵树到底谁才是“真正活着”的那棵树。1.1 Widget 只是一张图纸Element 才是房子刚上手 Flutter 的时候我一度以为 Widget 就是界面本身。后来发现完全不是。Widget 本质上只是“配置对象”它是 immutable 的也就是说每一次 build框架都会创建一整批新的 Widget 实例。你写的Text(Hello)、Container(color: red)这些东西在每一帧重建时都可能被替换成一个新对象。那 State 存在哪在StatefulWidget对应的State对象里而State对象的生命周期是由 Element 管理的。看这个常见的代码class Counter extends StatefulWidget { override StateCounter createState() _CounterState(); } class _CounterState extends StateCounter { int count 0; // 这个 count 存在哪不在 widget 里 // 而是挂在 _CounterState 上_CounterState 又由 Element 持有 }很多人写了一年 Flutter 都没意识到一件事createState返回的State对象不是由 Widget 持有的而是由 Element 持有的。Widget 会在 build 时被频繁地丢弃、重建但 Element 会尽力保持稳定。只要 Element 在 UI 树里的位置和类型没变State 对象就会一直活着count 的值就还在。这个概念为什么重要因为它直接解释了“为什么 setState 能更新 UI”和“为什么状态会丢失”这两类问题。Widget 是图纸Element 是房子State 是住在房子里的住户。图纸可以随便改但你得保证住户还住在原来的房子里你的状态才不会丢。1.2 三棵树各司其职状态边界由 Element 树决定再系统化一点把三棵树的角色放到一张表里树角色生命周期是否可变Widget 树界面配置的描述短每次 build 都可能重建不可变Element 树组件实例管理持有 State长只要树结构稳定就保持内部状态可变RenderObject 树负责布局、绘制、命中测试与 Element 树同步维护可变Button 在屏幕上显示成什么样最终由 RenderObject 决定但这个 Button 的点击次数存在哪存在 State 里State 由 Element 持有Element 在 UI 树上占据了一个节点。整条链路串起来你就会发现状态到底能活多久、能被谁访问、更新时影响谁全由这棵 Element 树说了算。所以我说的“UI 树才是状态边界”更精确地说是 Element 树在划定状态的物理边界。Widget 树只是一个可丢弃的投影Element 树才是支撑所有状态的那张骨架。2. 状态边界在哪里决定了你的刷新成本懂了三棵树的关系接下来这个问题就非常实际了setState 之后到底有多少东西会被重建2.1 setState 的刷新范围是一棵子树很多人误以为 setState 只会刷新“当前这一个 widget”。其实不对。setState 的真实含义是把当前这个 Element 标记为 dirty然后在下一帧重建它的 build 方法返回的整棵子树。看一个最简单的例子class ParentWidget extends StatefulWidget { ... } class _ParentWidgetState extends StateParentWidget { int counter 0; override Widget build(BuildContext context) { return Column( children: [ Text(counter: $counter), ChildWidgetA(), ChildWidgetB(), ], ); } }当setState(() counter)被调用时不只是那个Text会重建。ParentWidget的 build 方法会重新执行ChildWidgetA和ChildWidgetB的 build 也会被重新调用除非它们被优化掉了。因为状态放在ParentWidget这一层它的边界就是整棵 Column 子树。这就是状态边界的第一个现实影响**状态放得越高刷新范围越大。**如果你的页面根组件是一个StatefulWidget里面存了一个很底层的 UI 状态比如某个弹窗是否展开那么每次这个状态变化整个页面所有子组件都会经历一次 build。在复杂页面里这可能直接导致掉帧。2.2 状态放太高和太低的代价我做一个待办列表时踩过这个坑。列表里每个 item 有一个“展开/收起”箭头点击后显示详细描述。第一版我图省事把展开状态统一放在页面级 State 里class TodoListPageState extends StateTodoListPage { final Setint expandedIds {}; override Widget build(BuildContext context) { return ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { return TodoItem( todo: todos[index], expanded: expandedIds.contains(todos[index].id), onTap: () { setState(() { if (expandedIds.contains(todos[index].id)) { expandedIds.remove(todos[index].id); } else { expandedIds.add(todos[index].id); } }); }, ); }, ); } }功能没问题但性能很拉胯。每次点一个 item整个 ListView 的 builder 都会重新跑一遍。如果列表里还有网络图片、复杂卡片用户能明显感到展开动画卡顿。第二版我把“是否展开”的状态下沉到TodoItem自己的StatefulWidget里每个 item 自己管自己class TodoItem extends StatefulWidget { final Todo todo; // 展开状态不再由父组件管理 } class _TodoItemState extends StateTodoItem { bool expanded false; override Widget build(BuildContext context) { return GestureDetector( onTap: () setState(() expanded !expanded), child: Column( children: [ Text(widget.todo.title), if (expanded) Text(widget.todo.description), ], ), ); } }改动很小效果天差地别。展开一个 item 时只有那个 item 自己的 State 被标记 dirty刷新的边界从“整个列表”缩小到“一个 item”。这种量级的优化不需要什么高级性能工具只需要你时刻问自己这个状态属于 UI 树的哪一层什么样的状态适合放在 item 内部很简单只影响 item 自身、不需要被兄弟节点或父节点读取的状态。弹窗开合、选中态、展开态都属于这一类。只要状态不跨组件共享就果断往下放这既是合理的架构设计也是免费的性能优化。2.3 善用 const 和不变子树除了把状态往下放const也能帮你划定状态边界。当一个 Widget 被声明为const它在 widget 树里就是同一个实例。父组件 rebuild 时框架会比较新旧 Widget 的 runtimeType 和 key发现完全没变就会直接跳过这棵子树的 rebuild。这相当于你手动告诉框架这棵子树的状态不依赖父级别碰它。相反的坑是在 build 里用print调试、每次 build 都new一个对象塞给子组件这些都会破坏 const 优化甚至让子组件白白 rebuild。你每在 build 里写出一堆非 const 的 Widget就等于在把状态边界的防线往外推了一点。3. 状态管理的本质在 UI 树上找一个坐标聊完了 setState 的刷新机制再回头看各种状态管理方案思路会完全不一样。你会发现所有状态管理库的本质都是在解决同一个问题状态放在 UI 树的哪个节点上谁能访问它状态变化时哪些子树要 rebuild。3.1 setState、InheritedWidget、Bloc 背后的统一逻辑先从最底层的机制说起。setStateStatefulWidget状态存在当前 Element 的 State 里边界是当前组件自身。自己能用子树能通过回调间接修改但兄弟节点读不到。InheritedWidget状态可以存在某个 Element 的 State 中但所有 descendant 都可以通过context向上查找读取到它。这是 Provider、Riverpod 的底层通信机制。Bloc状态本身存在 Bloc 实例里但 Bloc 实例是通过BlocProvider注入到 UI 树某一层的。Widget 通过context.watch监听状态流从而触发局部 rebuild。看出来了吗哪怕你用的是再花哨的库状态只要想和 UI 产生关联就必须在 UI 树上占据一个位置。InheritedWidget依赖的是 Element 树向上查找祖先的能力BlocProvider的本质也是把一个对象挂到 Element 树的一个节点上让整棵子树共享。3.2 全局状态的问题不是“全局”而是边界太大很多团队习惯把用户信息、购物车、主题配置全部丢进一个全局 store。从状态边界的角度看这会有什么代价状态一旦放到全局它的刷新范围就覆盖整个监听区域。你在任意一个页面改一下全局状态所有context.watch了这个状态的组件都会被标记 dirty。哪怕你用了 Provider 的select也很难完全避免无关组件的 build。我不反对全局状态但我反对“图省事把一切都全局化”。比如主题色这种全局共享且变化极低的状态放全局完全没问题但一个页面搜索框的关键词放全局就属于“状态边界过大”它不仅会带来无谓的 rebuild还会让你的页面状态和别的页面耦合在一起出了 bug 都难排查。3.3 按使用范围给状态归类实践经验里我会把状态分成三类每一类都有默认的 UI 树位置状态类型例子建议放置位置本地 UI 状态弹窗开合、item 展开、下拉刷新动画当前StatefulWidget的 State页面级共享状态搜索关键词、筛选条件、分页数据页面根节点的 Provider/Bloc跨页面共享状态登录态、购物车、主题、全局配置共同祖先节点的 Provider/Bloc核心原则就是一句话**状态放在离“真正使用它的组件”最近的共同祖先上。**这个位置就是你在 UI 树这张地图上给状态画的边界。边界画得越小状态越内聚刷新成本越低边界画得越大代码越“方便”但性能和可维护性都会随之下降。4. Bloc 实战按 UI 树边界拆分布局聊完理论来一份实战拆解。我用 Bloc 比较多就以购物车页面为例讲讲怎么按 UI 树边界来给状态定位。4.1 一个购物车页面的 Bloc 布局购物车页面的 UI 结构大概是顶部 AppBar 有一个购物车商品数量徽标中间是商品列表每个商品有数量加减按钮底部是一个结算栏显示总价。很多人的第一反应是购物车是全局数据所以建一个全局CartCubit放到 App 根部MultiBlocProvider里。功能确实能跑但你会发现在某个页面加减商品时整个 App 里所有监听了CartCubit的组件都在 rebuild。哪怕当前页面根本没有购物车 UI。更好的做法是先看 UI 树结构。购物车页的这些组件AppBar 徽标、商品列表、结算栏共同祖先是谁就是这个购物车页面根节点。所以CartCubit只需要在这个范围提供就足够了class CartPage extends StatelessWidget { override Widget build(BuildContext context) { return BlocProvider( create: (_) CartCubit()..loadCart(), child: Scaffold( appBar: CartAppBar(), // 可以 context.watchCartCubit() body: CartItemList(), // 可以 context.watchCartCubit() bottomNavigationBar: CartCheckoutBar(), // 可以 context.watchCartCubit() ), ); } }这个BlocProvider的摆放位置就是购物车状态的 UI 树边界。它保证了购物车内所有组件都能读到状态同时不会影响到购物车页面以外的任何组件。4.2 状态需要跨页面共享时提升到共同祖先那如果 AppBar 徽标在主导航的其他页面也需要显示怎么办比如用户从商品详情页添加购物车全局导航栏的购物车角标也要更新。这时候状态边界就不能只在购物车页了。你要找的是“所有需要显示角标的页面”的共同祖先。如果主导航是ScaffoldBottomNavigationBar购物车徽标在导航栏的AppBar上那状态就需要提升到主导航这一层甚至更高class MainShell extends StatelessWidget { override Widget build(BuildContext context) { return BlocProvider( create: (_) CartCubit()..loadCart(), child: Scaffold( appBar: MainAppBar(), // 角标在这里 body: TabBarView(...), // 里面的页面也能访问但不强制 ), ); } }有些开发者会觉得“反正都要提升不如直接全局”。我的建议是按需提升。只有确实有多个无关页面需要共享时才把 Provider 放到更高层。否则页面内部的状态就留在页面内部不要为了未来可能的需求提前全局化——你无法预测未来但你现在就要为每一次无谓的 rebuild 买单。4.3 用 context 读取状态时别越过状态边界Bloc 使用中最常见的问题之一是在 build 方法里用context.read并触发事件build(context) { context.readCartCubit().addItem(item); // 每次 build 都会触发应避免 }context.read的设计初衷是“在事件回调里读取一次状态”它不建立监听关系。在 build 里调用配合副作用轻则重复触发重则死循环。而你如果遵循 UI 树边界来设计这个问题的本质是你在 build 里越过当前组件的状态边界向上层的 Provider 发起了访问。正确的做法是把这个动作放到用户交互的回调里让状态边界在事件层就完成闭环。5. 那些“状态丢失”的坑UI 树结构变化才是真凶理论聊了不少下面进入最容易踩的坑。我发现很多状态丢失的 bug表面上看起来千奇百怪但根因几乎都是同一个UI 树的结构发生了变化导致 Element 被销毁重建。5.1 列表项状态错乱先查 key经典的ListView分页加载每条数据有个输入框或勾选状态。如果你用index当 key在数据排序、删除、插入后Flutter 会误认为同一个位置的 Element 是同一个 item于是复用了旧 Element。你会看到第二项的输入框里保留着第一项曾经输入的文字。原因是Framework 判断 Widget 能否复用同一个 Element核心依据是 runtimeType 和 key。你用 index 做 key等于告诉 Flutter“第 0 个位置就是第 0 个 item”。但数据一变位置和内容的对应关系就变了旧状态自然会被错误地保留下来。解决方式很简单用稳定且唯一的item.id做 keyListView.builder( itemBuilder: (context, index) { final item items[index]; return TextField(key: ValueKey(item.id), ...); }, )这个 key 实质上就是 UI 树上的“门牌号”。门牌号对了住户才不会搬错房间。5.2 条件渲染导致 State 被销毁另一个高频坑加载状态和内容显示用if切换。比如Widget build(BuildContext context) { if (isLoading) { return LoadingIndicator(); } return OrderForm(); // 这个表单包含一堆输入框和控制器 }看似正常但每次isLoading从 true 变 falseOrderForm才第一次被创建从 false 变 trueOrderForm又会被销毁。表单里已经填写的输入框内容、滚动位置全部随 Element 一起被回收。解决方案不是不用if而是要保持子树结构的稳定。比如用Stack来叠放加载态和表单态让两个子树都常驻 Element 树return Stack( children: [ OrderForm(), if (isLoading) LoadingIndicator(), ], );这样OrderForm的 Element 一直都在State 就不会丢。你还可以用Offstage、Visibility来做类似的控制核心原则就是不要轻易改变 UI 树的拓扑结构否则状态生命周期会一起变动。5.3 滚动位置消失不只是“初始化一下”的事Tab 里放了一个列表切走再切回来列表滚动到了顶部。很多人以为是数据重新加载导致列表高度变化其实根因是切换 Tab 时这个列表的 Element 被销毁了滚动偏移量自然无从保留。Flutter 提供了一个PageStorageKey机制专门应对这种情况ListView( key: PageStorageKey(order_list), ... )但要注意PageStorageKey只能存储 Scrollable 的偏移量等有限信息如果你的列表 item 里有复杂的内部状态Element 树一旦销毁那些状态一样会清空。所以最稳妥的思路是凡是用户输入、列表滚动这些需要保留的状态所属子树最好保持常驻 UI 树。5.4 build 里创建对象等于每帧都把状态边界重画一遍还有一种很隐蔽的写法override Widget build(BuildContext context) { final controller TextEditingController(); // 错 return TextField(controller: controller); }每次 build 都创建新的 controllerTextField 的状态看似在实际上输入内容每次都被清空。因为 controller 和输入框之间的状态边界根本就没锚定到 Element 上。这种对象的生命周期要么放到State的成员变量里初始化要么交给BlocProvider这类拥有明确生命周期的容器管理。说到底任何“有生命周期”的对象都要绑定在 Element 这个房子的生命周期上而不是绑定在 build 这个“装修活动”上。6. 从 UI 树边界谈 60fps 的性能设计最后落到性能问题上。很多人理解 Flutter 高性能总以为是引擎的功劳其实 UI 树边界的设计才是决定帧率上限的关键因素。6.1 一次 rebuild 的成本取决于你的边界画得多大做个很粗略的计算假设列表里有 20 个 item每个 item 的 build 需要 1ms。如果状态边界只在一个 item 内部一次状态变化最多花 1ms如果状态边界是整个列表一次状态变化就要重建 20 个 item花费 20ms。而一帧的预算只有约 16.6ms。你不需要多精密的性能工具就能算出问题所在**状态边界越大单次刷新的工作量就越容易超标。**想保住 60fps第一步就是控制 rebuild 边界让大家 focus 在真正变化的子树上。6.2 RepaintBoundary 隔离的是重绘不是 rebuild有人会把RepaintBoundary当成性能银弹。它能隔离 RenderObject 的重绘层但不能阻止父级 rebuild 引发的整棵子树 build。build 是 “构建配置” 的过程重绘是 “产生像素” 的过程两者发生在流水线的不同阶段。所以正确的优化顺序是先把状态边界画小减少 rebuild 范围。再用RepaintBoundary把重绘代价高的区域隔离起来。最后才是考虑动画、图片缓存之类的细节优化。顺序反了你会在RepaintBoundary上花很多时间帧率却没有明显改善因为瓶颈根本不在重绘而在 build。6.3 Impeller 渲染引擎优化不了“状态边界设计”Flutter 新的渲染引擎 Impeller 确实解决了很多 Skia 时代的性能问题比如首次渲染的 shader 编译卡顿。但 Impeller 优化的是“渲染”这一层它不能替你做状态边界设计。这一点特别想提醒刚入门的朋友不要以为升级到 Impeller 之后UI 卡顿就全部消失了。如果你的页面在每次 setState 时都要重建一棵巨大的子树该掉帧还是掉帧。**渲染引擎再快也只负责“画”不负责“重新构建那棵树”。**树的构建成本始终掌握在你自己手里。做了几年 Flutter我的一个体会是这个框架的 API 并不难学真正拉开差距的是对“状态应该放在哪”这件事的理解。你可以不用 Bloc也可以不用 Riverpod但你绕不开 UI 树。它就像一张地图你的所有状态都在上面有一个坐标。边界画对了状态管理库怎么选都是顺手的边界画错了换再多工具也是治标不治本。下次再遇到“setState 没反应”或者“状态又丢了”先别急着怀疑库去看看你的状态在 UI 树上待的地方对不对。
返回列表