ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter自定义组件实战:StatefulWidget生命周期与CustomPaint绘制技巧

Flutter自定义组件实战:StatefulWidget生命周期与CustomPaint绘制技巧

组件不是"画"出来的,是"设计"出来的

接上一篇继续聊 Flutter Widget。上一篇我们把 StatelessWidget 的命门摸了一遍——纯展示、无状态、build 里一锤子买卖,适合做图标、标签、静态卡片。但很多朋友留言问:按钮点了要变色、轮播图要跟着索引走、数据回来了页面要刷新,这些怎么封装成自定义组件?这就是本篇要解决的实战问题。

本篇会围绕自定义组件(2)这个核心,把 StatefulWidget 的生命周期、事件回调设计、数据回传、以及 CustomPaint 自绘视觉这几个硬骨头逐个拆开。适合已经能写简单静态 Widget、但一碰到"组件内部要变、要反馈"就卡壳的同学。我会用三个可复用的真实组件案例串起整篇,每个都带完整代码和踩坑记录。


1. 组件设计的第一道分岔路:组合还是继承

1.1 你不需要什么 Widget 基类继承

我见过很多从 Java 或 C++ 转过来的开发者,写 Flutter 的第一反应是搞一个BaseWidget,然后所有组件都 extends 它,再留几个 protected 方法给子类重写。这个思路在 Flutter 里基本是个坑。

Flutter 的组件体系是"组合优先于继承"。Text、Container、Row这些内置组件,全部是组合出来的,而不是从某个抽象基类一层层继承下来的。你可以直接用Container(decoration: ..., child: ...)拼出一个按钮,然后把这个按钮包进一个函数返回,它就是一个新组件,根本不需要 class。

但在团队协作中,函数返回组件有个问题:没有类型标识,也挂不了 key,更没法在后面挂 State。所以当组件开始有内部状态、有交互反馈时,就该用类来封装,但类与类之间尽量用"传入 Widget"或"传入回调"来协作,而不是搞继承树。我参与的某跨平台系统项目里有一条约定:继承层级超过一层,就要停下来复议。这条约定救了不少人——组件复用靠的是接口清晰,不是父类多能打。

1.2 什么时候必须上 StatefulWidget

判断标准其实很简单:组件内部的某个数据,在 build 执行之后还会变,并且变化后要重新渲染,那它就是状态,这个组件就需要是 StatefulWidget。

举一个常见的例子:一个数字统计卡片。外层传进来一个数值,卡片内部想让它做一次平滑滚动动画,从旧值滚到新值。这个"当前显示到哪一位"的数值,就是内部状态,必须用 StatefulWidget 来管理。如果这个值不会变,只是纯展示,那 StatelessWidget 就够了。

我在实际项目中总结的划分口诀:

  • 组件内容由外部数据决定,且外部数据不会在组件内被修改 → Stateless
  • 组件内部有定时器、动画控制器、滚动位置、选中态、请求状态 → Stateful
  • 组件要拦截手势、做点击反馈 → 通常 Stateful(因为要动状态机)

1.3 一个统计卡片组件的设计拆解

下面这个AnimatedStatCard我会在全文反复拿出来用,先把基础版写出来:

class AnimatedStatCard extends StatefulWidget { final String title; final double value; final Color accentColor; final VoidCallback? onTap; const AnimatedStatCard({ super.key, required this.title, required this.value, this.accentColor = Colors.blueAccent, this.onTap, }); @override State<AnimatedStatCard> createState() => _AnimatedStatCardState(); }

注意这里的设计:title和value是外部传入的只读数据,组件自己只维护"动画过程"这个内部状态。onTap是回调接口,由使用方决定点击后干什么。

为什么要这样拆?因为职责边界越清楚,组件越好测、越好复用。动画这件事跟业务没关系,是组件自己的事;点击跳转是业务的事,得让使用者通过回调注入,组件不该硬编码一个跳转逻辑在里面。


2. 生命周期:让组件在正确的时机做正确的事

2.1 StatefulWidget 真正的生命周期

很多新手以为生命周期是initState和build两点一线。其实一个 State 对象的完整生命周期更接近:createState→initState→didChangeDependencies→build→(外部更新时)didUpdateWidget→build→(销毁时)dispose。

我用生活化类比理解这个东西:State 对象就像是组件雇佣的一个管家。initState是管家上任第一天,去熟悉户型、领钥匙;didChangeDependencies是管家去认认邻居(比如 InheritedWidget 这种共享数据源);build是管家每天按业主需求重新布置房间;didUpdateWidget是业主换了需求单,管家先看看哪些需求变了;dispose是管家下班前关水闸、拉电闸,把钥匙交回去。

