
之前有朋友问我Flutter里那个圆弧电池计量器到底怎么画出来的是不是得接原生控件。我说不用Flutter的CustomPaint和Canvas这套底子对付这种表盘类组件非常顺手关键是把坐标系和弧度学明白。这篇文章就把我用Flutter实现圆弧电池计量器的完整过程写出来从需求拆解、绘制原理、状态管理到上线前踩过的那些坑一次性聊透。想自己做仪表盘、弧形进度条的开发者可以直接参考。1. 需求拆解与方案选型1.1 圆弧电池计量器到底要画哪些东西先说需求本身。普通的电池电量展示一般是矩形电池图标加数字顶多再加个颜色变化。但产品侧要的是“科技感强、能一眼看出剩余量”的圆弧式计量器本质上就是一个带缺口的弧形进度环中间可以放电量百分比。这种形态在智能手表、耳机盒电量、设备剩余电量、车辆仪表盘上特别常见比起矩形进度条它信息密度更高外观也更精致。拆开来看这个组件至少包含四层内容背景轨道弧固定一条灰色的圆弧表示电池容量最大化的“空壳”。电量填充弧根据当前电量百分比在背景弧上叠一条更亮、可能带渐变的彩色弧线。装饰刻度仪表盘风格一般会加刻度线让数据读起来更精确。中心文本通常是大号百分比数字有时加一行“剩余电量”之类的辅助文字。如果只是画一个静态环那其实做到第二层就够了。但要让一个计量器真正“能用”电量数据还得有来源数值变化时要刷新界面要响应不同屏幕尺寸要适配这些远远超出“会画圆”的范畴。1.2 CustomPaint和第三方图表库的取舍刚接到需求时我也习惯性地去翻了一圈图表库像fl_chart、syncfusion_flutter_charts这类确实有环状图、仪表盘组件但真要贴着自己的UI风格去改会发现限制不少。要么是API绕来绕去要么是包体积大、新版本API变动频繁为了一个电池环引入一个完整图表引擎性价比太低。所以我直接选择了CustomPaint。这是Flutter框架自带的绘制能力核心就是给一个CustomPainter对象自己在Canvas上画任意图形。它的好处有三点不引入额外依赖包体积可控。绘制逻辑完全掌握在自己手里弧度、颜色、刻度、动画都能做到像素级控制。和Flutter的视觉系统天然统一支持主题切换、屏幕自适应不会出现“原生控件跟Flutter UI风格割裂”的情况。副作用就是所有代码都要自己写尤其涉及弧度计算、文字对齐、坐标系转换时第一次搞会有点绕。但把这套东西吃透以后后面再做类似组件会非常快这也是我坚持自己画的核心原因。2. Canvas绘制核心弧度计算与drawArc2.1 Flutter坐标系统与角度约定在写代码之前必须先把Flutter里的坐标系和Canvas角度规矩讲清楚这一块最容易出事。Flutter的Canvas坐标系原点是屏幕左上角X轴向右Y轴向下。这和我们在纸上画坐标的习惯不一样很多新手一笔就画反了。另一个重点是角度。Flutter的Canvas方法比如drawArc使用的是弧度制而且有个让人绕不清的约定0弧度0度对应的是时钟的3点钟方向也就是正右方。角度增大是顺时针方向不是数学课上常用的逆时针。Y轴向下导致角度方向看起来“反了”实际上Canvas是这么设计的。所以如果想把圆弧起始位置放在“从正上方或左下角开始”需要按弧度去计算而不是简单写090度。我一般这样记三点钟是0六点钟是π/2九点钟是π十二点钟是3π/2再次回到三点钟是2π。示例里如果直接给startAngle传个角度值却忘了转弧度画出来的弧线位置基本是乱的这不是代码逻辑的问题是单位换算没做。2.2 背景轨道与电量弧的绘制代码理解坐标和角度以后第一步就是画轨道弧。先用一个Rect描述圆弧所在的包围盒再在这个Rect上调用drawArc。class BatteryGaugePainter extends CustomPainter { BatteryGaugePainter({required this.percent}); final double percent; override void paint(Canvas canvas, Size size) { final double strokeWidth size.width * 0.08; final Rect arcRect Rect.fromCircle( center: Offset(size.width / 2, size.height / 2), radius: size.width / 2 - strokeWidth, ); final Paint trackPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..color Colors.grey.shade300; final Paint fillPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round; final double startAngle 150 * pi / 180; final double sweepAngle 240 * pi / 180; // 背景轨道 canvas.drawArc(arcRect, startAngle, sweepAngle, false, trackPaint); // 电量填充弧 final double filledSweep sweepAngle * percent; canvas.drawArc(arcRect, startAngle, filledSweep, false, fillPaint); } override bool shouldRepaint(covariant BatteryGaugePainter oldDelegate) { return oldDelegate.percent ! percent; } }这段代码有两个容易被忽略的细节。一个是strokeWidth我取了size.width的8%这样圆弧粗细会随着屏幕变化不至于在手机和手表上比例失调。另一个是Rect.fromCircle给定了圆心和半径就不需要手算矩形四个边界了可读性更高。2.3 渐变、圆角端点的细节处理纯色弧线太寡淡我一般会给填充弧加渐变。Flutter里有几种方式最简单的是SweepGradient也就是“扫过渐变”颜色沿着圆弧角度方向渐变正好适合进度环。final Paint fillPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..shader SweepGradient( startAngle: 150 * pi / 180, endAngle: 390 * pi / 180, colors: [Colors.green, Colors.yellow, Colors.orange, Colors.red], ).createShader(arcRect);但这里有个坑SweepGradient的startAngle和endAngle必须和弧线使用的角度一致否则渐变位置会跟弧线对不上出现颜色起点在最前面、红色却跑到最前面之类的错位现象。我调试时经常发现渐变和弧线错位最后意识到渐变本身是按整个圆来算的需要把缺口部分也给出去。还有一个细节是StrokeCap.round它会让弧线两端变成圆弧头。这种效果看起来确实更精致但也有副作用因为两端被延长了电量充满100%时会略微超出轨道视觉上不太美观。要处理的话可以在percent超过0.98时把strokeCap临时改成StrokeCap.butt或者接受那一点点溢出。2.4 刻度、电量数字和视觉偏差修正刻度线的绘制比画弧线麻烦一些因为它不在同一条角度线上而是每个刻度要独立计算坐标。刻度的逻辑是把240度范围均分成若干段每个刻度对应的角度处画一条从内半径到外半径的短线段。for (int i 0; i totalTicks; i) { final double tickAngle startAngle sweepAngle * i / totalTicks; final double innerRadius centerRadius - strokeWidth / 2 4; final double outerRadius innerRadius 8; final Offset innerPoint Offset( center.dx innerRadius * cos(tickAngle), center.dy innerRadius * sin(tickAngle), ); final Offset outerPoint Offset( center.dx outerRadius * cos(tickAngle), center.dy outerRadius * sin(tickAngle), ); canvas.drawLine(innerPoint, outerPoint, tickPaint); }电量数字则用TextPainter绘制。这里有个典型问题直接把TextPainter的中心点对齐到画布中心文字会偏右下因为TextPainter默认是从左上角开始布局的。正确做法是先layout再根据文本宽度和高度做偏移final TextPainter tp TextPainter( text: TextSpan( text: ${(percent * 100).round()}%, style: TextStyle(fontSize: size.width * 0.18, fontWeight: FontWeight.bold), ), textDirection: TextDirection.ltr, )..layout(); tp.paint( canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2), );这个方法说穿了不值钱但不亲手算一遍百分之百会踩到文字偏移的坑。3. 电量状态管理与组件通信3.1 用ValueNotifier承载最简单的电量状态绘制这个环节搞定以后下一步要解决的是“电量数据从哪来、怎么变”。刚开始我做了一个很笨的方案把电量百分比放在StatefulWidget的state里每次setState触发重绘。这在小页面里没问题但一旦有其他组件也要监听电量变化就会被迫层层回调代码很快变得难维护。后来我换成了ValueNotifierFlutter内置的轻量状态对象。它有点像是一个“可以监听的盒子”把电量值放进去其他组件可以addListener也可以在ValueListenableBuilder里直接监听重建。final ValueNotifierdouble batteryLevel ValueNotifierdouble(0.85); ValueListenableBuilderdouble( valueListenable: batteryLevel, builder: (context, value, child) { return BatteryGauge(percent: value); }, )这样做的好处是电池电量的读取、更新逻辑和UI绘制完全解耦。系统电量回调来了改batteryLevel.value就行UI自动重建不用层层setState也不用手动管理painter的更新时机非常干净。3.2 组件通信的几种姿势按场景选就行Flutter项目里“组件通信”是个高频问题我也是在调试这个电量组件时把几种类型都过了一遍。如果只是单个页面内父传子直接构造函数传参配合ValueNotifier就够了。但电量这类数据往往不止一个页面在用比如设置页要显示设备电量、首页也要显示耳机盒电量这时候就需要跨组件通信。常见姿势有回调函数子组件把变化抛给父组件适合简单交互但层级多了会喧宾夺主。InheritedWidgetFlutter框架层的“依赖注入”方案适合读多写少的数据省去繁琐的构造函数传参。Provider社区主流的状态管理库本质是InheritedWidget封装适合中型项目。Stream/Riverpod再复杂的场景用Stream或Riverpod处理异步数据源更顺手。我的建议是别为了用而用。如果项目只是做一个仪表盘WidgetValueNotifier就够如果要把电量数据放到多个页面共享直接上Provider别硬写回调链。3.3 读取真实电量方法通道与原生能力模拟数据能跑通以后就该接真实电量了。Flutter本身没有获取电量的API必须走原生平台这正好用到Flutter和原生通信的机制——MethodChannel。通过这个通道Flutter可以调用Android/iOS的原生方法拿到结果再传回Dart侧。Android端在MainActivity里注册一个通道用BatteryManager拿到电量百分比。在Dart侧代码如下static const MethodChannel _channel MethodChannel(samples/battery); Futureint getBatteryLevel() async { final int result await _channel.invokeMethod(getBatteryLevel); return result; }调用的时候要注意几点MethodChannel调用是异步的Future的then回调默认会进入微任务队列不要指望它在当前帧内同步返回真机测试和模拟器行为差异很大模拟器上BatteryManager的值有时一直是固定值iOS上读取电量需要留意系统隐私策略UIDevice.batteryLevel在未开启batteryMonitoringEnabled时返回的是-1很容易被误判成“电量异常”。4. 完整代码实现与用户交互4.1 整体Widget结构与代码骨架把绘制、状态、数据读取串起来完整组件结构大致是这样的最外层是一个监听电量变化的StatefulWidget内部持有ValueNotifierbuild时返回一个CustomPaint用BatteryGaugePainter画所有图形。class BatteryGauge extends StatefulWidget { const BatteryGauge({super.key, required this.percent, this.showTicks true}); final double percent; final bool showTicks; override StateBatteryGauge createState() _BatteryGaugeState(); } class _BatteryGaugeState extends StateBatteryGauge { override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: CustomPaint( painter: BatteryGaugePainter(percent: widget.percent), child: Center( child: Text( ${(widget.percent * 100).round()}%, style: TextStyle( fontSize: 28, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface, ), ), ), ), ); } }这里我特意用了AspectRatio而不是SizedBox固定尺寸是为了让组件在不同位置都能自适应。CustomPaint在无尺寸约束时会很难办AspectRatio加外部布局约束能保证它至少保持正方形。4.2 参数计算与调优经验圆弧角度怎么选直接影响整机感观。我做第一版时把整个圆都画满了结果“电池环”看起来像个花里胡哨的眼罩完全不像计量器。后来查了下仪表盘的通用设计下面留个缺口视觉上更像“仪表”所以把起始角度设为150度扫过240度底部留120度空白这样的比例最舒服。相关参数再明确一下起始角度150度对应时钟的大约5点钟方向。扫过角度240度覆盖两个半区剩120度缺口。弧线粗细屏幕宽度的8%左右太细看不清渐变太粗又挡中间数字。刻度数量21个小格也就是把240度分成20段每段12度。这些参数写好后最好抽成一个配置类不要散落在painter里。比如TextStyle、粗细、颜色、起止角度都放到一个ThemeExtension里之后支持深色模式时就不会手忙脚乱。4.3 不同屏幕和主题适配Flutter自定义绘制的优势在适配这步体现得很明显。由于绘制用的是size参数动态计算组件在任何屏幕都不会变形关键是把“比例”而非“固定尺寸”写进代码。深色模式我踩过一次小坑。最开始背景轨道弧直接用Colors.grey.shade300在浅色模式下很好看一切换深色模式这条浅灰轨道就像一条发光的飞碟轨迹刺眼得不行。后来改成从Theme里取颜色final trackColor Theme.of(context).colorScheme.surfaceContainerHighest; final fillColors Theme.of(context).brightness Brightness.dark ? [Colors.lightGreen, Colors.orange, Colors.red] : [Colors.green, Colors.yellow, Colors.orange, Colors.red];主题适配不是简单换背景色还得同步调整文字颜色、刻度颜色和渐变配色。把颜色源统一收敛到一个地方才能避免“白天能用晚上瞎眼”的情况。4.4 用Android Studio创建Flutter项目补充一个入门环节很多刚接触Flutter的读者容易卡在第一步怎么在Android Studio里创建Flutter项目as创建flutter项目。我用的是比较老派的稳定流程插件装好Flutter和Dart以后File - New - New Flutter Project选择Flutter Application填上项目名和SDK路径等Gradle同步完就能跑。因为这个过程涉及不少Gradle配置偶尔会遇到“You are applying Flutters main Gradle plugin imperatively using the apply script”这类提示通常是Gradle插件应用方式变了本质上不影响小demo运行但看着闹心。我的处理方式是升级到官方推荐的新模板或者直接按报错信息把main gradle脚本里的apply方式调整一下别一上来就重装环境。做完这步再把上面说的BatteryGauge文件放进lib目录编译运行一个圆弧电池计量器就真正跑起来了。5. 实战中必须避开的坑与性能调优5.1 shouldRepaint与RepaintBoundary优化自定义绘制组件最常见的性能问题就是“无脑重绘”。如果父组件用setState刷新一个无关状态而BatteryGauge又嵌在里面那么CustomPaint也会跟着重绘一次纯属浪费。解决办法有两层在painter里实现shouldRepaint旧数据和新数据完全一致时返回false告诉Flutter不需要重画。在组件外面包一层RepaintBoundary把重绘隔离到当前图层避免影响整个页面的其他部分。我这个电量组件的shouldRepaint只检查percent是否变化刻度颜色这些不参与判断。这样即使在电量没动、父页面频繁刷新布局时它也不会被反复拉去画。5.2 文字与刻度偏移问题前面提过文字偏移实际操作中最折磨人的其实是刻度偏向因为使用cos和sin计算坐标时角度是浮点数累积误差会让最后一个刻度和第一个刻度对不上肉眼可能看不出但截图放大比较时很影响品质。我的做法不是循环里每次累加角度而是每次都拿i乘以单步角度避免连续浮点加法带来的误差累积final double angle startAngle sweepAngle * i / totalTicks;另外刻度线的坐标起点不能直接用Center因为arcRect和strokeWidth的偏移会让刻度跟电弧贴合度变差。通常我在内外半径上各留2到4像素的余量让刻度线和弧线之间保留一点视觉间隙效果反而更精致。5.3 Impeller渲染引擎下的绘制差异Flutter发展到现在iOS上已经默认启用Impeller渲染引擎Android在部分版本上也开始切换。Impeller和之前的Skia渲染器在shader、渐变处理上存在差异这是很多自定义绘制组件升级后“突然变了”的原因。我遇到的真实情况是在iOS真机上SweepGradient的渐变过渡颜色比Simulator上更“硬”原因是Impeller对着色器的编译和颜色插值策略不一样。遇到这类问题排查思路是先在iOS和Android真机上分别截图确定颜色差异再考虑用普通线性渐变或者多段drawArc手动画出渐变效果避免依赖shader。另一个和Impeller相关的现象是部分旧设备上圆弧边缘出现轻微锯齿。可以试试在Paint上开抗锯齿isAntiAlias默认就是true如果还不行就把尺寸调大后整体缩放或者更新Flutter版本毕竟Impeller本身也在快速迭代。5.4 unhandled exception与启用绑定异常很多人启动Flutter项目时都会见过类似日志error:flutter/runtime/dart_vm_initializer.cc(41) unhandled exception。我第一次看到这个日志以为是自己代码写崩了后来才发现它其实就是一段干干净净的“未捕获异常通知”真正的堆栈信息在它后面得继续往上翻日志。如果这个异常发生在main()里通常是三个原因初始化WidgetsFlutterBinding之前就去调了原生通道异步方法在页面还没构建完成时抛错空安全类型不匹配导致运行时null赋值。排查方法很直接在main()开头加上WidgetsFlutterBinding.ensureInitialized()把原生调用放到build之后用try/catch包裹异步方法再在main里装一个全局错误上报问题基本能定位。void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); }这个方法对自定义绘制组件同样有效因为某些字体加载、通道注册如果时机不对异常会在首帧渲染时爆发页面直接白屏。5.5 和下拉刷新、实时电量刷新如何协作如果电量展示页需要做下拉刷新一个比较实用的组合是FutureBuilder加RefreshIndicator。比如从平台通道异步获取真实电量时FutureBuilder负责在等待期显示环形菊花RefreshIndicator提供下拉交互电量数据返回后再把新值塞进BatteryGauge。这里要特别注意Future的回调时机Future的then回调默认进入微任务队列不会阻塞UI但如果在一个State里连续多次发起异步请求又没做取消或状态检查很容易在组件销毁后回调里setState从而触发上面的unhandled exception。稳妥做法是在await之后先用mounted判断一下再更新状态。6. 这个组件后续还能怎么玩把基础版圆弧电池计量器做完以后我个人觉得它可以扩展的方向还挺多。比较实用的几个增加平滑动画电量从20%跳到80%时直接刷新会显得很生硬。用TweenAnimationBuilder包一层让percent在几百毫秒内过渡视觉舒服很多。把缺口位置做成可配置有的场景想上方留缺口有的想侧边留缺口把startAngle和sweepAngle暴露成参数就行。低电量状态联动电量低于20%时弧线变成红色并闪烁提醒这类逻辑放在ValueListenableBuilder里处理不用改painter。多设备电量显示如果是耳机仓或外设项目把多个BatteryGauge排成一行每个监听不同设备的电量数据。其中电量变化动画是我最推荐加的。原因很朴素用户看到的是“续航在缓步减少”或“充电正在恢复”而不是突然跳变一个数字。实现上也不需要引入复杂动画一个TweenAnimationBuilder足够了。我在实际项目里还发现一个小技巧把弧线粗细设置成动态的当电量低时strokeWidth略微加粗一点配合颜色变化识别度比单纯改颜色高很多。这个技巧看起来不起眼但对老人或者弱视用户特别友好算是一个很有温度的小细节。一开始做这个圆弧电池计量器我以为难点在“画弧线上”真正做下来才发现难点分布在数据状态、平台通信、渲染差异和细节排版这些平时不会集中爆发的环节上。希望这篇整理能帮你少走一点弯路把精力留给真正重要的打磨上。