查看: 111|回复: 3

鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
最近在将Flutter应用适配到鸿蒙nova12u真机时,遇到了一个需求:首页需要展示饼图统计和一个实时走动的时钟表盘。考虑到第三方图表库在鸿蒙上的兼容性问题,我决定用Flutter的CustomPaint组件自行绘制。这篇文章记录了从基础形状到时钟表盘的自定义绘制实践,以及鸿蒙适配中需要注意的细节。

CustomPaint与CustomPainter的关系

CustomPaint是一个Widget,它负责将子组件夹在两层绘制之间;CustomPainter是抽象类,你需要继承它并实现paint方法,在Canvas上绘制内容。
  1. CustomPaint Widget
  2. ├── painter: 画前景
  3. ├── foregroundPainter: 画前景(上层)
  4. └── child: 子组件(夹在两层绘制中间)
复制代码

关键方法:
- paint(Canvas canvas, Size size) —— 所有绘制逻辑都在这里
- shouldRepaint(covariant CustomPainter oldDelegate) —— 告诉Flutter是否需要重绘,这个返回值容易踩坑

画布、画笔、路径三大核心

Canvas相当于画布,Paint是画笔,Path用于绘制复杂曲线。Flutter的Canvas API与鸿蒙ArkTS的CanvasRenderingContext2D设计思路类似,但使用方式有差异。鸿蒙的Shape组件自由度不如CustomPaint,适合画图表和自定义控件。

基础形状绘制热身

先画几个基础形状试试手:圆形、矩形、圆角矩形、空心圆和椭圆。
  1. class _ShapePainter extends CustomPainter {
  2.   @override
  3.   void paint(Canvas canvas, Size size) {
  4.     final w = size.width;
  5.     final h = size.height;
  6.     // 实心圆
  7.     canvas.drawCircle(Offset(50, h / 2), 35, Paint()..color = Colors.blue.shade300);
  8.     // 矩形
  9.     canvas.drawRect(Rect.fromLTWH(120, h / 2 - 30, 80, 60), Paint()..color = Colors.red.shade300..style = PaintingStyle.fill);
  10.     // 圆角矩形
  11.     final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(230, h / 2 - 30, 80, 60), const Radius.circular(12));
  12.     canvas.drawRRect(rrect, Paint()..color = Colors.orange.shade300);
  13.     // 空心圆
  14.     canvas.drawCircle(Offset(340, h / 2), 30, Paint()..color = Colors.purple.shade400..style = PaintingStyle.stroke..strokeWidth = 3);
  15.     // 椭圆
  16.     canvas.drawOval(Rect.fromLTWH(400, h / 2 - 20, 100, 50), Paint()..color = Colors.teal.shade300);
  17.   }
  18.   @override
  19.   bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  20. }
复制代码

注意PaintingStyle.stroke属性,默认是fill,画空心图形必须改成stroke并设置strokeWidth。刚开始画空心圆时忘记设线宽,结果啥都看不见——因为stroke模式默认线宽为0。

线条和路径:虚线手动绘制,贝塞尔曲线与弧线

对于直线、虚线、贝塞尔曲线和弧线,我会先抽离公共Paint,然后独立绘制每条线。Flutter Canvas没有内置画虚线的方法,需要手动循环绘制短线:
  1. class _LinePainter extends CustomPainter {
  2.   @override
  3.   void paint(Canvas canvas, Size size) {
  4.     final paint = Paint()..color = Colors.blue.shade400..strokeWidth = 2;
  5.     // 直线
  6.     canvas.drawLine(const Offset(20, 20), const Offset(150, 20), paint);
  7.     // 手动虚线
  8.     final dashPaint = Paint()..color = Colors.red.shade400..strokeWidth = 2;
  9.     for (double x = 20; x < 300; x += 15) {
  10.       canvas.drawLine(Offset(x, 40), Offset(x + 8, 40), dashPaint);
  11.     }
  12.     // 二次贝塞尔曲线
  13.     final path = Path()
  14.       ..moveTo(20, 80)
  15.       ..quadraticBezierTo(100, 30, 180, 90)
  16.       ..quadraticBezierTo(260, 130, 340, 80);
  17.     canvas.drawPath(path, Paint()..color = Colors.orange.shade400..strokeWidth = 3..style = PaintingStyle.stroke);
  18.     // 弧线
  19.     canvas.drawArc(Rect.fromLTWH(20, 100, 200, 60), -pi / 2, pi * 1.5, false, Paint()..color = Colors.purple.shade400..strokeWidth = 2..style = PaintingStyle.stroke);
  20.   }
  21.   @override
  22.   bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  23. }