这个类比的关键点在于:State 对象本身在整个生命周期里一般只有一个实例,但build会被调用非常多次。你在build里做网络请求、开定时器、创建大对象,就等于管家每次布置房间都去重装一次水管,既慢又容易泄漏。

2.2 initState 和 dispose 的标准姿势

给AnimatedStatCard加上动画控制器,生命周期就活起来了:

class _AnimatedStatCardState extends State<AnimatedStatCard> with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animation<double> _displayValue; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _displayValue = _controller.drive( Tween<double>( begin: 0, end: widget.value, ).chain(CurveTween(curve: Curves.easeOutCubic)), ); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } }

这里有两个实操要点。第一,AnimationController必须传vsync: this,这是 TickerProvider 提供帧回调的必需参数。不加就会报TickerNoOp异常。第二,dispose里必须_controller.dispose(),不释放的话,组件大量销毁重建时,底层 Ticker 会一直持有帧回调,轻则报警,重则卡帧。这个是我在某图像处理 Demo 里踩过的实打实的坑——连续切换 20 个页面后,动画直接掉到个位数帧率,排查半天发现是上一页的 Controller 没释放完。

2.3 didUpdateWidget 与外部数据变化

现在模拟一个场景:卡片外部接到了推送,value从 100 变成 260。此时 Flutter 会触发didUpdateWidget,然后重新 build。如果我只在initState里设了动画终点,后面数据变了画面是不动的。正确的做法是在didUpdateWidget里判断新旧值是否不同,不同就重新驱动动画:

@override void didUpdateWidget(covariant AnimatedStatCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { _displayValue = _controller.drive( Tween<double>( begin: _displayValue.value, end: widget.value, ).chain(CurveTween(curve: Curves.easeOutCubic)), ); _controller.forward(from: 0); } }

注意covariant关键字,这是重写didUpdateWidget时的标准写法。判断用的是oldWidget.value != widget.value,如果外部是对象类型,这里要谨慎处理引用相等性问题。

我还犯过一个错误:在didUpdateWidget里直接又 new 了一个 Tween,但忘记从_displayValue.value作为 begin,而是从 0 开始。结果每次数字更新,动画都从 0 滚一遍,视觉上闪得厉害。正确做法是从当前值滚到新值,这才是"滚动统计卡片"该有的体验。


3. 事件回调与对外交互:组件和外部世界的对话协议

3.1 回调参数设计的三个层次

自定义组件光会展示是不够的,还要能向使用方传递事件。Flutter 里最常见的做法就是回调函数。我总结了一层浅、中、深三种回调设计:

  • 浅层:VoidCallback,只通知"发生了点击/完成了动画"。适合按钮、开关、切换类组件。
  • 中层:回调带具体参数,比如ValueChanged<T>,把用户选中的值、滑动的进度、当前索引传给外部。
  • 深层:回调传对象,比如传一个"选中项的数据模型",外部拿这个模型直接去更新业务状态。

设计回调参数时有一个容易被忽略的细节:回调里不要传BuildContext给异步操作之外的用途。某人喜欢写onTap: (context) => Navigator.push(...),这没问题,但如果外部拿到 context 后存了起来,等异步返回再用,页面可能已经销毁了,再用 context 找路由会直接报错。我一般只在回调里传"纯业务数据",context 尽量不给。

3.2 一个带索引回传功能的立方体轮播指示器

我们做一个稍微复杂点的例子:一组小圆点指示器,用它来接轮播图索引。这个组件要管理"当前选中哪一个点"的状态,还要把索引变化回传给外部,外部拿到索引去切换轮播图。

class DotIndicator extends StatelessWidget { final int count; final int currentIndex; final ValueChanged<int>? onDotSelected; const DotIndicator({ super.key, required this.count, required this.currentIndex, this.onDotSelected, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(count, (index) { final selected = index == currentIndex; return GestureDetector( onTap: () => onDotSelected?.call(index), child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: selected ? 24 : 10, height: 10, margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: selected ? Colors.blueAccent : Colors.grey.shade400, borderRadius: BorderRadius.circular(5), ), ), ); }), ); } }

这里我刻意让DotIndicator还是 Stateless,因为索引这个状态可以是父组件持有的。轮播图页面的索引发给子组件,子组件点击时回调回去,由父组件setState更新索引并传递给轮播图。这种"状态提升"的模式在 Flutter 里非常重要——不是所有能动的组件都得是 StatefulWidget,能把状态放到外部去管理,会让组件更纯粹、更好测试。

onDotSelected?.call(index)这种写法是我的习惯。如果使用方不想响应点击,回调为空也不影响组件渲染。调用valueChanged(index)时,参数一定是"当前事件真正产生的值",不要自作聪明地传一个互相引用变化后的值,否则排查时你根本分不清是谁先改了谁。

3.3 回调时机:一前一后定乾坤

回调时机是个玄学。一个典型的 bug:组件内部在initState里调用外部回调,告诉外部"我准备好了"。此时父组件可能还没完成自己的构建流程,回调里setState可能触发 "setState() called during build" 的报错。

我的经验:外部回调的调用时机要尽量延后到"手势完成""动画完成""值变化之后",不要在 build 过程中、initState 过程中调用。如果确实要在 initState 发通知,用WidgetsBinding.instance.addPostFrameCallback把通知挂到当前帧结束之后:

@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onReady?.call(); }); }

这个技巧在处理 Tab 页联动、嵌套滚动的组件里非常实用。


4. 当组合不够用:用 CustomPaint 做真正意义上的自定义视觉

4.1 什么场景才需要自绘

很多人刚接触 Flutter 时,觉得所有组件都应该用 CustomPaint 来画,好像这样才能显得"高级"。实际开发中 90% 的界面用组合组件就够了。真正需要 CustomPaint 的场景缩一缩就这几类:

  • 不规则图形:仪表盘、进度环、水波纹、波浪线、折线图平滑曲线。
  • 高频自定义动画:需要逐帧控制绘制的粒子系统、加载动画。
  • 已有 canvas 绘制逻辑要移植到 Flutter 的场景,比如从网页端抄了一段 canvas 画法。

如果用组合能拼出来(比如用 ClipRRect 加渐变、用 Transform 旋转就能做的),就不要上 CustomPaint。CustomPaint 的调试成本更高,性能上也没比组合组件有明显优势。但该用的时候也别犹豫,比如做一个进度环,用图片资源或者多层 ClipRRect 拼出来极其痛苦,这时上CustomPainter就是正解。

4.2 画一个带渐变效果的进度环

先看代码,再讲每一步的用意:

class GradientProgressRing extends StatelessWidget { final double progress; // 0.0 ~ 1.0 final double strokeWidth; final List<Color> gradientColors; final Color trackColor; const GradientProgressRing({ super.key, required this.progress, this.strokeWidth = 10, this.gradientColors = const [Colors.blue, Colors.purple], this.trackColor = const Color(0xFFEEEEEE), }); @override Widget build(BuildContext context) { return CustomPaint( painter: _RingPainter( progress: progress, strokeWidth: strokeWidth, gradientColors: gradientColors, trackColor: trackColor, ), size: const Size.square(120), ); } } class _RingPainter extends CustomPainter { final double progress; final double strokeWidth; final List<Color> gradientColors; final Color trackColor; _RingPainter({ required this.progress, required this.strokeWidth, required this.gradientColors, required this.trackColor, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = (size.width - strokeWidth) / 2; final rect = Rect.fromCircle(center: center, radius: radius); final trackPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = trackColor; canvas.drawCircle(center, radius, trackPaint); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..shader = ui.Gradient.sweep( center, gradientColors, [0.0, progress], ); canvas.drawArc( rect, -pi / 2, 2 * pi * progress, false, progressPaint, ); } @override bool shouldRepaint(covariant _RingPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.strokeWidth != strokeWidth || oldDelegate.gradientColors != gradientColors || oldDelegate.trackColor != trackColor; } }

几个细节拆开说。

第一,drawArc的起始角度调成了-pi / 2,也就是从圆的最顶部开始画,而不是数学上的 3 点钟方向。进度环最常见的视觉起始位就是正上方,这个习惯我在很多项目中都用到,直接避免"环从右侧爬出来"的突兀感。

第二,sweepGradient的 stops 写的是[0.0, progress]。这里有个小技巧:当 progress 变化时,渐变色的终止位置跟着变,视觉上就是颜色从起点延伸到进度终点,让人感觉渐变是跟随进度走的。如果 stops 固定写[0.0, 1.0],进度只画到 0.5 时你会发现环上只显示渐变的一部分,另外一半颜色被截掉了,视觉上不够连贯。

第三,shouldRepaint的作用极其关键。Flutter 在每一帧都可能回调 painter 的shouldRepaint,如果你直接返回true,那整个绘制每一帧都重跑一遍,性能堪忧;如果返回false,组件尺寸变化或进度变化时画面不更新,Bug 就出来了。我传参时把所有会影响的字段逐个比一遍,这样 ** 只有值真正变化时才重绘 **。

4.3 CustomPaint 的命中检测与交互

CustomPaint 画出来的图形默认是透明的——它只是在 canvas 上画了一层像素,并不会参与手势命中检测。有人画了一个圆形的按钮,然后放了个 GestureDetector 在外面,发现点击不到。原因就是 CustomPaint 的尺寸区域里,透明区域不参与命中。

解决方案有两种:

一种是在CustomPaint外面包一层GestureDetector,并给CustomPaint设置一个尺寸,同时用behavior: HitTestBehavior.opaque让整个区域都可点击。这个最简单,适合圆形、矩形这种封闭图形。

另一种是针对不规则图形的精确命中检测:在GestureDetector的onTapDown里拿到全局坐标,转换成组件局部坐标,再自己写数学判断(比如判断坐标到圆心的距离是否小于半径)。这个适合需要"点在圆外面就不触发"的场景。

我做一版带点击跳转的进度环时,还碰到过一个坑:CustomPaint 的外层父组件设置了Transform.scale缩放,结果onTapDown拿到的坐标对不上画笔所在的坐标系。后来统一用RenderBox.globalToLocal转换,把全局坐标转成组件局部坐标,再和绘制时的数学计算做配对,问题才解决。凡是手势和自绘区域强绑定的组件,坐标转换这块一定要留神。


5. 常见问题与排查技巧实录

5.1 状态丢失:为什么 Group 切换后组件变形了

很多人用 IndexedStack 或 PageView 切换页面,发现页面切换回来后,组件内部的状态全乱了。比如轮播图切到第 3 页,切走再切回来又回到第 1 页。这不是状态真的丢了,而是你没用对 key。

Flutter 的 Widget 树在页面切换时可能会被整体重建,State 对象是否保留,取决于 Widget 在树中的位置和类型是否稳定。如果你在列表里用 index 当 key,列表重排后 State 跟着错位,状态自然乱。解决方法是给需要保活的组件一个稳定的身份标识,比如用业务 ID 做 key,而不是用 index。

我有一个非常深刻的教训:在做某跨平台系统的列表页时,每个卡片组件都用了 index 作为 ValueKey。结果某次排序功能上线后,用户发现点击卡片展开的选中态全部错乱。我排查到凌晨才意识到 key 不是稳定的,改成业务 ID 后问题消失。

5.2 setState 报错与动画泄漏的排查思路

先给张问题速查表:

症状常见根因处理方式
setState() called during build在 build 或 initState 同步调用 setState用addPostFrameCallback延后到帧末尾
setState called after dispose异步回调里没判断mounted调用前检查if (!mounted) return;
页面切走后动画还在跑Controller 没在 dispose 释放dispose里调用_controller.dispose()
列表滚动卡顿CustomPaint 的 shader 每帧重复创建把 Paint 对象缓存,shader 只在进度变化时重建
组件状态错乱key 用了 index 等不稳定取值改成稳定的业务标识

mounted这一个属性,是排查 setState 异常的第一道闸门。写法:

if (!mounted) return; setState(() { // 更新数据 });

异步网络请求回调里,这个检查是保命符。很多朋友问我怎么避免"setState after dispose",我统一回复:任何异步操作后要 setState,先看mounted。

5.3 逐帧调试:Flutter Inspector 和 Profile 模式

遇到神秘的重绘问题,不要急着翻代码。先开 Flutter Inspector 的 "Show Debug Paint" 和 "Highlight Repaints" 这两个开关。后者能直接告诉你哪些区域在持续重绘。

如果发现一个静态页面在疯狂重绘,先把AnimationController停掉,再检查是不是哪里用了MediaQuery.of(context)之类的依赖,它可能因为屏幕尺寸变化而触发 rebuild。这种问题常出现在"同一屏上放了很多个自绘组件"的场景,每个都监听 MediaQuery 的变化,滑动时全部触发 rebuild,卡顿就来了。

我做一个图表页面时,四条折线全由 CustomPaint 绘制,每条折线的 painter 里都依赖了 MediaQuery textScaleFactor。滑动页面时 textScaleFactor 没变,但 build 照样重新跑一遍,四条线全部重画,帧率掉了近一半。后来把 painter 的依赖从 Widget 里拆出去,跟多媒体无关的参数显式传入,问题才解决。这个经验说到底就是一个原则:Painter 的入参越少、越基础,重绘范围越小。


最后分享几个调试小技巧

自定义组件的调试,跟我玩断点一样:要会"定位边界"。第一优先检查的是"这到底是 Widget 更新了、State 变了、还是 Canvas 重绘了"。在didUpdateWidget、build、paint三个方法里分别打点,就可以快速定位故障层。我实际排查过的一个 case:组件表现是"视觉更新慢半拍",最后发现是 build 里创建新 Tween 的时机不对,问题根本不在绘制层,而在状态更新层。

第二个技巧是给自定义组件写独立的最小 Demo 页面。每次做组件,我都会在项目里的 kit page 页面上放一个独立入口,只渲染这个组件,配几个最基础的交互参数。这个习惯帮我节省了大量联调时间。调试组件最重要的是"把组件从业务环境里剥离出来"。

做自定义组件这件事,天赋不是第一位的,系统方法才是。组合优先、状态清晰、回调明确、自绘克制,把这四条刻在脑子里,你会发现自己也能做出很耐打的自定义组件。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进