资讯详情

资讯详情

Flutter入门:从StatelessWidget到StatefulWidget的状态分离详解

很多零基础学 Flutter 的朋友都有过这样一段经历学了布局、学了装饰、学了列表信心满满准备写点有交互的小程序结果卡在了一个看似很简单的地方——想做“点击按钮数字加一”却发现写出来的组件怎么点都没反应。原因多半只有一个整个页面用的全是 StatelessWidget没有“记性”。StatelessWidget 的名字翻译过来就是“无状态”它没有状态页面自然就不会自己动起来。真正让页面动起来的是 StatefulWidget 里的 State。这篇博文我要把这个“第一次分离”掰开揉碎讲清楚StatelessWidget 和 State 是怎么被拆成两个类的、State 到底是什么、它什么时候被创建、什么时候被回收以及你该在哪些场景里刻意把“展示”和“状态”分开。适合刚学完 Flutter 基础组件、正准备写第一个交互页面的同学来读也适合那种“会用但不理解为什么要分两个类”的朋友。1. 从“点击按钮加一”开始的第一次认知冲击1.1 为什么 StatelessWidget 做不了交互其实 StatelessWidget 并不是完全“不能做交互”你可以给它加手势监听也可以用 InkWell 包一层让它有点按效果。真正的难点在于按下按钮之后数据变了视图怎么跟着变。拿一个小卡片来说它显示“点赞数3”如果这个数字永远都是 3那 StatelessWidget 写起来非常清爽class LikeCountBadge extends StatelessWidget { const LikeCountBadge({super.key, required this.count}); final int count; override Widget build(BuildContext context) { return Text(点赞数$count); } }你看这个组件接收一个 count 参数然后画出来完事。它本身没有保存任何数据所以用起来特别简单也特别好预测。但如果点赞数会从 3 变成 4再变成 5问题就来了数字变了以后界面上的文字不会自动变。StatelessWidget 的 build 方法没法由自己触发更新它只能等父组件重新构建、给它传一个新的 count 才会重新画一遍。组件自己不持有任何会变化的数据副本一旦数据变了它就无能为力。这就是零基础阶段最先撞到的那堵墙你想写一个“自己会变”的组件StatelessWidget 给不了你这个能力。于是学习曲线自然就顺着到了 StatefulWidget。1.2 StatefulWidget 的骨架继承与拆分的直觉很多人第一次看到 StatefulWidget 的代码第一反应是怎么比 StatelessWidget 复杂这么多关键是多了一个类。class CounterPage extends StatefulWidget { const CounterPage({super.key}); override StateCounterPage createState() _CounterPageState(); } class _CounterPageState extends StateCounterPage { int _count 0; void _increment() { setState(() { _count; }); } override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text($_count), ElevatedButton( onPressed: _increment, child: const Text(加一), ), ], ), ), ); } }注意看这里出现了第一次“分离”页面的配置写在 CounterPage 里状态和行为写在_CounterPageState里。为什么非要拆成两个类直接把_count写在 CounterPage 里不行吗这里要讲一个 Flutter 很核心的设计机制Widget 是会被频繁重建的。每次父组件 rebuild、每次屏幕旋转、每次路由切换Flutter 都可能创建一个新的 Widget 实例来做对比。Widget 本质上是一份轻量级的“配置描述”它描述“这个界面此刻应该长什么样”。如果状态也直接存在 Widget 实例里面那问题就大了——Widget 一重建状态就全没了。设想一下你刚点了五次加一计数到 5结果因为某个父级刷新了一下页面被重建计数直接归零那整个交互就崩了。State 就是为了解决这个问题才单独存在的。State 对象不会随着 Widget 的丢弃而丢弃只要这颗子树没有被真正移除框架就会把 State 一直保存在内存里。1.3 “第一次分离”到底分离了什么我用一个生活化的比喻来解释。StatelessWidget 像一张图纸画了一个客厅就永远是一个客厅StatefulWidget 是图纸加一台有记忆的机器图纸负责描述当前客厅的样子机器负责记录“住在哪、家具摆到哪、灯是开是关”。也就是说Widget 负责“描述界面长什么样”State 负责“保存持续的数据和逻辑”。Widget 可以被随意丢弃重建因为它只是可以随时重新计算的描述State 却被框架保存下来只要对应位置没有发生结构性变化比如换 key、删掉节点它就一直活着数据不丢。你可能还会听到 Flutter 里有“三棵树”的说法Widget Tree、Element Tree、State Tree。零基础阶段不用深挖但有一个概念可以先烙在脑子里Widget 只是配置Element 是中间桥梁State 才是真正持久化的数据体。setState 通知的是 Element 去重建最后在屏幕上渲染的是根据最新 State 重新生成的 Widget 配置。这里我给大家一个很实用的建议学到这里不要急着往下冲先在纸上把上面计数器代码的“分层关系”画一遍把页面描述、状态持有、界面刷新这三件事的关系搞清楚。很多后面难懂的坑都是因为这一步草草过去了。2. State 的生命周期从创建到销毁的完整旅程2.1 先记住三个关键时机initState、didUpdateWidget、disposeState 有一套完整的生命周期回调零基础阶段不需要全背但有几个时机必须心里有数。我把主要方法整理成了一张速查表你可以先收藏方法触发时机典型用途注意事项initStateState 被创建后调用且只调用一次初始化控制器、订阅数据源、初始化动画不要在这里做耗时操作也不要去依赖具体 BuildContextdidChangeDependencies依赖的上层数据变化时调用读取 InheritedWidget 的数据初始化时会在 initState 后紧跟着执行一次build每次需要绘制界面时调用根据当前状态生成 Widget 树可能被反复调用一定保持轻量didUpdateWidget父级用新的 Widget 配置重建当前组件时调用对比新旧参数更新内部状态可通过 oldWidget 拿到之前的配置deactivateState 从树中移除时调用清理与树位置相关的东西并不代表一定会销毁可能只是换位置disposeState 永久销毁前调用释放监听、销毁控制器、停掉 timer之后绝对不能再 setState先重点看 initState。它适合做初始化给 TextEditingController 赋值、初始化一些非空的计算结果、建立监听。有一点要特别注意initState 阶段不要马上用 BuildContext 去做一些依赖具体布局的事情比如弹 SnackBar、跳转路由这种因为在第一帧完成前你可能拿不到正确的显示位置。再说说 didUpdateWidget。它的触发条件是父级以新的配置重建你这个组件并且新旧的 runtimeType 一致。最典型的场景是父页面把某个参数传下来这个参数变了你得用新值把内部的状态覆盖一次。dispose 则是 State 永久消失前最后的机会。典型的用途包括移除滚动监听、销毁 TextEditingController、取消 Timer、停掉 AnimationController。后面我在实战环节里会重点演示这个超级容易被漏掉。2.2 build 为什么不是“你想调就能调”很多新手容易把 build 和 initState 搞混以为 build 只在页面创建时执行一次。这是错的build 可能在非常多的情况下被反复调用父组件 rebuild 了、页面尺寸变了、键盘弹起来了、你调用了 setState、系统通知触发本地设置变化……都会导致 build 重新执行。所以 build 这个函数永远应该遵循一个原则它只是一台“根据当前状态快速画出界面”的机器。不要把重活放在 build 里不要在 build 里创建大的对象、做耗时字符串处理、发起网络请求。有人测试过一个复杂页面的 build 方法里如果每次做不必要的计算页面滑动时就会有肉眼可见的掉帧。那如果你确实想在每次构建后做点别的呢可以用WidgetsBinding.instance.addPostFrameCallback它会在当前这一帧结束之后执行回调这样既不影响这一帧的绘制又能拿到构建完成后的时机。2.3 关于 mounted 与异步泄漏的提醒State 有一个很实用的属性叫mounted表示这个 State 当前是否还挂载在组件树上。它的典型使用场景是异步操作回调。举个例子你在 initState 里发起了网络请求等用户已经把页面关掉了请求才回来。这时候如果你直接 setStateFlutter 会报一个很经典的错setState() called after dispose()甚至直接导致状态异常。正确的做法是在回调里先判断一下 mountedFuturevoid _loadData() async { final data await api.fetch(); if (!mounted) return; setState(() { _data data; }); }mounted就是 State 的生命安全锁。零基础阶段可能觉得多写一个 if 很烦但一旦写过几次异步更新页面的功能你就会发现这个判断有多重要。它本质上在表达这个组件的“宿主”还活着吗如果已经死了就别再折腾了。3. setState 触发的重建机制别把整个页面都卷进来3.1 setState 的本质与作用范围很多人把 setState 理解成“调一下刷新整个页面”。它不是那么玄乎它做的最核心的事情是把当前 State 对象标记为 dirty脏并向框架登记——“我需要在下一帧重新构建”。关键在于这个重建是有边界的。setState 只会触发当前 State 所属 Widget 的 build 和它的子树并不会让它上面的父级也重建。父级不会因为子组件调了 setState 而重跑除非父级自己也调用了 setState。这一点在实战里特别重要。一个页面如果只有一个局部区域在变化你最好把变化的那个区域单独做成一个 StatefulWidget让状态只在那一片的小范围内自我更新。这样既省掉了跟这个状态无关的大范围重建也让代码的职责更清晰。举个例子一个商品卡片只有右下角的心形收藏按钮会变其他地方展示的基础信息都不变。如果你把整张卡片写成一个大的 StatefulWidget收藏状态一变整卡都要重建一遍虽然大多数场景性能也能扛住但没必要。把收藏按钮单独抽成一个带状态的小组件按下时只重建按钮那一小块滑动列表时才能保持稳定流畅。3.2 什么时候不该调用 setState我做个小总结这几个场景都是新手容易踩的场景问题正确做法在 initState 里调用 setState初始化后必然会有一次 build完全多余直接给字段赋值即可在 build 执行过程中调用 setState会再次标记 dirty可能造成循环重建把标记脏的操作放到事件回调里State 已经 dispose 后调用 setState会报错或崩溃先判断 mounted数据来自父级、父级自己管理状态子组件没必要 setState通过回调通知父级去处理有一种情况最容易犯错页面里有一个异步任务用户快速退出异步任务完成后你还在用 setState 刷新一个已经被销毁的 State。我之前做一个小项目时就因为在登录页的请求回调里没判断 mounted连续崩溃了好几次。所以后来我养成了一个习惯只要是async里面有 setState前面必定有一个if (!mounted) return;。3.3 两个容易被忽略的细节合并更新与 addPostFrameCallbacksetState 是可以合并的。你如果在一个事件回调里面修改了三个字段没有必要调三次 setState应该把三次字段变更都放进同一个 setState 的闭包里setState(() { _isLoading true; _errorText null; _attemptCount; });这种方式的好处不仅仅是代码更简洁还能避免中间状态的界面闪烁。框架会在下一帧统一处理不会因为连续标记多次而重复 build 好几遍。另外如果你需要在一帧结束之后拿到某个组件的尺寸信息或者在布局完成之后弹出提示直接写在 build 里是不行的因为那一帧还没画完。比较稳妥的做法是用WidgetsBinding.instance.addPostFrameCallback把动作推迟到这一帧结束之后执行。这个技巧在刚学完状态管理的阶段不太会用但当你开始做弹窗、Toast、动态布局测量时几乎是必备的。3.4 key 与 State 的复用关系最后再补一个关键点State 不是永远跟着同一个组件跑的它能不能存活取决于新旧 Widget 的比较结果。每次重建时Flutter 会拿新的 Widget 和旧的 Widget 做对比先看 runtimeType再看 key。类型变了直接重建类型一样且 key 匹配或者都没有 key就复用 Element 和 State只是把配置更新一下key 不一样那就完全是两个不同的节点旧的 State 会被销毁新的 State 会创建。这引出了一个非常重要的实战教训当列表使用 StatefulWidget 子项时一定要给每一项一个稳定的 key。我举个真实踩过的坑有一个待办事项列表每个列表项里有一个展开按钮展开后能看到备注。因为列表项没有设 key我在删除中间一项时发现原本下面那项的展开状态“转移”到了别的项上。原因很简单框架比较后以为这是同一个位置的组件于是复用了旧的 State。后来我给它加了ValueKey(item.id)状态错乱的问题当场消失。4. StatelessWidget 存在的价值不是“缺状态”而是“不持有状态”4.1 别把所有组件都写成 StatefulWidget知道 StatefulWidget 能处理状态以后新手很容易走另一个极端干脆所有组件都用 StatefulWidget 吧反正它能干更多事。结果写出来一堆 StatefulWidget状态满天飞改一处崩三处。StatefulWidget 不是更高级更准确地说它只是“需要时才用”。Flutter 更推崇的默认选择恰恰是 StatelessWidget。因为无状态组件有天然的优势它容易预测、容易测试、性能开销小而且没有状态就意味着你不会因为状态泄漏而调试到怀疑人生。一个纯展示型的组件——比如头像、图标、标题、描述文字组合成的信息卡片——完全不需要自己持有状态。写成 StatelessWidget干干净净。而且配合const关键字还能拿到实实在在的性能收益当你在 build 里写一个const Widget只要输入参数不变框架可以直接复用旧的实例省掉一遍又一遍的构造开销。我写过一个大表格页面里面有几百个格子。最初全部用 StatefulWidget 写滑动时明显感觉发闷。后来把那些只负责展示的格子改成 StatelessWidget并且加上了 const 构造滑动手感立刻变顺了。别小看这一个改动在大量复用的 UI 场景里收益非常直观。4.2 把状态上提谁拥有状态谁就是 StatefulWidgetFlutter 的“分离”思路里还有一个更值得理解的设计方向状态提升。想象这样一个页面顶部有一个筛选区域下面是商品列表。商品的筛选条件放在哪里显然不应该放在每个商品卡片内部——否则你切换条件时难道要给几十个商品组件各发一条消息正确做法是把筛选条件保存在页面的 StatefulWidget 里下面的 StatelessWidget 只是通过参数接收“当前筛选条件是什么”并且把用户操作通过回调往上抛。这个模式有个很朴素的叫法“数据向下事件向上”。父组件持有状态往下传数据子组件怎么操作自己管但要改变数据了就通过回调通知父组件去改。这也是理解 Flutter 状态管理方案的核心起点。以后学习 Provider、Riverpod、Bloc 这些框架时你会发现它们的本质思想没有变只是把“状态存在哪”这件事做得更规范化了。状态提升虽然原始但它是理解所有状态管理工具的基石——现在把基础打牢后面看那些进阶方案会顺很多。4.3 展示与状态分离带来的可维护性提升把展示和状态拆开最大的受益者其实是写代码的人自己。当一个组件纯粹负责展示你一眼就能看明白它显示什么、参数从哪来。当一个 State 对象纯粹负责状态你排查问题的范围一下子就缩小了。我在维护一个较老的页面时深有体会那个页面把数据请求、界面渲染、用户交互全揉在了一个巨大的 StatefulWidget 里任何一处改动都可能牵动全局。后来我花了一个下午按照“展示组件只管接收参数渲染”“状态组件只管数据与逻辑”的原则拆了一遍后续再改需求时心里踏实太多了。拆分的核心思路很直接先看这个组件有没有“自己会变化的数据”。没有就 StatelessWidget有再看这个数据是不是必须由自己保存如果数据其实属于父级那即使这个组件能写成 StatefulWidget也让它保持无状态通过参数和回调完成协作。5. 从计数器到登录表单一次完整的分离实战5.1 案例拆解登录页的分工设计理论讲了这么多我们来一个实际例子把前面的概念串起来。就拿最常见的登录页来说通常需要用户名输入框、密码输入框、登录按钮、加载状态、错误提示。按“谁拥有状态谁就是 StatefulWidget”的原则整个页面显然需要持有状态两个输入框的内容、loading 字段、error 信息这些都是会变的。所以页面顶层应该是 StatefulWidget。但页面内部的子组件完全可以用 StatelessWidget 来承担。我来拆一下分工LoginPageStatefulWidget持有两个 TextEditingController、bool loading 字段、String error 字段。UsernameFieldStatelessWidget接收 controller 作为参数只负责把输入框渲染出来。PasswordFieldStatelessWidget同样接收 controller 和是否可见的开关。LoginButtonStatelessWidget接收 loading 状态和 onPressed 回调。这里有个小知识先说明白TextField 自己内部是有状态管理的你打字时文字在屏幕上实时变化这个工作由 Flutter 的内置组件自己完成不需要我们 setState。但我们仍然要手握一个 TextEditingController因为最终登录的时候我们需要从 controller 里面读取用户输入的内容。这个 controller 就是状态的一部分所以它放在父级 State 里。5.2 完整代码与运行流程解析下面是一段可跑的登录页核心代码我刻意把展示和状态分开写class LoginPage extends StatefulWidget { const LoginPage({super.key}); override StateLoginPage createState() _LoginPageState(); } class _LoginPageState extends StateLoginPage { final TextEditingController _usernameController TextEditingController(); final TextEditingController _passwordController TextEditingController(); bool _loading false; String _error ; override void dispose() { _usernameController.dispose(); _passwordController.dispose(); super.dispose(); } Futurevoid _handleLogin() async { setState(() { _loading true; _error ; }); final success await _doLogin( _usernameController.text, _passwordController.text, ); if (!mounted) return; setState(() { _loading false; if (!success) { _error 用户名或密码错误; } }); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(登录)), body: Padding( padding: const EdgeInsets.all(24), child: Column( children: [ UsernameField(controller: _usernameController), PasswordField(controller: _passwordController), if (_error.isNotEmpty) Text(_error, style: TextStyle(color: Colors.red)), LoginButton(loading: _loading, onPressed: _handleLogin), ], ), ), ); } } class UsernameField extends StatelessWidget { const UsernameField({super.key, required this.controller}); final TextEditingController controller; override Widget build(BuildContext context) { return TextField(controller: controller, decoration: const InputDecoration(labelText: 用户名)); } } class PasswordField extends StatelessWidget { const PasswordField({super.key, required this.controller}); final TextEditingController controller; override Widget build(BuildContext context) { return TextField( controller: controller, obscureText: true, decoration: const InputDecoration(labelText: 密码), ); } } class LoginButton extends StatelessWidget { const LoginButton({super.key, required this.loading, required this.onPressed}); final bool loading; final VoidCallback onPressed; override Widget build(BuildContext context) { return ElevatedButton( onPressed: loading ? null : onPressed, child: loading ? const Text(登录中...) : const Text(登录), ); } }运行流程其实很清晰页面第一次创建时先走 createState再走 initState这里用的字段初始化然后 build 渲染出输入框和按钮。输入内容时TextField 内部自己更新显示我们不用 setState点登录按钮时_handleLogin被触发第一个 setState 把 loading 置为 true界面上的按钮立刻变成“登录中...”并且不可再点等登录请求完成后再 setState 更新 loading 和 error。整个过程里UsernameField、PasswordField、LoginButton 这三个组件完全没有自己的状态它们只是从父级拿到参数再渲染把操作回调透传给父级。你会发现这样的代码读起来非常舒服每个组件都在做自己该做的事状态和展示之间有一条清晰的线。等以后页面复杂度上来了这条线会帮你省下大量排查时间。5.3 我踩过的坑和给你的入门建议最后分享几个我在从“会用 setState”到“理解分离”这个过程中亲身踩过的坑希望你能绕开。第一个坑是在 initState 里直接用 BuildContext 去弹 SnackBar。当时我刚开始写 Flutter在 initState 里写了一段加载失败提示结果运行直接报错。后来才知道initState 执行时这一帧还没完成你用的 context 还不能安全地用于弹出提示。正确做法是用WidgetsBinding.instance.addPostFrameCallback等这一帧画完再去弹。第二个坑是忘了在 dispose 里释放 TextEditingController。这不算运行时报错但 IDE 会一直给你黄色警告而且长时间使用页面的场景里内存会实实在在一点一点往上涨。后来我养成习惯只要在 State 里 new 了 controller、AnimationController、Timer关闭前就一定会去 dispose 里清一遍。第三个坑是过度 setState。早期我写过一个物理课用的答题页面每刷新一道题就给整页 setState。后来发现界面在做一些无关动画时也时常卡顿排查了很久才发现我把一个跟答题无关的倒计时小组件也放在了大页面的 State 里每次倒计时更新都把整页重建一遍。拆成独立的 StatefulWidget 后问题立刻消失。如果你现在就在这个阶段我的建议很简单先把计数器吃透然后把计数器改造成一个“可增减数量的商品卡片”再尝试把状态往父级提一层让子组件变成 StatelessWidget 只接收参数。每写一个新页面时都先问自己一句——这个页面有自己会变的数据吗没有就写 StatelessWidget有再问——这些数据必须由自己保存吗不见得能上提就上提。这个判断练多了你对 Flutter 这个“第一次分离”的理解就不会只停留在语法层面了。状态管理库以后都可以慢慢学setState 虽然不算最高级的手段但它是理解整座 Flutter 状态大厦的地基。地基打牢了后面学什么都不会慌。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →