ARTICLE DETAIL

资讯详情

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

Flutter自定义绘制实战:从零实现圆弧电池计量器组件

Flutter自定义绘制实战:从零实现圆弧电池计量器组件 做 Flutter 自定义组件绕不开“画 UI”。真正动手画一个圆弧电池计量器的时候你会发现它把自定义绘制、状态管理、动画控制、组件通信这几个高频知识点全串起来了。这个标题听起来像一个小而美的练习实际在生产里它能用在设备电量展示、耳机充电仓状态、车载仪表、甚至是后台 App 的能耗可视化上。我来用实际做过项目的口吻把这个组件的从设计到落地的完整思路、核心代码、踩坑点全部拆一遍。先说清楚它能解决什么问题原生控件里没有现成的“圆弧电池”要么去套第三方库要么自己拿 Canvas 画。自己画不难但如何让电量从后端/原生通道传进来后自动平滑刷新如何做到低电量闪烁提醒如何做一个别人能直接复制使用、换颜色换尺寸的通用组件这些才是真正的价值。这篇文章适合两类人一类是把 Flutter 自定义绘制刚入门的开发者照着代码能做出效果另一类是已经写过一些 CustomPaint但希望组件设计得更优雅、性能更好的同学。1. 先想清楚这个圆弧电池计量器到底是做什么的1.1 为什么是圆弧而不是常规的直角壳电池最常见的电池图标是圆角矩形里填一段色块足够直观但它的可设计性很弱。圆弧形态本质上是一个扇环形仪表盘视觉重心更集中放在卡片、仪表板、智能硬件交互页里都有更强的“质感”。很多车机界面上的电池就是这种弧形表盘设计占据空间小同时能配合刻度、数字、渐变营造出科技感。从技术角度讲圆弧绘制对 Flutter 来说也很自然Canvas.drawArc本身就是为弧线准备的配合StrokeCap.round就能做出圆润的端点。相比之下直角电池需要处理内矩形、圆角裁剪、边框描边逻辑反而更碎。1.2 组件定位与适配场景这个组件在我的项目里定位是“展示型计量器”不是交互输入控件。用户不会去点它调电量——电量是只读数据来自系统或者蓝牙设备上报。所以组件对外只需要暴露一个数据入口电量百分比。往里传值往外展示附加的动画和颜色变化都由组件内部自治。适合接入的场景包括设备列表页的剩余电量角标取代原来干巴巴的“86%”文字。蓝牙耳机、智能手表等外设详情页的主视觉充电环。后台任务中心里的资源占用仪表盘。新手引导页做状态演示组件。如果你的场景是用户能拖动调节电量比如课程软件的虚拟电池实验那必须在组件里加手势处理复杂度立刻上了一个台阶。做之前先明确这个边界后面设计才能高效取舍。2. 拆解核心细节状态管理、绘制与动画2.1 电量状态的监听与传递方式这个组件的核心难点不是画弧线而是电量值变化时组件如何更新。很多第一次写的人直接把进度值放在StatefulWidget的setState里数据一变就重建整个组件树。短时间频繁刷新会导致电量数字闪烁、页面卡顿。更合理的做法是把电量数据设计成ValueNotifierdouble或者继承ChangeNotifier的统一状态对象组件内部通过ValueListenableBuilder监听变化只刷新自己这块区域。这样页面其他部分完全不会因为电量的波动而重建。热词里有个典型问题“flutter future的then回调是放入微任务队列吗”。这个问题放在这里不是闲话它提醒我们一个关键点如果你在Future回调里获取电量并setState回调的执行时机依赖微任务调度如果异步链路复杂界面更新会有肉眼可见的延迟。所以电量这种高频变化的数据我更推荐通过ValueNotifier同步推送而不是频繁FuturesetState。2.2 Canvas 圆弧绘制的四个关键点第一背景弧。画一个完整的270度圆弧作为底层轨道颜色用灰色或者其他低饱和色宽度略大于前景进度弧这样视觉上有“槽位感”。final backgroundCirclePaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color backgroundColor; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, backgroundCirclePaint, );第二前景进度弧。电量百分比映射到弧度范围比如设计成270度表盘那么起点从右上角开始算。这里需要注意的是起点角度我用-225 * pi / 180作为起始角扫过270 * pi / 180 * progress弧度这样下方留一个缺口比整圆更有仪表感。final progressPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round; progressPaint.shader SweepGradient( startAngle: startAngle, endAngle: startAngle sweepAngle, colors: [gradientStart, gradientEnd], ).createShader(rect); canvas.drawArc(rect, startAngle, sweepAngle * progress, false, progressPaint);第三渐变与圆角。渐变色让表盘不呆板常用绿色到青色的渐变或电量颜色分级。圆角端点用StrokeCap.round你会发现弧线首尾像打了胶囊头视觉上立刻精致了一个档次。第四中心文字。用TextPainter居中画百分比文字字体可以粗一些颜色根据电量等级变化。TextPainter记得textDirection: TextDirection.ltr和layout()否则文字不显示这个细节我见不少人漏掉。2.3 低电量闪烁动画的启停机制低电量时闪烁是一个很好的体验设计逼迫感不强但注意力会被吸引。实现方式不复杂一个AnimationController循环反向播放把透明度映射到进度弧颜色上。这里容易犯一个错误直接在build里判断“当前小于20%就controller.repeat()”。因为build会因各种原因被调用你重复调用repeat()会触发断言异常或者动画越来越卡。正确做法是重写didUpdateWidget判断电量是否跨越了阈值override void didUpdateWidget(CircularBatteryGauge oldWidget) { super.didUpdateWidget(oldWidget); final wasLow oldWidget.progress widget.lowPowerThreshold; final isLow widget.progress widget.lowPowerThreshold; if (!wasLow isLow) { _controller.repeat(reverse: true); } else if (wasLow !isLow) { _controller.stop(); _controller.value 0; } }只有从“非低电量”跨到“低电量”时才启动动画回到正常区间就停掉同时重置动画值否则下一次启动会从上次的位置继续产生跳变。3. 完整实操从零实现一个可复用组件3.1 项目准备与目录结构新建项目这步我就不展开命令行了用 Android Studio 的 New Flutter Project 或者命令行flutter create battery_gauge都行。重点说目录划分我建议把自定义组件单独放一个文件方便后续抽出成公共库。lib/ main.dart widgets/ circular_battery_gauge.dart battery_value_notifier.dartcircular_battery_gauge.dart放组件本身battery_value_notifier.dart放电量状态管理。如果只是演示 Demo也可以把状态管理直接放在页面里但为了复用到设备列表还是独立出来更干净。3.2 核心绘制代码逐段拆解来看看组件的骨架。我把所有对外可配置项都作为构造参数默认值保证开箱即用业务方需要自定义时又能灵活控制。class CircularBatteryGauge extends StatefulWidget { final double progress; // 0.0 ~ 1.0 final double size; // 组件宽高 final double strokeWidth; final Color backgroundColor; final double lowPowerThreshold; final bool showPercentText; final ValueChangeddouble? onStatusChanged; // 电量档位变化回调 const CircularBatteryGauge({ Key? key, this.progress 0.0, this.size 120, this.strokeWidth 12, this.backgroundColor const Color(0xFFEEEEEE), this.lowPowerThreshold 0.2, this.showPercentText true, this.onStatusChanged, }) : super(key: key); override StateCircularBatteryGauge createState() _CircularBatteryGaugeState(); }接着是State部分和CustomPaint包裹层。注意我给BatteryPainter传了repaint参数这样动画值变化时只触发局部重绘不必每次走build流程。class _CircularBatteryGaugeState extends StateCircularBatteryGauge with SingleTickerProviderStateMixin { late final AnimationController _controller; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); } override Widget build(BuildContext context) { return CustomPaint( size: Size.square(widget.size), painter: BatteryGaugePainter( progress: widget.progress, strokeWidth: widget.strokeWidth, backgroundColor: widget.backgroundColor, lowPowerThreshold: widget.lowPowerThreshold, showPercentText: widget.showPercentText, animation: _controller, ), ); } }BatteryGaugePainter里我做了几件事画背景弧、按颜色档位画前景弧、低电量时绘制半透明的闪烁层、画中心文字。文字使用TextPainter前一定要记得layout()否则啥也画不出来。低电量闪烁层我通过animation.value控制Color.fromRGBO(255, 82, 82, 0.6 0.4 * animation.value)的透明度视觉上是在原进度弧上叠加同形状的红色弧形成呼吸感。3.3 参数配置与对外通信设计组件内部不建议持有“真正的电量数据”它只负责展示。真正的数据源可以是系统电池插件、蓝牙回调或者接口上报由业务层维护。组件和业务层之间的通信我建议用ValueNotifier理由前面提过它天然支持多个监听者设备列表里十个电池组件可以同时监听同一个电量对象但各自画各自的界面。如果你希望组件完全“傻瓜式”也可以直接在构造参数里传ValueListenabledouble组件内部用ValueListenableBuilder包一层class CircularBatteryGauge extends StatelessWidget { final ValueListenabledouble progressListenable; ... override Widget build(BuildContext context) { return ValueListenableBuilderdouble( valueListenable: progressListenable, builder: (context, value, _) { return CircularBatteryGaugeView(progress: value, ...); }, ); } }但要注意这种做法修改了组件性质原组件是StatefulWidget管理动画包裹后还得把状态提升。所以我的最终方案是组件本身仍是StatefulWidget页面上用ValueListenableBuilder做桥接传出最新的 progress。这个理解很重要热词里大量出现的“flutter组件通信”问题本质就是搞清楚父子组件到底谁持有状态、用什么方式通知对方。这里我给一个最省心模板父页面维护ValueNotifierdouble()刷新时直接value newValue子组件内部通过didUpdateWidget感知progress变化该启停动画启停动画该更新颜色更新颜色。各司其职逻辑清晰也方便测试。4. 集成到真实业务页面的正确姿势4.1 接入真实电量数据源真实项目里电量不可能是写死的 0.86它来自系统或硬件。拿手机电量举例可以用battery插件通过原生电池管理获取这里注意原生通道的调用是异步的调用的频率也要控制没必要每秒轮询电量变化本身很缓慢。设备侧的电量比如耳机、手环往往来自蓝牙回调那个频率更不规则可能一下子连续推好几条消息。我在集成时做过一层“防抖”记录上次更新时间如果两次上报间隔小于 300ms就只保留最后一次。这能显著减少不必要的重绘。与下拉刷新联动也是常见场景设备详情页允许用户下拉强制刷新电量数据。这时候RefreshIndicator触发数据请求拿到最新电量后写入ValueNotifier圆弧电池组件就能静默更新。这个过程的界面反馈比“转菊花 数字跳变”自然得多。4.2 组件刷新的性能细节性能这个点很多入门教程根本不讲但生产环境里非常关键。我实测过一个列表页面十个设备卡片各带一个圆弧电池组件如果每个电量刷新都setState整个列表掉帧非常明显。改成ValueListenableBuilderCustomPaint(repaint: animation)之后帧率稳定在 60。具体实现上BatteryGaugePainter的构造函数要接收动画对象并把它传给super(repaint: animation)class BatteryGaugePainter extends CustomPainter { final Animationdouble animation; BatteryGaugePainter({required this.animation, ...}) : super(repaint: animation); }这样低电量闪烁时只有该组件的画布重绘没有动画时完全零开销。另外要注意shouldRepaint的判断比较 progress 和 strokeWidth 等参数如果没变就返回 false。动画对象传入repaint后正常更新 progress 时也需要触发重绘所以shouldRepaint里要比较 progress 是否变化避免改了电量画面却不动的尴尬。我还尝试过给进度弧加MaskFilter.blur的发光效果视觉效果确实不错但真机上 CPU/GPU 开销上升明显。后来改成了画两层弧里层是原色外层用半透明粗弧模拟光晕效果接近但性能稳很多。5. 常见问题与排查技巧实录5.1 高频报错与处理方案CustomPaint 画出来一片空白。八成是TextPainter没有调用layout()或者CustomPaint的size为 0。组件被塞进无约束宽高的容器里就会这样比如直接放在Column里没套SizedBox。解决办法是CustomPaint外层给一个明确的SizedBox(width: size, height: size)。didUpdateWidget 里启动动画报错。如果你在didUpdateWidget里直接调用_controller.repeat()一定要确认当前Ticker没有被dispose。正确做法是判断_controller.isAnimating如果已经在播放就别重复调用。如果是页面跳转返回后组件残留记得在dispose里释放控制器override void dispose() { _controller.dispose(); super.dispose(); }电量从 0.8 变到 0.2 时弧线瞬间跳变没有过渡。这个问题是需求层面没想清楚你是要“实时刷新”还是要“平滑过渡”我实战里两种都写过。如果是真实设备电量实时刷新就好充电中的微小波动不会有感知如果是演示动画希望从 0.8 转到 0.2 有渐变那需要再套一个TweenAnimationBuilder或者自建AnimationController正向播放一次把目标值作为Tween.end。Gradle 构建报“you are applying flutters main gradle plugin imperatively using the apply script”。这是 Flutter 新版本对 Android 构建脚本的约束如果你要打 AAR 包给原生集成需要按提示迁移到pluginsDSL。解决方式是把项目中android/settings.gradle和app/build.gradle的 Flutter 插件声明方式更新Flutter 官方迁移说明里有明确改法这里不展开但提醒一句不要直接去搜索乱改照着版本对应的迁移文档操作最安全。5.2 渲染引擎与真机适配Flutter 3.10 之后默认启用 Impeller 渲染引擎。Impeller 对很多Canvas绘制 API 的底层实现和旧版 Skia 不一样。特别是MaskFilter.blur、大范围drawArc渐变这类操作在不同型号手机上表现差异很大。我实测在 iOS 上 Impeller 表现很好但某些国产 Android 机上弧线边缘会略糊。如果项目对绘制精度要求苛刻可以把组件的绘制全部限制在Canvas.drawArcPaint.shader的范围内避免使用花哨的高级效果这样无论 Skia 还是 Impeller 都能稳定出图。另一个坑是strokeCap圆头在弧线两端带来的“溢出”。当进度为 0 时圆弧不画没有端点但进度极小比如 0.001时圆头端点的半径会超出弧线轨道一点视觉上和背景槽位重叠显得很突兀。解决方式是把前景弧线的总长度减掉一个端点的宽度sweepAngle * progress - _roundCapPadding当然这个值需要根据弧长和半径计算。我用过一个简化方案直接限制最小显示角度低于 0.01 就按 0 处理不画弧线。5.3 一些经验沉淀这个组件别看体积小做的时候值得当成一个小项目来管理。组件对外参数尽量收敛不要设置太多开关能把代码量降一半。把颜色档位抽象成一个方法传进度进去返回对应颜色比在 painter 里堆 if 判断清爽得多Color _batteryColor(double progress) { if (progress lowPowerThreshold) return const Color(0xFFFF5252); if (progress 0.5) return const Color(0xFFFFA726); return const Color(0xFF66BB6A); }组件姿态确定后加数字文字、加网格刻度、加充电图标等都是后续扩展。网格刻度这类静态元素不要用动画驱动重绘否则每次闪一下浪费性能可以在 painter 里用常量缓存路径路径点。另外如果你后续要把这个组件作为安卓原生项目里的一个模块打 AAR需要注意 Flutter 引擎组件不能通过简单继承混入原生工程必须用官方的 FlutterEngine 托管方式。这个动作看着复杂其实核心就是处理好生命周期和消息通道。我在组件里预留了一个onStatusChanged回调在低电量阈值跨越时向外通知这样原生侧不需要轮询 UI直接监听状态回调就能联动弹窗提醒。说到最后我想说一个自己在实际迭代中的体会圆弧电池计量器这类自定义组件的价值不在于一个漂亮的表盘而在于你把它做成一个“懂业务状态的组件”——该闪烁时闪烁该变色时变色数据来了才刷新数据没变不打扰页面。把这些细节抠到位之后这个组件在某个产品模块里能活很久甚至会被全团队复用。你也不妨从把这一块弧线画圆开始照着上面的步骤做一遍遇到问题就回来翻一翻排障清单多在真机上跑跑比什么源码分析都实在。
返回列表