资讯动态

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

发布时间:2026/10/18 11:00:22 来源:尧图企业网站定制
组件不是画出来的是设计出来的接上一篇继续聊 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 StateAnimatedStatCard 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 StateAnimatedStatCard with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animationdouble _displayValue; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _displayValue _controller.drive( Tweendouble( 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( Tweendouble( 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只通知发生了点击/完成了动画。适合按钮、开关、切换类组件。中层回调带具体参数比如ValueChangedT把用户选中的值、滑动的进度、当前索引传给外部。深层回调传对象比如传一个选中项的数据模型外部拿这个模型直接去更新业务状态。设计回调参数时有一个容易被忽略的细节回调里不要传BuildContext给异步操作之外的用途。某人喜欢写onTap: (context) Navigator.push(...)这没问题但如果外部拿到 context 后存了起来等异步返回再用页面可能已经销毁了再用 context 找路由会直接报错。我一般只在回调里传纯业务数据context 尽量不给。3.2 一个带索引回传功能的立方体轮播指示器我们做一个稍微复杂点的例子一组小圆点指示器用它来接轮播图索引。这个组件要管理当前选中哪一个点的状态还要把索引变化回传给外部外部拿到索引去切换轮播图。class DotIndicator extends StatelessWidget { final int count; final int currentIndex; final ValueChangedint? 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 ListColor 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 ListColor 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 页面上放一个独立入口只渲染这个组件配几个最基础的交互参数。这个习惯帮我节省了大量联调时间。调试组件最重要的是把组件从业务环境里剥离出来。做自定义组件这件事天赋不是第一位的系统方法才是。组合优先、状态清晰、回调明确、自绘克制把这四条刻在脑子里你会发现自己也能做出很耐打的自定义组件。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