复制代码

drawArc的最后一个参数useCenter:设为true时画出扇形(饼图切块),false只画弧线本身。绘制饼图时这个参数至关重要。虚线画法虽然简单,但如果需要精确的虚线长度和间距,建议封装为工具方法或使用第三方包。

渐变色:线性、径向与扫掠

渐变通过设置Paint的shader属性实现。Gradient有LinearGradient、RadialGradient、SweepGradient等子类。
  1. class _GradientPainter extends CustomPainter {
  2.   @override
  3.   void paint(Canvas canvas, Size size) {
  4.     // 线性渐变
  5.     canvas.drawRect(Rect.fromLTWH(0, 0, size.width / 2, size.height),
  6.       Paint()..shader = LinearGradient(colors: [Colors.blue, Colors.red, Colors.orange, Colors.red], begin: Alignment.centerLeft, end: Alignment.centerRight)
  7.         .createShader(Rect.fromLTWH(0, 0, size.width / 2, size.height)));
  8.     // 径向渐变
  9.     canvas.drawCircle(Offset(size.width * 0.75, size.height / 2), 40,
  10.       Paint()..shader = RadialGradient(colors: [Colors.yellow, Colors.orange, Colors.red], stops: const [0, 0.5, 1])
  11.         .createShader(Rect.fromCircle(center: Offset(size.width * 0.75, size.height / 2), radius: 40)));
  12.   }
  13.   @override
  14.   bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  15. }
复制代码

createShader需要传入Rect参数指定渐变作用区域。一开始我直接传整个Canvas size,导致径向渐变圆心偏移,改成与绘制区域一致的Rect后才正常。鸿蒙ArkTS中设置渐变的逻辑类似,但Flutter的createShader更灵活,可绑定到任意Paint上。

饼图绘制:数据可视化基本功

饼图由多个drawArc拼成,每个扇形useCenter设为true。
  1. class _PieChartPainter extends CustomPainter {
  2.   final data = [35.0, 25.0, 20.0, 15.0, 5.0];
  3.   final colors = [Colors.blue, Colors.red, Colors.orange, Colors.purple, Colors.red];
  4.   @override
  5.   void paint(Canvas canvas, Size size) {
  6.     final center = Offset(size.width / 2, size.height / 2);
  7.     final radius = 70.0;
  8.     final total = data.reduce((a, b) => a + b);
  9.     double startAngle = -pi / 2;
  10.     for (var i = 0; i < data.length; i++) {
  11.       final sweepAngle = (data[i] / total) * 2 * pi;
  12.       canvas.drawArc(
  13.         Rect.fromCircle(center: center, radius: radius),
  14.         startAngle, sweepAngle, true,
  15.         Paint()..color = colors[i].shade300,
  16.       );
  17.       // 标注圆点
  18.       final midAngle = startAngle + sweepAngle / 2;
  19.       final textPos = Offset(
  20.         center.dx + (radius + 20) * cos(midAngle),
  21.         center.dy + (radius + 20) * sin(midAngle),
  22.       );
  23.       canvas.drawCircle(textPos, 12, Paint()..color = colors[i].shade200);
  24.       startAngle += sweepAngle;
  25.     }
  26.   }
  27.   @override
  28.   bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  29. }
