做 Flutter 开发这几年我最早遇到进度条需求时第一反应也是直接用LinearProgressIndicator。那时候还觉得挺好样式能跑就行。可慢慢发现几乎所有真实场景都会问你要“定制”能力这个条要高一点、圆角要更大、颜色要跟随状态变化、进度数字要叠在条上、进度动起来要有缓动曲线……内置控件在这些需求面前非常被动——它只能给你一种预设的“线性 半透明色块”效果可配置项就那么几个颜色一深一浅换个样式都需要绕路处理。所以后来我的项目里凡是涉及进度展示一律走上自绘这条路。自绘进度条本质就是用 Flutter 的CustomPainter拿到 Canvas 绘制能力把轨道、进度、圆角、渐变、动画这些元素全部自己画出来。它解决的问题非常具体内置控件给不了你要的视觉效果或者给了但你没法精确控制。这篇文章会从思路、基础原理、完整代码到踩坑记录把“Flutter 自绘进度条”这件事一次性讲透适合刚接触 CustomPaint 的新手也适合已经在用但被各种奇怪问题卡住的老手。1. 为什么非要自绘进度条内置控件的“够用但不够用”1.1 LinearProgressIndicator 到底差在哪我不否认LinearProgressIndicator在极简场景下能用。它的问题不是“不能用”而是可定制空间太小。你能调的就那么几个参数value当前进度、backgroundColor轨道色、color进度色、minHeight高度。看上去够用实际上只要需求稍微复杂一丁点立刻破功想要渐变进度条没有color只接受单个颜色。想要圆角没有它默认是直角连参数都不给。想要进度条内部叠加一个百分比数字没有。想要进度值变化时带缓动动画有但动画曲线和时长都不可控。想要确定性和不确定性indeterminate两种状态切换时平滑过渡默认实现的跳变很难看。这时候有人会说那我用ContainerFractionallySizedBoxClipRRect组合一下不就行了确实可以做一个不会动的静态进度条完全没问题。但只要加上动画、渐变、以及频繁更新的进度值这种“纯 Widget 组合”的方式就会暴露出性能问题——进度每更新一次整个 Widget 树都要参与重建和布局文本、圆角、阴影全部重新计算。而自绘方案把这些都压进一层 Canvas一次paint调用全部搞定性能和可控性都高一个量级。1.2 自绘到底解决了什么核心问题自绘的核心价值在于你把“视觉表现”的控制权完全拿到了自己手里。画多宽、画多高、圆角半径多少、渐变从哪儿开始到哪儿结束、进度条走到一半是什么颜色、阴影怎么打、文字放在哪个位置全部由你的代码决定。框架只提供一个画布和一支画笔画什么、怎么画你是唯一作者。这带来两个直接好处复用性。写一个通用CustomPainter整个项目的进度条样式统一任何页面传入不同参数即可得到不同外观。产品改设计时只改一处核心实现不用满项目找进度条代码。性能可控。CustomPaint是一个 RenderObject只在自己发生变化时触发重绘不需要重建整个 Widget 树。配合shouldRepaint做细粒度控制可以做到只有进度值真正变化时才重画。1.3 什么场景才值得自绘自绘不是银弹它也有学习成本和调试成本。根据我的经验下面这几种场景非常值得走自绘品牌定制型进度条有专属色系、圆角规范、动画曲线希望全 App 统一的。数据展示密集场景比如文件上传、下载、磁盘空间占用、学习进度统计需要实时更新、精确展示数值的。特殊形态需求圆形进度环、半环形、跑道形、带刻度线的进度条等内置控件完全无法覆盖。跟手交互场景可拖动进度条、播放器进度调节需要精确处理手势和绘制层关系的。反过来如果产品需求就是“一个默认的蓝色横条”放着别自绘没必要。2. 自绘前必须理解的三张牌CustomPainter、Canvas、Paint2.1 CustomPainter 的生命周期与回调很多新手第一次接触自绘直接卡在 我应该继承什么类 上。核心类就一个CustomPainter。你自己写的 Painter 类继承它然后实现两个方法paint(Canvas canvas, Size size)绘制逻辑全部写在这里。系统会把当前画布和画布尺寸传给你。shouldRepaint(covariant CustomPainter oldDelegate)告诉框架“旧的绘制结果还能不能用”。如果新旧 Painter 的核心参数一样就返回false框架跳过重绘不一样就返回true重新执行paint。这里有个关键理解shouldRepaint不是“是否需要绘制”的开关而是“绘制结果是否失效”的判断。你要比对新旧实例里跟视觉强相关的属性而不是简单返回true。比如进度值一样、颜色一样、圆角一样那确实没必要重画。2.2 Canvas 坐标系与 Size 的坑Canvas 的坐标系是 Flutter 自绘最容易踩坑的地方。它的原点0,0在画布左上角x 轴向右递增y 轴向下递增单位是逻辑像素lp。这意味着你想画一个从左边开始的进度条起点就是(0, 0)。你想画一个圆角半径等于高度一半的“胶囊”需要先拿到size.height再除以 2。size由CustomPaint的size参数决定。如果你写CustomPaint(size: Size(double.infinity, 8))那传给你的size.width就是父级约束下的实际宽度如果你不传size又不约束外层size会变成Size.zero绘制结果直接“消失”。我第一次写自绘进度条时就犯过这个错CustomPaint放在Column里没有给宽度约束结果调试半天发现 Canvas 的 size.width 是 0进度条根本画不出来。正确做法是在外层套SizedBox(width: double.infinity, height: 8)或者让CustomPaint自己实现computeMinIntrinsicWidth这类布局方法。工程上最省事的方案是前者。2.3 Paint 的几个常用属性组合Painter 里最常用的是Paint对象。初始化的方式直接决定绘制效果几个关键属性我从实际项目中总结的经验如下stylePaintingStyle.fill填充还是PaintingStyle.stroke描边。进度条轨道通常用 fill 画圆角矩形圆形进度环用 stroke 画圆弧。strokeWidth描边模式下笔触的宽度。画圆形进度环时这个值就是环的粗细。strokeCapStrokeCap.round会让描边端点变成圆头。画圆环时如果不设置圆弧两端会看到明显的截断很丑。shader渐变效果靠它实现。LinearGradient.createShader(rect)返回一个 Shader赋给shader属性后画出来的内容就带渐变。maskFilter做外发光、模糊效果时用MaskFilter.blur。注意这玩意性能开销不小别在每帧动画里滥用。一个常见的误区是渐变需要调用LinearGradient.createShader()并且传入一个Rect。这个 Rect 的范围就决定了渐变的起止区域。你传的 Rect 是进度条的实际绘制区域渐变才会贴在进度条上否则可能出现颜色错位、渐变方向不对的问题。3. 从零实现一个渐变圆角进度条3.1 第一步搭好 Widget 与 Painter 的架构先给最终的成果定个目标一个支持渐变、圆角、缓动动画、并且可以叠加百分比文字的水平进度条。架构上我把它拆成两层外层是一个StatefulWidget负责管理动画状态对外暴露value、height、colors等参数。内层是一个CustomPainter只接收当前进度值和配色参数纯函数式地把画面画出来。为什么要拆成两层因为CustomPainter不应该持有任何可变的业务状态。它就像一个“绘图函数”你给我参数我给你画面。动画状态放在 Widget 层管理通过参数方式传给 Painter这样职责清晰、测试也方便。某同学第一次写的时候把所有动画代码塞进 Painter 里结果更新进度时连画布都重建了性能惨不忍睹后来按这个拆法重构才正常。3.2 第二步轨道与进度条的核心绘制代码这里直接把 Painter 的完整实现贴出来import package:flutter/material.dart; class CustomProgressPainter extends CustomPainter { final double progress; final Color trackColor; final Gradient gradient; CustomProgressPainter({ required this.progress, required this.trackColor, required this.gradient, }); override void paint(Canvas canvas, Size size) { // 防止尺寸为零导致崩溃或不可见 if (size.width 0 || size.height 0) return; // 轨道背景整体是一个圆角矩形 final trackRect Offset.zero size; final trackRRect RRect.fromRectAndRadius( trackRect, Radius.circular(size.height / 2), ); canvas.drawRRect(trackRRect, Paint()..color trackColor); // 进度为 0 时不需要画任何内容 if (progress 0) return; // 计算实际进度宽度并做越界保护 final clampedProgress progress.clamp(0.0, 1.0); final progressWidth size.width * clampedProgress; final progressRect Rect.fromLTWH(0, 0, progressWidth, size.height); final progressRRect RRect.fromRectAndRadius( progressRect, Radius.circular(size.height / 2), ); canvas.drawRRect( progressRRect, Paint()..shader gradient.createShader(progressRect), ); } override bool shouldRepaint(covariant CustomProgressPainter oldDelegate) { return oldDelegate.progress ! progress || oldDelegate.trackColor ! trackColor || oldDelegate.gradient ! gradient; } }解释几个关键设计圆角用的是size.height / 2这是“胶囊形”进度条的标准做法。如果你写死一个Radius.circular(4)高度变成 20 的时候圆角还是 4视觉上像个怪异的小圆角矩形。用高度一半永远保证两侧是完美的半圆。进度条宽度直接用size.width * progress没有处理最小值问题。实际产品里进度非常小比如 1%时圆角矩形会变得很扁有时候视觉上不够明显。我的处理是加一个最小可见宽度比如 4 像素用max(progressWidth, minVisibleWidth)兜底。渐变范围传的是progressRect注意不是整个size。这样渐变只存在于进度已覆盖的区域看起来是“进度走到哪里颜色跟到哪里”。如果你想要“整个进度条区域永远渐变只是被遮罩切出当前进度”的效果那就需要换一种裁剪方案后面进阶部分再展开。3.3 第三步用 AnimationController 把进度“动”起来Painter 画的是静态画面真正的“进度动画”要靠 AnimationController 驱动。先看完整 Widget 代码class GradientProgressBar extends StatefulWidget { final double value; final double height; final Color trackColor; final ListColor gradientColors; final bool showText; final TextStyle? textStyle; const GradientProgressBar({ super.key, required this.value, this.height 8, this.trackColor const Color(0xFFE8EAF0), this.gradientColors const [Color(0xFF4A90E2), Color(0xFF50C878)], this.showText true, this.textStyle, }) : assert(value 0 value 1, value 必须在 0~1 之间); override StateGradientProgressBar createState() _GradientProgressBarState(); } class _GradientProgressBarState extends StateGradientProgressBar with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animationdouble _animation; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); // 首次动画从 0 到 widget.value _animation Tweendouble(begin: 0, end: widget.value) .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.forward(); } override void didUpdateWidget(covariant GradientProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value ! widget.value) { _animateTo(widget.value); } } void _animateTo(double target) { // 以当前动画值为起点动画到新目标值 final begin _animation.value; _animation Tweendouble(begin: begin, end: target) .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.forward(from: 0); } override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return SizedBox( height: widget.height, child: Stack( alignment: Alignment.center, children: [ Positioned.fill( child: AnimatedBuilder( animation: _animation, builder: (context, child) { return CustomPaint( painter: CustomProgressPainter( progress: _animation.value, trackColor: widget.trackColor, gradient: LinearGradient( colors: widget.gradientColors, begin: Alignment.centerLeft, end: Alignment.centerRight, ), ), ); }, ), ), if (widget.showText) IgnorePointer( child: Text( ${(_animation.value * 100).toStringAsFixed(0)}%, style: widget.textStyle ?? TextStyle( fontSize: widget.height * 0.6, color: Colors.black54, fontWeight: FontWeight.w600, ), ), ), ], ), ); } }这里有几个值得说清楚的点SingleTickerProviderStateMixin是必须的它给 AnimationController 提供 vsync垂直同步信号。漏了它会直接报错提示 “provide a vsync”。每次value变化时先用当前动画值作为新 Tween 的起点而不是从 0 开始。这样连续多次更新时会形成一个平滑衔接不会往回跳。AnimatedBuilder只包了CustomPaint文字部分自己监听动画值。我故意让两者分开这样动画值变化时文字和进度条各自刷新互不阻塞。文字放在IgnorePointer里避免它拦截点击事件。如果进度条支持点击或手势这一步很重要否则文字区域会挡住下面的 InkWell。3.4 第四步百分比文字与进度条的位置处理很多人纠结文字放哪。简单方案是放中间居中但实际产品里最常见的是放进度条右侧或者正上方。放右侧的处理方式我单独说明一下Widget build(BuildContext context) { return Row( children: [ Expanded( child: AnimatedBuilder( animation: _animation, builder: (context, child) { return LayoutBuilder( builder: (context, constraints) { return SizedBox( height: widget.height, width: constraints.maxWidth, child: CustomPaint( painter: CustomProgressPainter( progress: _animation.value, trackColor: widget.trackColor, gradient: LinearGradient(...), ), ), ); }, ); }, ), ), const SizedBox(width: 8), ValueListenableBuilder( valueListenable: _animation, builder: (context, v, child) { return Text( ${(v * 100).toStringAsFixed(0)}%, style: TextStyle(fontSize: 12), ); }, ), ], ); }注意这里用了LayoutBuilder拿具体宽度。原因和前面讲的一样CustomPaint在Expanded里实际的宽度约束需要从LayoutBuilder获取不这样做的话progressWidth计算会基于错误的值。文字放在进度条外部后视觉上更干净也避免了“文字挡住进度条”的问题。不过这种方式有个小缺点进度条实际可用宽度变少了计算文字和进度条之间的间距时要注意别让进度走到 100% 时撞上文字。4. 实操记录三种常见场景下的接入方式4.1 普通页面里的固定进度展示这种场景最简单比如展示用户的成长值、任务完成度、账号安全等级。直接把GradientProgressBar放进页面即可GradientProgressBar( value: 0.72, height: 8, gradientColors: [Color(0xFFFF9500), Color(0xFFFF2D55)], showText: true, )固定进度的好处是不需要任何交互逻辑页面加载时动画自然从 0 播放到目标值用户体验已经比内置控件好了。前提是保证value在 0 到 1 之间其实我在 Widget 构造函数里已经加了assert做防呆写的时候少操一份心。4.2 上传/下载场景的动态进度动态进度场景核心是三个字别卡 UI。上传进度回调通常来自网络层频率可能很高每几十毫秒一次。如果每次回调都直接setState页面会疯狂重建极容易掉帧。我的做法是网络层回调只更新一个状态值通过ChangeNotifier或者ValueNotifierdouble保存进度条组件用ValueListenableBuilder监听它。这样进度条的动画和更新完全独立于父页面生命周期class UploadProgressCard extends StatelessWidget { final ValueNotifierdouble progressNotifier; const UploadProgressCard({super.key, required this.progressNotifier}); override Widget build(BuildContext context) { return ValueListenableBuilderdouble( valueListenable: progressNotifier, builder: (context, value, child) { return GradientProgressBar( value: value, height: 10, gradientColors: [Color(0xFF4A90E2), Color(0xFF7B68EE)], showText: true, ); }, ); } }外部更新逻辑只需要一行progressNotifier.value received / total;。这个方案实测在 30fps 左右的更新频率下依然流畅没有出现明显的掉帧。4.3 倒计时与自动更新的轮询场景还有一种特殊场景进度条本身不是“任务完成度”而是一个倒计时或者轮询的剩余时间。比如验证码重新发送的 60 秒倒计时、广告展示的 15 秒倒计时。这种进度条的值不是由外部事件驱动而是自身按时间递减。实现思路是给GradientProgressBar加一个可选参数autoDecrease或者更通用地支持传入一个AnimationController。我做验证码倒计时时会单独写一个 CountdownProgressBar内部维护一个Tweendouble(begin: 1, end: 0)时长设为 60 秒动画完成后自动停在 0。这种场景下文字内容就不是百分比了而是剩余秒数需要给进度条加一个labelBuilder回调让外部完全控制文字GradientProgressBar( value: remainingSeconds / 60, height: 6, showText: true, labelBuilder: (v) ${remainingSeconds}s, )把文字生成逻辑抽象成回调是这个组件设计中性价比极高的一步。产品改需求时不用动绘制代码只改回调就行。5. 常见问题与排查技巧实录5.1 进度条“卡死”不更新了这是最容易被误判的问题。现象进度条显示正常但值怎么变都不动。排查思路先别急着怀疑CustomPainter先检查shouldRepaint。一种常见低级错误是shouldRepaint里什么都没比直接return false框架认为画面永远不需要重绘。另一种是比了但没比完比如加了渐变参数后忘了把渐变加进比较逻辑里。我排查时直接在 Paint 方法第一行加个print看日志输出频率。如果paint根本没执行问题一定在shouldRepaint或者外部没有触发重建上。5.2 圆角变成“竖向胶囊”现象进度条高度明明设了 8圆角看起来却很高甚至整个进度条像一个竖着的药丸。原因基本只有一个——RRect.fromRectAndRadius用的 Radius 写死了比如Radius.circular(20)当高度只有 8 时圆角半径超过高度一半视觉就不对了。正确做法半径永远取size.height / 2。这行代码应该是动态计算的而不是写死一个常量。另外注意进度条进度很小的时候比如 progress 0.02progressWidth可能小于高度此时RRect的半径相当于还是拿 height 算但 Rect 宽度不够Flutter 会自动压缩圆角匹配形状一般不会崩溃但视觉上需要minVisibleWidth兜底。5.3 设置了渐变却一片空白这个问题几乎每个自绘新手都会遇到一次。具体现象轨道画出来了但进度条区域是空白。绝大多数原因都是LinearGradient.createShader(rect)传了个零宽度的 Rect。比如进度值是 0progressWidth就是 0Rect 的 width 是 0此时创建 Shader 就得不到任何内容。所以写代码时一定要在paint开头判断progress 0并且直接 return。另外还有一种情况渐变 Rect 传的是Rect.fromLTWH(0, 0, 0, 0)或负值会导致 shader 不可见。把 Rect 打印出来确认一下最直接。5.4 页面性能卡顿与频繁绘制进度条动画期间出现卡顿要先分清是“UI 线程卡”还是“整体掉帧”。经验上 90% 的新手问题集中在以下几点动画期间每帧都创建新的Gradient对象和Paint对象导致垃圾回收压力大。正确做法是不变化的Paint对象和Gradient在 Widget 层复用Painter 里只保存引用。文字Text每帧都在toStringAsFixed这个开销其实不算大但如果文字还在跑文本布局那就有问题了。方案是把文字样式缓存好文本内容变化时只改字符串不触发整体重新布局。AnimatedBuilder的 builder 里写了大量非绘制逻辑。记住 builder 是动画每帧都会执行的别把网络请求、集合遍历放进去只做和绘制相关的轻量操作。为了确认是 Engine 绘制瓶颈还是 Dart 层逻辑问题我常用的办法是开 Flutter 的性能面板DevTools 里的 Performance 页看帧时间落在 Runtime 还是 UI/GPU。如果卡在 Dart 层优先查上面这几条。6. 进阶玩法从横向进度条到圆形进度环6.1 圆形进度环的绘制思路很多应用比如磁盘用量、健康达标率、课程完成度喜欢用圆形进度环。它的绘制原理和横向进度条非常相似区别只在于 Canvas 的坐标系搬运到了角度上。用drawArc或者drawArc对应的Paint描边方式就能从 0 度扫到 360 度乘进度。核心要点有三个画圆弧需要传一个 Rect通常是正方形比如Rect.fromCircle(center: Offset(size.width/2, size.height/2), radius: radius)。使用drawArc时设置useCenter: false然后再用描边 Paint 画出来得到的就是一个环而不是扇形。角度起止约定drawArc的起始角度 0 是三点钟方向不是十二点钟。要做出从正上方开始的环起始角度要传-pi / 2。6.2 圆形进度环完整实现class CircularProgressPainter extends CustomPainter { final double progress; final Color trackColor; final Gradient gradient; final double strokeWidth; CircularProgressPainter({ required this.progress, required this.trackColor, required this.gradient, this.strokeWidth 6, }); override void paint(Canvas canvas, Size size) { final center Offset(size.width / 2, size.height / 2); final radius (size.width size.height ? size.width : size.height) / 2 - strokeWidth / 2; final rect Rect.fromCircle(center: center, radius: radius); // 轨道环 canvas.drawArc( rect, 0, pi * 2, false, Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..color trackColor, ); // 进度环 final sweepAngle pi * 2 * progress.clamp(0.0, 1.0); final progressPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..shader gradient.createShader(rect); if (sweepAngle 0) { canvas.drawArc( rect, -pi / 2, sweepAngle, false, progressPaint, ); } } override bool shouldRepaint(covariant CircularProgressPainter oldDelegate) { return oldDelegate.progress ! progress || oldDelegate.trackColor ! trackColor || oldDelegate.gradient ! gradient || oldDelegate.strokeWidth ! strokeWidth; } }这里需要注意一个细节strokeCap StrokeCap.round在某些低端安卓设备上圆弧的两个端点会渲染异常出现“箭头状”的过度描边。如果遇到这个问题解决方案是放弃strokeCap改成在圆弧末端单独画一个小圆来模拟圆头代价是代码复杂一点但兼容性更好。6.3 shouldRepaint 的正确使用姿势最后啰嗦一下shouldRepaint因为这是自绘组件高性能的命门。正确的做法是只对影响绘制结果的属性做比较。如果 Painter 里有一个textScale但它不影响绘制就别放进比较逻辑否则每帧触发重绘。我见过某同学的代码shouldRepaint里直接return true理由是“这样最简单”。动画期间每帧都会调用shouldRepaint返回值 true 就等于告诉框架无脑重绘。当页面同时存在多个自定义绘制组件时这种写法会拖垮整页性能。写shouldRepaint的正确心态是能返回false就不返回true能少比一个字段就少比一个。最后分享一个我个人的小经验自绘进度条这类组件最难的不是绘制本身而是把它设计成一个“边界清晰、参数合理、可复用”的公共组件。我在实操中反复踩坑后得到的结论是Painter 保持无状态动画交给外层渐变对象尽可能复用文字内容用回调暴露给业务方。这四条原则让我后续所有自绘组件进度环、打分条、滑杆等都走得比较顺。你第一次写可能觉得代码很多但拆开看无非就是“画轨道、画进度、加动画、叠文字”四件事。只要这四件事的边界划分清楚剩下的一切问题都只是调试时间问题。