ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter动效底层:StatefulWidget、setState与AnimationController

Flutter动效底层:StatefulWidget、setState与AnimationController 做 Flutter 界面开发前期最爽的阶段是把静态页面搭出来一排卡片、一个表单、一套布局怎么摆怎么有。但一旦需求变成这个按钮按下去要有个呼吸动效这张卡片加载完要淡入这个 Tab 切换时指示条要平滑滑过去很多人就卡住了——明明该用的动画组件都试了界面就是纹丝不动。这个问题的答案最终都会落到 StatefulWidget 头上。这篇是这个系列的第 6 篇前几篇已经把生命周期、setState 基础都讲透了这一篇我们聚焦一个主线怎么让界面真正动起来。我会从动效的本质讲起拆解 setState 驱动 UI 更新的完整链路再用一个呼吸按钮把 Timer 和 AnimationController 两条路都走一遍最后回应几个群里高频踩坑问题Navigator 切页面后 State 到底丢不丢、TabBar 的点击动画怎么关、什么阶段该抛弃 StatefulWidget 硬写动画的思路。适合已经会写基础 Widget、开始实打实做交互动效的开发者看完可以直接抄代码也能明白底层在发生什么。1. 先弄明白一个底层问题为什么动起来绕不开 StatefulWidget我见过不少新人在社区提问Flutter 不是有 AnimatedContainer 吗有 AnimationController 吗我直接套用不就行了跟 StatefulWidget 有什么关系有关系而且关系是根子上的。所有动画机制最终都必须回答两个问题当前动画进行到哪一步了下一步要在屏幕上画成什么样能持续回答这两个问题的地方一定是那个能够持有可变状态的组件——也就是 StatefulWidget。动画组件只是把怎么把进度变成画面这件事做好了但进度存在哪里谁来触发下一帧始终要靠 State。1.1 静态和动态的分界线StatelessWidget 到底少了什么先对比一下两种组件的能力边界。StatelessWidget 的 build 方法很简单——拿传入的构造参数返回一棵 Widget 树。参数不变build 执行一百遍结果都是一样的。它没有一块私有可变数据的存储空间所以它天生做不到过一秒钟把自己变成另一个样子。StatefulWidget 则拆成了两个类Widget 本身仍然 immutable不可变但 State 对象是长期存活、允许变化的。State 里的字段可以存动画进度、存按钮当前是按下还是弹起、存当前选中了哪个页签。字段更新后配合 setState 告知框架重新 buildUI 才能进入下一帧。举一个生活化的例子StatelessWidget 像一张印好的纸质菜单客人点什么菜菜单内容都是固定不变的StatefulWidget 像一块黑板老师可以在上面不断擦写写到什么内容学生就看到什么内容。动画需要的就是这块黑板跟你用什么粉笔动画组件没关系黑板的身份必须是 StatefulWidget 提供的 State。1.2 动效的本质连续的状态变化再往深看一层。什么叫动画一个东西从 A 点移到 B 点中间不是瞬移而是连续过渡。放到 Flutter 的视角就是把这个连续过程拆成几十帧每一帧都重新 build 一次。StatefulWidget 在做动画时的表现形式就是高频调用 setState。你可以开一个 Timer每隔 16 毫秒更新一次当前进度这个 double 值UI 跟着变。AnimationController 底层也是一样的运动逻辑只是它把时间轴、插值计算、曲线缓动都封装好了你不需要自己手算步长。把这两点想明白了后面几章的所有代码都好理解了不管你是手写 Timer 循环还是用 AnimationController本质都是在周期性修改 State 里某个字段再触发 build。差别只在于谁帮你管理节奏、谁帮你做平滑插值。2. setState 背后的运行机制从状态变化到屏幕像素的完整链路很多教程会告诉你改完数据要调 setState但很少讲清楚 setState 到底在框架层面干了什么。为什么调了 setState 界面就会刷新为什么不调就不动为什么某些时机调用它还会崩2.1 setState 的本质给 Element 打上脏标记先看官方实现逻辑。setState 做的事其实很朴素把当前 State 对应的 Element 标记成 dirty脏然后交给帧调度管线处理。等到下一帧绘制前框架会从这些被标记的 dirty Element 出发调用其 build 方法重新生成新的 Widget 子树最后进入布局和绘制阶段。这个过程保证了 Flutter 不需要每次把整个页面全部重建一遍而是从状态变化的位置开始局部重建。这也是为什么StatefulWidget 拆得越细动效范围越可控——你只 setState 某个小组件dirty 标记就只落在它身上别的区域连 build 都不会执行。这里有个新手的经典误区改了 State 里的对象属性但没调 setState。比如_user.name Tom;从语言层面看数据确实变了但框架不知道你改了因为没有任何脏标记产生下一帧自然不会重新 build界面纹丝不动。在 Flutter 里数据变了和界面刷新了不是自动关联的必须靠 setState 这个上报动作桥接起来。2.2 什么时候调 setState 会出问题最容易踩的坑是异步回调里的 setState。比如网络请求返回后页面已经销毁了你还在回调里调用 setState框架会直接抛出异常setState() called after dispose()。这本质上还是 State 生命周期的问题——State 已经没了你还在操作一个失效对象。标准解法是在异步回调开头加一道防线// 异步回调或耗时操作返回后 if (!mounted) return; setState(() { // 更新 UI });mounted是 State 自带的只读属性表示当前 State 是否还挂载在 Element 树上。为 false 说明已经销毁或即将销毁此时无论数据有没有必要更新都不该再碰 setState 了。另一个让人困惑的场景是 initState 里调 setState。initState 执行的时候 State 刚刚插入树上首帧构建本来就会自动调用 build这时再去标记 dirty 没有意义Flutter 也会给你报警告。正确做法是直接在 initState 里给字段赋初始值build 会自动消费这个值。还有一个隐蔽的顺序问题StatefulWidget 的widget字段在 build 外部使用时容易绕进去。当父组件通过构造函数传新参数时Flutter 会调用didUpdateWidget而不是重建 State。如果你在动画代码里引用了 widget 的属性作为初始值但父组件改了参数State 不会自动拿到新值动画可能就停在旧状态上。处理方式是重写 didUpdateWidget在里面同步关键数据。2.3 每帧 setState到底是性能灾难还是稀松平常我早期写 Flutter 时特别害怕每帧 setState总觉得会带来性能问题。实际测试下来这个担心多半是过度的。Flutter 的 Widget 重建非常轻量真正有成本的是 Element 的 reconcile 过程和 RenderObject 的布局/绘制。只要你的 build 方法里没有写耗时的重计算、没有在 build 中创建大集合或执行 IO每帧 setState 完全扛得住。真正需要注意的反而是作用域。setState 的粒度越小重建范围越可控。如果一个页面里有三个区域都在做动画最好拆成三个独立的 StatefulWidget各自持有自己的动画字段。这样某个区域动画时其余区域的 Widget 树连 build 都不用碰这是动效流畅性的第一道保障。3. 手写一个呼吸按钮两条路径实操对比原理说到这儿来点能跑的东西。我做一个非常常见的动效场景——呼吸按钮。它持续地放大缩小模拟呼吸节奏很多录音 App 的红色录制键、直播间的连麦按钮都是这个交互。拆解下来需求很简单无限循环缩放范围从 1.0 到 1.15 倍节奏平滑不能有跳变页面销毁后动画停干净不能有内存泄漏。这个按钮我写两种实现。第一种用 Timer 加 setState直观但粗糙第二种用 AnimationController 加 SingleTickerProviderStateMixin是生产环境的标准写法。3.1 方案一Timer 加 setState 的朴素实现class BreathingButton extends StatefulWidget { const BreathingButton({super.key}); override StateBreathingButton createState() _BreathingButtonState(); } class _BreathingButtonState extends StateBreathingButton { double _scale 1.0; bool _growing true; Timer? _timer; override void initState() { super.initState(); _timer Timer.periodic(const Duration(milliseconds: 16), (timer) { setState(() { if (_growing) { _scale 0.004; if (_scale 1.15) { _growing false; } } else { _scale - 0.004; if (_scale 1.0) { _growing true; } } }); }); } override void dispose() { _timer?.cancel(); super.dispose(); } override Widget build(BuildContext context) { return Center( child: Transform.scale( scale: _scale, child: Container( width: 96, height: 96, decoration: const BoxDecoration( color: Color(0xFF6C5CE7), shape: BoxShape.circle, ), child: const Icon(Icons.mic, color: Colors.white, size: 36), ), ), ); } }这段代码的逻辑一眼能看懂每 16 毫秒更新一次_scale值_growing标志位控制放大缩小的方向让缩放范围锁定在 1.0 到 1.15 之间。dispose里取消 Timer防泄漏。但仔细想想这个写法有三个问题第一步长0.004是拍脑袋定的如果你的设备帧率波动比如某些 Android 机掉到 45 帧动画实际速度和 iOS 上完全不同第二缩放曲线是线性的视觉上偏机械没有那种呼吸应有的韵律感第三你在手动管理定时器和方向标志位逻辑稍微复杂一点就容易乱。作为理解状态驱动 UI的教学案例它很合格作为生产代码它不够理想。3.2 方案二AnimationController 加 SingleTickerProviderStateMixinclass BreathingButton extends StatefulWidget { const BreathingButton({super.key}); override StateBreathingButton createState() _BreathingButtonState(); } class _BreathingButtonState extends StateBreathingButton with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animationdouble _animation; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(reverse: true); _animation Tween(begin: 1.0, end: 1.15).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Center( child: AnimatedBuilder( animation: _animation, builder: (context, child) { return Transform.scale( scale: _animation.value, child: child, ); }, child: Container( width: 96, height: 96, decoration: const BoxDecoration( color: Color(0xFF6C5CE7), shape: BoxShape.circle, ), child: const Icon(Icons.mic, color: Colors.white, size: 36), ), ), ); } }这个版本里值得注意的点有三个。SingleTickerProviderStateMixin给 AnimationController 提供了 vsync 信号源让控制器能感知当前 State 是否在屏幕上活跃。页面被覆盖或销毁时Ticker 自动停止动画不会再白白消耗 CPU。这是相对于 Timer 方案最大的优势——你不需要手动关心页面可见性。repeat(reverse: true)让动画无限循环并且正反来回900 毫秒跑完一个完整呼吸周期这个时长是我调出来的视觉上比 600 毫秒从容比 1200 毫秒更紧促适合大多数按钮场景。AnimatedBuilder监听动画值变化每帧只重建Transform.scale这一小段子树而容器和图标本体通过child参数传入后被复用不参与每帧构建。这是动画性能优化的一个细节把不变的子树放到 child 里builder 里只写会变的变换层。3.3 两个方案的取舍如果你追求的是快速验证一个动效创意Timer 方案甚至不用引入 Controller直接往 build 里塞个 Transform 就行。但一旦涉及到循环动画、曲线缓动、页面生命周期联动直接用 AnimationController 是更省心的选择。它把最常见的时间轴、插值、循环机制都封装好了。我个人的建议很直接做任何有持续时间的动效不要手写计时器。手写 Timer 意味着你需要自己处理帧率差异、时间累积误差、页面不可见时的暂停逻辑这些都是没必要重复造轮子的部分。4. 让动效活起来的配套机制vsync、Tween、AnimatedBuilder 怎么配合上一章的方案二里有几个概念可能第一次接触的人会困惑vsync 是什么Tween 跟 Animation 什么区别AnimatedBuilder 到底在干什么这一章把底层机制讲透你才能举一反三做出滑动手势动画、翻页动画、进度动画。4.1 vsync 解决了什么问题AnimationController 的构造函数里vsync: this这行乍看有点莫名其妙。它的作用是给控制器提供一个 TickerProvider帧回调提供者。Ticker 会在每一帧开始前回调一次动画就是靠这个东西逐帧推进的。如果页面已经不在屏幕上Ticker 还不停的话动画就会在一个看不见的地方空转消耗 CPU 和电量。SingleTickerProviderStateMixin会在 State 生命周期里自动管理 Ticker 的启动和停止State 插入树上时激活离开屏幕或销毁时停止。这也就解释了为什么动画和 StatefulWidget 绑定得如此紧密——State 的生命周期恰好决定了 Ticker 的生命周期。你没法脱离 State 去玩一套可持续的动画因为没有任何其他内置机制能这么干净的帮你处理页面不可见时自动停这件事。4.2 Tween、CurvedAnimation、AnimatedBuilder 的分工Tween负责把 0 到 1 的抽象进度映射成你需要的数值区间。Tween(begin: 1.0, end: 1.15)就是把控制器 0 到 1 的进度换算成缩放倍率。CurvedAnimation负责给进度做缓动让线性时间变成先慢后快、或带一点回弹的效果。Curves.easeInOut是最常用的开头加速、结尾减速呼吸感够自然。AnimatedBuilder负责把动画值和 UI 关联起来它内部监听 animation 的变化并在变化时调用 builder 重建被包裹的 UI。这三个东西是可以灵活组合的。同一个 AnimationController 的进度可以同时喂给多个 Tween分别映射成缩放大、透明度、偏移量。比如你要做一个按钮出现并上移的入场动画完全可以一个控制器驱动三个 Tween不必开三个控制器。4.3 什么时候用 AnimatedBuilder什么时候手写 ListenerAnimatedBuilder 适合大多数根据动画值修改某个变换属性的场景比如缩放、旋转、透明度。某些特殊场景下比如你用的是 CustomPaint 自绘图形需要把动画值传给 Paint 对象且不希望重建任何跟动画无关的 Widget这时可以采用_controller.addListener手动取_controller.value去更新。不过说实话多数项目中 AnimatedBuilder 已经能完成 90% 的需求。手写 Listener 还要额外处理 setState 时机边界情况多反而更容易出问题。能用 AnimatedBuilder 解决的问题就别绕远路。5. 高频踩坑实测页面切换后的状态丢失与 TabBar 动画控制这一章节的内容全是从实际开发和使用 Flutter 社区时高频出现的问题里挑出来的。这两个问题都直接关系到 StatefulWidget 的状态生命周期值得单独拿出来说。5.1 Navigator 切换页面State 到底丢不丢结论先行默认情况下用 Navigator.push 跳转到新页面再返回时前一个页面的 State 不会被销毁所有状态都还在。原因是路由栈的工作原理push 只是把新路由压入栈顶旧路由对应的 Widget 树仍然挂在树上只是不处于活跃状态它的 State 也一直保留着。所以最常见的进详情页再返回这种场景不用操心状态丢失。真正会丢状态的是下面几种情况路由被替换或移除使用Navigator.pushReplacement、popUntil、pushAndRemoveUntil时被移除的路由会被 dispose对应的 State 也就销毁了。Widget 树结构被条件切换这个最阴险。比如 build 里写了if (isLogin) LoginPage() else HomePage()从登录态切到非登录态LoginPage 的子树被整体移除State 就没了再切回来会重新走 initState。根 Navigator 被重置某些白屏恢复、全局登出逻辑会把整个路由栈清空所有页面 State 随之销毁。如果你希望页面在可见性变化时保住状态得靠下面两板斧。5.2 保状态的两套方案IndexedStack 与 AutomaticKeepAliveClientMixin第一个方案是 IndexedStack。它会一次性构建所有子页签只是通过 index 控制显示哪一个。因为子 Widget 始终挂在树上State 自然不会销毁。适合底部 Tab 切换这种固定数量页签的场景代价是首次会构建全部页面如果每个 Tab 都是重型页面启动时间会受影响。第二个方案是AutomaticKeepAliveClientMixin。这个 Mixin 用于 ListView.builder、PageView 这类有懒加载机制的滚动场景。列表项滑出屏幕后默认会被清理掉State 随之销毁混入这个 Mixin 并重写wantKeepAlive返回 true滑出屏幕的元素就会保留状态滚动回来时不会丢位置、不会重新初始化。它有个使用注意点必须在 build 里调用super.build(context)否则 keepAlive 信息没法正常上报给 Sliver 容器这是我一开始用的时候不知道结果列表一直不保活困扰了大半天。5.3 TabBar 的点击动画想关掉就自己接管TabBar 点击取消动画效果这个问题经常在设计师提需求时出现。默认 TabBar 的指示器会在点击页签时滑动过去有时候设计师就要那种点击即切、毫不拖泥带水的感觉这个默认动画反而碍事。官方的 TabBar 没有直接暴露关闭指示器动画的开关。靠现有参数做最多是把指示器样式改得固定一些视觉上勉强能接受。真正想把动画控制权攥在自己手里的我推荐第三种思路干脆不用 TabBar 组件自己用 Row 加装饰线实现页签。思路很简单一个 Row 里放若干块页签文字被选中的文字高亮底部塞一条用 AnimatedContainer 包裹的装饰线宽度和位置跟着选中索引走。这样动画节奏完全由你定义——想平滑过渡就用 AnimatedContainer 的 duration 控制想彻底去掉动效就直接根据选中索引切换颜色一步到位。这个方案还有个附加好处自定义页签后不再受 TabBar 默认高度、padding 的限制视觉效果更自由。缺点是要手动处理页签的点击事件、选中态样式、分割线位置工作量稍微多一点但可控性是最高的。6. 什么阶段该考虑别硬写 StatefulWidget状态共享与组件通信的转折点讲到这里你可能会形成一个印象StatefulWidget 就是 Flutter 动效的全部答案。前五章确实都在给你强化这个认知但把视角拉到真实项目规模这个结论是要打折扣的。6.1 setState 方案的边界什么时候开始“不顺手”了我把开始不顺手的典型信号列出来你对照自己的项目看看多个区域共享同一状态比如一个页面上部是计数器下部是累计金额中间有几个按钮都在改同一个数据源。把这些状态全部堆到页面级 State 里setState 一调整片区域全重建跟前面说的作用域越小越好原则完全背道而驰。跨页面共享状态登录态在首页改了个人中心页要同步。如果全靠 StatefulWidget 往上传回调再往下传参数代码会变成一条巨长的调用链改一处要动几个文件。动画参数需要被外部修改父组件想动态改变子组件动画的时长、起点、终点。StatefulWidget 的构造参数虽然可以传但一旦业务复杂层级一深维护成本直线上升。组件通信的话题热词里也有人问flutter 组件通信其实说到底就是状态该放哪的问题。父子之间用构造函数回调就能解决兄弟节点就得把状态提升到公共父级跨页面就要上全局方案了。6.2 从 InheritedWidget 到 Provider/Bloc思路的转变当你发现自己开始频繁在组件间手动传递回调函数代码里出现三层以上参数穿透时就该考虑引入更高层的状态管理方案了。第一个可以接触的是 InheritedWidgetFlutter 自带的底层机制Theme.of(context)、MediaQuery.of(context)都是它的应用。它能让你在任意子节点读取到上层提供的数据不用一层层传参。再往后走就是社区状态管理库的范畴Provider、Riverpod、Bloc 这类工具本质上是把状态的存储、修改、共享从 Widget 树里抽离出来用更独立的逻辑单元去管理。这里有一点我想特别强调状态管理库并不是要替代 StatefulWidget。页面局部的动画状态、某个小组件自己的临时状态照样应该用 StatefulWidget 来管。状态管理库解决的是跨组件共享和逻辑组织的问题属于上层建筑StatefulWidget 解决的是局部可变动效的问题是基础设施。两者从来都是配合关系不是替代关系。具体怎么选型、Bloc 的代码架构长什么样这个系列后面会专门开篇章去写。这里先埋个伏笔当你决定引入状态管理框架时回头再看 StatefulWidget 的定位会清晰很多。写到这里回头想想这几年做 Flutter 动效的经验我最想对新手说的是别急着往项目里塞状态管理框架先把 StatefulWidget 和 setState 这套底层机制彻底玩明白。你会发现 Flutter 动画的神秘感其实是纸老虎内核不过就是改一个字段、打一个脏标记、重建一帧的循环。我早年做录音按钮时把整套动画逻辑糊在一个巨型 State 里代码又长又乱后来改成把动效拆成独立 StatefulWidget每个组件只负责自己的动画状态整个工程清爽了不止一个量级。最后分享一个实践小技巧动画里涉及的颜色、尺寸、时长参数一开始就统一放进一个 constants 文件或主题扩展类里。不要在每个 Widget 里写死Duration(milliseconds: 900)后面设计师跟你说呼吸节奏再慢一点你只需要改一处全项目奏效。这个习惯帮我省了不知道多少返工时间也希望对你管用。
返回列表