复制代码

起始角度设为-pi/2(12点钟方向),饼图看起来更自然。数据应通过构造函数传入以便复用Painter。

时钟表盘:AnimationController驱动的实时绘制

这是最花心思的部分。表盘需要每秒刷新,用AnimatedBuilder + AnimationController驱动重绘,Controller设每秒repeat,每次tick触发CustomPaint重绘。
  1. class _ClockWidget extends StatefulWidget {
  2.   const _ClockWidget();
  3.   @override
  4.   State<_ClockWidget> createState() => _ClockWidgetState();
  5. }
  6. class _ClockWidgetState extends State<_ClockWidget> with SingleTickerProviderStateMixin {
  7.   late final AnimationController _ctrl;
  8.   @override
  9.   void initState() {
  10.     super.initState();
  11.     _ctrl = AnimationController(vsync: this);
  12.     _ctrl.repeat(period: const Duration(seconds: 1));
  13.   }
  14.   @override
  15.   void dispose() {
  16.     _ctrl.dispose();
  17.     super.dispose();
  18.   }
  19.   @override
  20.   Widget build(BuildContext context) {
  21.     return AnimatedBuilder(
  22.       animation: _ctrl,
  23.       builder: (context, child) {
  24.         return SizedBox(
  25.           height: 200,
  26.           child: CustomPaint(
  27.             painter: _ClockPainter(),
  28.             size: const Size(double.infinity, 200),
  29.           ),
  30.         );
  31.       },
  32.     );
  33.   }
  34. }
复制代码

Painter每次paint取当前时间计算角度:
  1. class _ClockPainter extends CustomPainter {
  2.   @override
  3.   void paint(Canvas canvas, Size size) {
  4.     final center = Offset(size.width / 2, size.height / 2);
  5.     final radius = 80.0;
  6.     final now = DateTime.now();
  7.     // 表盘外圈
  8.     canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade100..style = PaintingStyle.fill);
  9.     canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade400..style = PaintingStyle.stroke..strokeWidth = 2);
  10.     // 12个刻度(0/3/6/9主刻度加粗加长)
  11.     for (var i = 0; i < 12; i++) {
  12.       final angle = -pi / 2 + (i * 2 * pi / 12);
  13.       final isMain = i % 3 == 0;
  14.       final inner = radius - (isMain ? 15 : 8);
  15.       final outer = radius - 5;
  16.       canvas.drawLine(
  17.         Offset(center.dx + inner * cos(angle), center.dy + inner * sin(angle)),
  18.         Offset(center.dx + outer * cos(angle), center.dy + outer * sin(angle)),
  19.         Paint()..color = isMain ? Colors.black87 : Colors.grey.shade500..strokeWidth = isMain ? 2.5 : 1.5,
  20.       );
  21.     }
  22.     // 时针
  23.     final hourAngle = -pi / 2 + (now.hour % 12) * 2 * pi / 12 + now.minute * 2 * pi / (12 * 60);
  24.     canvas.drawLine(center, Offset(center.dx + 35 * cos(hourAngle), center.dy + 35 * sin(hourAngle)),
  25.       Paint()..color = Colors.black87..strokeWidth = 4..strokeCap = StrokeCap.round);
  26.     // 分针
  27.     final minAngle = -pi / 2 + now.minute * 2 * pi / 60;
  28.     canvas.drawLine(center, Offset(center.dx + 50 * cos(minAngle), center.dy + 50 * sin(minAngle)),
  29.       Paint()..color = Colors.black87..strokeWidth = 2.5..strokeCap = StrokeCap.round);
  30.     // 秒针
  31.     final secAngle = -pi / 2 + now.second * 2 * pi / 60;
  32.     canvas.drawLine(center, Offset(center.dx + 60 * cos(secAngle), center.dy + 60 * sin(secAngle)),
  33.       Paint()..color = Colors.red.shade400..strokeWidth = 1.5..strokeCap = StrokeCap.round);
  34.     // 中心圆
  35.     canvas.drawCircle(center, 4, Paint()..color = Colors.black87);
  36.   }
  37.   @override
  38.   bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
  39. }
