ARTICLE DETAIL

资讯详情

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

Flutter自绘圆弧电池计量器:CustomPaint与渐变动画实战

Flutter自绘圆弧电池计量器:CustomPaint与渐变动画实战 一个蓝牙设备调试项目里设计师丢来一张效果图一条带渐变的圆弧从左上绕到右上中间一个很圆的电量百分比数字说这是圆弧电池计量器。产品要求是像手环表盘那样有质感电量数值要平滑滚动不能生硬跳变。当时我脑子里第一个念头就是这活儿必须用Flutter的CustomPaint自己画。Flutter的自绘模式对这种非标准控件太友好了不需要引入任何第三方图表库也不存在RN那种必须靠原生组件兜底的问题一条弧线、一段颜色渐变、一个动画控制器就能完全复刻设计稿。这篇分享就围绕这个圆弧电池计量器组件展开从绘制原理、渐变配色、数值动画到组件通信和工程化踩坑完整记录我实现它的全过程。如果你正在做Flutter自定义控件或者被电量环、表盘、仪表盘这类弧形组件困扰这篇应该能给你一个可复用的参考实现。1. 为什么这个圆弧电池计量器值得自己画而不是找现成方案1.1 三种常见电量控件的形态对比做电量显示方案其实不少我先把它们摆在一起对比过线性进度条Flutter自带LinearProgressIndicator就能秒出效果但它的问题是辨识度太差放在设备卡片上十个人里有八个会以为是加载中的进度条。而且线性条要占据一整条横向空间视觉比重偏大会挤压同一卡片里其他信息的排版。圆环进度条CircularProgressIndicator改一下也能做但它天生是加载中的语义用在电量上容易让人误判成刷新等待。另外全圆环在视觉上有封闭感当电量掉到10%以下一个接近空掉的圆环会让界面显得很丧。圆弧电池计量器形态介于线性条和圆环之间保留了一个拱形包裹感既有电池容量变化的直觉又不喧宾夺主。尤其适合放在设备页顶部、手表配对卡片、智能家居设备状态卡这类需要场景感的位置。从设计还原度来看圆弧形态对视觉的轻透要求更高字体、端头圆角、渐变方向都要精确控制这恰恰是自绘方案的长处。1.2 这个组件在视觉上拆解成哪几层一个完整的圆弧电池计量器从上到下可以拆成四层底部暗色轨道弧始终存在作为空电量的底衬一般用白色低透明度描边。渐变电量进度弧按百分比画出对应角度的渐变弧线这是整个组件的视觉主体。大号百分比数字放在圆弧内偏下的位置随电量变化平滑滚动。状态强调色电量低于阈值时数字和弧线同步切换成警示色。这四层里2和3是核心1和4是细节但恰恰是这些细节决定了一个自绘控件看起来是工业级还是玩具级。1.3 为什么Flutter自绘在这里比其他框架更顺手我做这个组件之前也简单评估过其他方案。React Native要画这种弧线渐变通常得引react-native-svg本质上是把SVG能力嫁接到原生视图上动画还要走JS桥设备电量更新频率一高掉帧就明显了。Web端的Canvas方案要自己做高分屏适配和不同内核兼容也是折腾事。而Flutter的CustomPaint本身就在渲染引擎层工作一次drawArc就是把绘制指令直接交给Skia/Impeller动画走的是原生ticker性能和帧率都稳定得多。这个结论直接决定了我用CustomPaint一步步自己抠而不是满世界找电池组件库。2. CustomPaint的圆弧绘制从坐标系统到核心代码2.1 先说清楚Flutter Canvas的圆弧坐标系很多人在自定义绘制时卡住不是不会写drawArc而是没搞懂角度坐标系。Flutter的Canvas坐标系里原点在左上角x轴向右y轴向下角度是顺时针方向计算而且0度在右侧3点钟方向。这个结论和数学课本里逆时针为正不一样是因为屏幕坐标系的y轴是倒过来的所以正角度变成了顺时针。0°右侧3点钟方向90°下方6点钟方向180°左侧9点钟方向270°上方12点钟方向我要做一个从左上绕到右上、经过顶部的C形弧最自然的做法是从135°左下位置开始顺时针扫过270°停在45°右下位置。这个弧的两端位置对称中间拱起正好把百分比文字包裹在里面。2.2 drawArc的六个参数逐个说canvas.drawArc需要六个参数前三个最核心canvas.drawArc( rect, // 圆弧所在的外接矩形 startAngle, // 起始角度弧度制 sweepAngle, // 扫过的角度弧度制正数表示顺时针 false, // useCenter是否连到圆心形成扇形 paint, // 画笔 );rect圆弧会被限制在这个矩形范围内。一般做法是在组件尺寸基础上减去线宽的一半作为内缩边距不然弧线会被裁掉半个笔画。startAngle起始角度注意是弧度制135°要换算成135 * pi / 180。sweepAngle扫过多大角度。我这里是270°代表从135°顺时针绕到45°途经顶部。useCenter电量圆弧必须传false。传true会从弧线两端连到圆心画出一个扇形那不是我们要的效果。2.3 背景轨道和电量进度条的核心代码我直接贴出第一版Painter的实现这段代码是整个组件的绘制地基import dart:math as math; class ArcBatteryPainter extends CustomPainter { ArcBatteryPainter({ required this.level, required this.highColors, required this.lowColors, required this.lineWidth, }); final double level; // 0~100 final ListColor highColors; final ListColor lowColors; final double lineWidth; static const double _startAngleDeg 135; static const double _sweepAngleDeg 270; double get _startAngleRad _startAngleDeg * math.pi / 180; double get _sweepAngleRad _sweepAngleDeg * math.pi / 180; override void paint(Canvas canvas, Size size) { final rect Rect.fromLTWH( lineWidth / 2, lineWidth / 2, size.width - lineWidth, size.height - lineWidth, ); // 1. 底部暗色轨道 final bgPaint Paint() ..color const Color(0x14FFFFFF) ..style PaintingStyle.stroke ..strokeWidth lineWidth ..strokeCap StrokeCap.round; canvas.drawArc(rect, _startAngleRad, _sweepAngleRad, false, bgPaint); // 2. 电量进度弧 final progressAngle _sweepAngleRad * level / 100.0; if (progressAngle 0.01) return; final palette level 20 ? lowColors : highColors; final progressPaint Paint() ..shader SweepGradient( startAngle: _startAngleRad, endAngle: _startAngleRad _sweepAngleRad, colors: [palette.first, palette.last], ).createShader(rect) ..style PaintingStyle.stroke ..strokeWidth lineWidth ..strokeCap StrokeCap.round; canvas.drawArc(rect, _startAngleRad, progressAngle, false, progressPaint); } override bool shouldRepaint(covariant ArcBatteryPainter oldDelegate) { return oldDelegate.level ! level || oldDelegate.lineWidth ! lineWidth || oldDelegate.highColors ! highColors || oldDelegate.lowColors ! lowColors; } }注意level为0时progressAngle小于0.01直接return避免在弧线起点画出一个突兀的原点。这个判断是我实测中加上的不加的话电量0%时左下角会有一个小点非常诡异。这一段代码里有两处细节值得单独讲strokeCap一定要用StrokeCap.round这样弧线端头是圆润的半圆形视觉效果更像电池的元气直角端头会显得廉价背景轨道我用Color(0x14FFFFFF)而不是Color.withOpacity(0.08)因为where直接包Opacity会导致内部额外绘制一次离屏缓冲消耗性能。颜色上的16进制透明度更干净。2.4 电量分级的配色策略为什么电量低于某个阈值要换色这是从汽车仪表盘继承来的直觉油量低亮黄灯再低亮红灯用户不必读数字就能感知状态。20%这个阈值也是比较通用的经验值你可以通过构造参数把它暴露出去让不同业务场景自己调。const highColors [Color(0xFF00E8A0), Color(0xFF00C853)]; // 青绿渐变 const lowColors [Color(0xFFFF8F6B), Color(0xFFFF3B30)]; // 橙红渐变高电量用的青绿色渐变比纯绿更有科技感适合智能硬件场景低电量切换橙红警示力度够了又不至于像报警器一样让人焦虑。3. 让数字动起来动画控制器、渐变着色器和文字排版3.1 用AnimationController实现电量平滑过渡如果电量只是把Painter的level直接替换成新值画面会瞬间跳变体验很差。蓝牙设备上报电量通常是每秒一次数值从60跳到61人眼如果看到咔一下的跳变会明显觉得卡。我的做法是用一个AnimationController把显示数值做补间动画。这里的关键不是动画本身而是连续多次更新时动画的衔接处理。我踩过一次坑第一次动画没走完第二次更新来了如果从初始值重新开始补间弧线会回退到原来的起点再往前跑视觉上像抽搐。正确做法是永远从当前正在显示的动画值作为新动画的beginclass _ArcBatteryMeterState extends StateArcBatteryMeter with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animationdouble _animation; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _animation AlwaysStoppedAnimationdouble(widget.level.toDouble()); _controller.addListener(() setState(() {})); } override void didUpdateWidget(ArcBatteryMeter oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.level ! widget.level) { _animateTo(widget.level); } } void _animateTo(double target) { _animation Tweendouble( begin: _animation.value, end: target, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, )); _controller.forward(from: 0); } }easeOutCubic曲线的好处是动画前快后慢看起来像物理世界里的惯性减速特别适合数字滚动的场景。600毫秒的时长我试过在秒级轮询下不会显得拖沓。3.2 SweepGradient的坑startAngle和endAngle必须对齐弧线画弧线渐变最直接的想法是用SweepGradient。但我刚开始只是写了SweepGradient(colors: [...])结果渐变起点从3点钟方向开始而我的弧线起点在135°方向整个颜色是拧着铺上去的弧线两端颜色完全对不上设计稿。原因在于SweepGradient默认从0°开始、顺时针铺满360°。要让渐变方向跟弧线走向一致必须显式设置startAngle和endAngle而且这两个值要跟drawArc的起始角、扫过角完全对应SweepGradient( startAngle: _startAngleRad, // 135° endAngle: _startAngleRad _sweepAngleRad, // 135° 270° 405° colors: [palette.first, palette.last], )还有一个隐蔽的问题SweepGradient会以center为中心按360°环状渐变如果你只画了270°弧渐变区间之外的30°颜色其实也定义了但因为没画出来所以不影响。重点是把startAngle和endAngle锁定到弧线范围内颜色才长在弧线上。如果你用的Flutter版本比较老SweepGradient的endAngle参数可能不生效一个过渡方案是加transform: GradientRotation(_startAngleRad)本质上等价但我建议直接升级到3.x省心。3.3 百分比文字的排版细节文字放哪我建议放在CustomPaint的child里而不是用TextPainter画在canvas上。因为child是常规widget文本样式、字体、主题都方便接管而且不必操心TextPainter的换行和缩放逻辑。override Widget build(BuildContext context) { return SizedBox( width: widget.width, height: widget.height, child: CustomPaint( painter: ArcBatteryPainter( level: _animation.value, highColors: widget.highColors, lowColors: widget.lowColors, lineWidth: widget.lineWidth, ), child: Align( alignment: const Alignment(0, 0.35), child: Text.rich( TextSpan( text: ${_animation.value.round()}, style: const TextStyle( fontSize: 36, fontWeight: FontWeight.w700, color: Colors.white, height: 1.0, ), children: const [ TextSpan( text: %, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w400, color: Colors.white70, ), ), ], ), textAlign: TextAlign.center, ), ), ), ); }alignment: const Alignment(0, 0.35)是我反复调的结论。因为圆弧的视觉重心偏上文字如果放在正中心看上去会吊在圆弧中间往下移一点才舒服。百分号用小字号、白色低透明度跟主数字形成层级对比这是很多手表表盘的设计手法。还有一个常见问题数字从9跳到10、从99跳到100时位数变化会导致文字宽度变化看起来很抖。解决思路是给文字设置一个固定宽度边界或者用FittedBox让它在尺寸内自适应。我采用固定宽度容器数字右对齐这样变化时只有内容在换位置不会跳动。4. 组件通信外部数据是如何驱动电量变化的4.1 对外API怎么设计才顺手圆弧电池计量器的本质是个纯展示组件对外接口不用太多。我最终的设计是这样的const ArcBatteryMeter({ super.key, required this.level, this.width 180, this.height 140, this.lineWidth 14, this.lowBatteryThreshold 20, this.highColors const [Color(0xFF00E8A0), Color(0xFF00C853)], this.lowColors const [Color(0xFFFF8F6B), Color(0xFFFF3B30)], }) : assert(level 0 level 100, level must be in range [0, 100]);level用required锁定其余样式参数都给出默认值。这样调用方只需要关心数据不用理解Painter内部怎么画。父组件更新电量有两种姿势。第一种是父组件自己setStatesetState(() _batteryLevel 62);组件内部通过didUpdateWidget感知到widget.level变了自动触发平滑动画。这个方案最简单适合页面少、数据驱动不复杂的场景。第二种是配合ValueNotifier把数据源和控件解耦final ValueNotifierdouble batteryLevel ValueNotifierdouble(100); ValueListenableBuilderdouble( valueListenable: batteryLevel, builder: (context, value, _) { return ArcBatteryMeter(level: value); }, );外部代码只需要改batteryLevel.value不管它是来自蓝牙轮询还是状态管理框架UI都会自动刷新而且组件内部的动画逻辑完全不用动。这种解耦方式的好处是同一个数据结构可以被多个页面复用测试起来也方便。4.2 didUpdateWidget为什么是更新动画的关键入口很多人第一次写StatefulWidget时会有个疑问level传入时我已经有initState了为什么还要didUpdateWidget关键在于widget树重建时State对象是复用的不是重新创建的。initState只在State第一次挂载时执行一次之后父组件修改属性State不会重新执行initState只会触发didUpdateWidget。所以检测新参数并响应的逻辑必须放在didUpdateWidget里。再说一遍那段判断条件比较的是oldWidget.level ! widget.level。这里的oldWidget是上一次构建时的旧配置widget是当前的新配置。千万别手滑写成widget.level ! widget.level那是恒等判断永远不成立动画永远不触发。4.3 真实场景对接蓝牙设备电量轮询实际项目里电量数据通常来自轮询回调。拿蓝牙设备来说底层协议每1~2秒返回一个原始电量百分比可能还会带上一些脏数据设备没连上时返回-1设备异常时返回120更新固件时会跳变到0所以进入组件之前必须先做数据清洗final raw await device.getBatteryLevel(); final normalized (raw ?? 0).clamp(0.0, 100.0).toDouble(); batteryLevel.value normalized;这里有一个跟Flutter事件循环相关的细节如果getBatteryLevel返回的是Future那么在.then回调里更新UI时回调会被放入微任务队列它会在当前事件循环里尽快执行而不是等下一帧。这个机制本身没毛病但如果你在异步链路里先await了别的东西再回来更新组件组件可能已经被父级从树中移除了此时直接setState会报错。统一的做法是组件内部做好防护如果是StatefulWidget内部setState先判断if (!mounted) return;如果是ValueNotifier方案在dispose时记得把监听器移除干净。5. 实测踩坑记录Impeller渲染、Gradle插件与异步陷阱5.1 Impeller引擎下的圆弧渐变色带问题Flutter较新版本在Android上默认开启了Impeller渲染引擎大部分场景下性能和稳定性都有提升。但我在实机上遇到过一个诡异问题同样的SweepGradient渐变弧在开启了Impeller的设备上出现了明显的颜色断层就像低质量GIF里那种一圈一圈的色带尤其在高电量青绿渐变处特别明显。排查过程花了不少时间。先怀疑是Paint的dithering没开但Flutter的Paint没有直接暴露这个选项。后来确认是Impeller在部分GPU驱动下对渐变着色器的处理精度不足。临时方案有两个第一个是在AndroidManifest.xml里关闭Impellerapplication android:labelyour_app android:name${applicationName} meta-data android:nameio.flutter.embedding.android.EnableImpeller android:valuefalse / /application切回Skia渲染后色带消失但这相当于放弃了新引擎的性能红利属于保底方案。第二个是曲线救国把一条渐变弧拆成36段短弧每段之间的颜色用颜色插值计算。这在代码上稍微复杂一点但能基本消除色带而且不需要关Impeller。取舍点在于Segment数量越多圆弧接缝越难控制需要把strokeCap设为butt平头才能无缝衔接。如果你的业务对Impeller的渲染优化很依赖这个方案更稳妥。5.2 打包成AAR集成时遇到的Gradle插件告警如果你的这个组件项目最终要作为Flutter Module打包成AAR给现有的Android原生工程使用大概率会遇到一条构建告警提示你Applying Flutters main Gradle plugin imperatively using the apply。这是因为Flutter 3.13之后的版本推荐使用声明式插件管理pluginManagement而老工程里常见的apply plugin:命令式写法会在构建时告警甚至CI流程会直接标红。处理方式是迁移到声明式配置// settings.gradle pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() } }然后在模块级build.gradle里改成plugins { id com.android.application id kotlin-android }这个警告在纯Flutter项目里看不见只有在混编集成场景才会冒出来。我一开始以为是组件代码的问题后来才发现是Gradle工程配置的兼容性问题。如果你遇到同样的报错先检查工程结构而不要去动Painter。5.3 异步回调的时序微任务与事件轮的微妙差别前面提过Future.then的回调是微任务它会在当前事件循环结束前立即执行。而Timer.run、Stream事件这类的回调属于事件队列会排在下一轮。这个区别在电量快速刷新时会产生一个可感知的现象如果业务层在Future.then里直接修改了ValueNotifier.value这个修改会很快传导到UI层组件动画还没播完又来一个新值中间可能会有一段追数值的过程。其实这不算Bug因为组件内部动画本来就是从当前值追到新值。但如果你在调试时发现电量的数字变化频率和蓝牙回调频率对不上可以往这个方向排查确认回调到底是微任务还是事件是否在某个await之后被延后了。我自己的习惯是凡是涉及蓝牙、串口这类高频外部数据的场景一律用流Stream而不是裸的Future回调。流天然利于节流、去重、合并也方便在页面销毁时统一取消订阅避免回调穿透到已销毁的组价。5.4 性能相关的三个小建议这个组件本身很轻绘制量也小但在实际工程里还是会遇到一些性能相关的选择问题。总结三条经验第一shouldRepaint一定要写到位。它决定了level没变时Painter要不要重绘。如果直接返回true每次父组件setState都会触发整个弧线重新绘制在小组件里问题不大但放在列表页里就成了性能负担。第二不要用Opacity组件包裹CustomPaint去做半透明效果。Opacity会对整个图层做离屏渲染开销翻倍不说在滚动的列表里还可能触发额外的合成层。半透明效果直接体现在Paint的color里一次到位。第三动画控制器在组件销毁时要及时释放。我用的是SingleTickerProviderStateMixin它会跟随State自动管理但如果你的组件里用了TickerProviderStateMixin管理多个控制器务必在dispose中手动_controller.dispose()否则会有Ticker泄漏的报错这个报错在Debug模式下非常醒目。最后说一点个人体会这个圆弧电池计量器看着简单但把它打磨到一个能直接上生产的水平中间其实藏了不少细节。从坐标换算、渐变对齐到动画衔接、数据清洗每一步都对应着一个为什么。如果你也想做类似的自定义控件我建议不要急着找插件先花半天时间把CustomPaint的绘制模型吃透这笔时间绝对划算。真正跑通之后再回头看你会发现自定义绘制不是负担反而是Flutter最爽的部分之一。
返回列表