复制代码

指针长度经过调试:时针35、分针50、秒针60,strokeCap设为round使端头圆润。

踩坑汇总

1. shouldRepaint返回false导致画面不动:静态图形(饼图)返回false可避免性能浪费,动态时钟必须返回true。
2. CustomPaint的size约束:不给child时必须显式设置size或用SizedBox包裹,否则size默认为零导致图形不显示。
3. 手动画虚线粗糙:如需精确虚线长度和间距,建议封装工具方法或使用dash_painter包。
4. Canvas坐标原点:左上角,y轴向下为正,角度顺时针为正。时钟从-pi/2(12点)开始。
5. 重绘性能:时钟每秒一次没问题,复杂动画建议用RepaintBoundary隔离绘制图层,鸿蒙上同样有效。

与鸿蒙ArkTS Canvas的比较

鸿蒙ArkTS的CanvasRenderingContext2D与Flutter Canvas设计思路相似,但API命名不同。举例来说,鸿蒙用ctx.fillRect绘制矩形,Flutter用canvas.drawRect。在nova12u真机上,Flutter的CustomPaint每秒刷新流畅不掉帧,渲染引擎走Skia或Impeller,性能接近原生。

总结

通过这次实践,我掌握了Flutter CustomPaint从基础形状到时钟表盘的完整用法。关键细节包括shouldRepaint返回值、Canvas坐标方向、渐变的createShader区域等。下一步计划对饼图增加点击切换功能(通过hitTest判断扇形区域)。验证环境:Flutter · HarmonyOS 6.0 · nova12u 真机演示。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩

感谢分享!同是鸿蒙适配踩坑人,CustomPaint在真机上的表现确实跟模拟器有差异,特别是shouldRepaint没处理好导致掉帧的问题,你提的这个点很关键。虚线手动绘制那段我也遇到过,后来干脆封装了个DashPainter工具类,需要的话可以交流。另外drawArc的useCenter参数在饼图里确实容易搞混,我之前画扇形时忘了设true,结果只出来一段弧线,排查了半天😂。期待后续时钟表盘的实现细节~
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩

感谢分享!这篇文章太及时了,正好最近也在尝试把Flutter应用往鸿蒙上迁移,CustomPaint的适配踩坑经验非常实用。 你提到的“shouldRepaint”和“stroke默认线宽0”这两个点,我之前也中招过,特别是画空心图形时什么都看不到,排查了半天才发现是线宽没设。手动画虚线的方法也亲测好用,Flutter Canvas没有内置虚线确实有点不方便,封装成工具方法是个好主意。 另外,想问一下你在鸿蒙nova12u上绘制时钟表盘时,对于指针实时更新的动画循环,是用了Timer配合setState还是其他方案?我担心鸿蒙的刷新率和Flutter的帧同步会不会有额外开销。期待你后续关于时钟和饼图具体实现的更新~
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙上Flutter CustomPaint自定义绘制:时钟表盘与饼图踩

感谢楼主分享!正好最近也在折腾鸿蒙上的Flutter适配,CustomPaint这块一直没敢碰,怕遇到奇怪的兼容问题。你列的几个坑点很实在,特别是PaintingStyle.stroke默认线宽为0这个,之前在普通Flutter开发里也踩过,没想到鸿蒙上一样中招。手动画虚线那部分也很实用,之前用dart:ui的dashPath效果不理想,看来还是自己循环更可控。想问下楼主,时钟表盘里秒针的平滑运动,在鸿蒙真机上刷新率能跟住吗?有没有遇到Canvas性能瓶颈?
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-22 19:10 , Processed in 0.032302 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部