鸿蒙专家 发表于 2026-7-22 17:00:00

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

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

CustomPaint与CustomPainter的关系

CustomPaint是一个Widget,它负责将子组件夹在两层绘制之间;CustomPainter是抽象类,你需要继承它并实现paint方法,在Canvas上绘制内容。


CustomPaint Widget
├── painter: 画前景
├── foregroundPainter: 画前景(上层)
└── child: 子组件(夹在两层绘制中间)


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

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

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

基础形状绘制热身

先画几个基础形状试试手:圆形、矩形、圆角矩形、空心圆和椭圆。


class _ShapePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
    final w = size.width;
    final h = size.height;
    // 实心圆
    canvas.drawCircle(Offset(50, h / 2), 35, Paint()..color = Colors.blue.shade300);
    // 矩形
    canvas.drawRect(Rect.fromLTWH(120, h / 2 - 30, 80, 60), Paint()..color = Colors.red.shade300..style = PaintingStyle.fill);
    // 圆角矩形
    final rrect = RRect.fromRectAndRadius(Rect.fromLTWH(230, h / 2 - 30, 80, 60), const Radius.circular(12));
    canvas.drawRRect(rrect, Paint()..color = Colors.orange.shade300);
    // 空心圆
    canvas.drawCircle(Offset(340, h / 2), 30, Paint()..color = Colors.purple.shade400..style = PaintingStyle.stroke..strokeWidth = 3);
    // 椭圆
    canvas.drawOval(Rect.fromLTWH(400, h / 2 - 20, 100, 50), Paint()..color = Colors.teal.shade300);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}


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

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

对于直线、虚线、贝塞尔曲线和弧线,我会先抽离公共Paint,然后独立绘制每条线。Flutter Canvas没有内置画虚线的方法,需要手动循环绘制短线:


class _LinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.blue.shade400..strokeWidth = 2;
    // 直线
    canvas.drawLine(const Offset(20, 20), const Offset(150, 20), paint);
    // 手动虚线
    final dashPaint = Paint()..color = Colors.red.shade400..strokeWidth = 2;
    for (double x = 20; x < 300; x += 15) {
      canvas.drawLine(Offset(x, 40), Offset(x + 8, 40), dashPaint);
    }
    // 二次贝塞尔曲线
    final path = Path()
      ..moveTo(20, 80)
      ..quadraticBezierTo(100, 30, 180, 90)
      ..quadraticBezierTo(260, 130, 340, 80);
    canvas.drawPath(path, Paint()..color = Colors.orange.shade400..strokeWidth = 3..style = PaintingStyle.stroke);
    // 弧线
    canvas.drawArc(Rect.fromLTWH(20, 100, 200, 60), -pi / 2, pi * 1.5, false, Paint()..color = Colors.purple.shade400..strokeWidth = 2..style = PaintingStyle.stroke);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}


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

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

渐变通过设置Paint的shader属性实现。Gradient有LinearGradient、RadialGradient、SweepGradient等子类。


class _GradientPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
    // 线性渐变
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width / 2, size.height),
      Paint()..shader = LinearGradient(colors: , begin: Alignment.centerLeft, end: Alignment.centerRight)
      .createShader(Rect.fromLTWH(0, 0, size.width / 2, size.height)));
    // 径向渐变
    canvas.drawCircle(Offset(size.width * 0.75, size.height / 2), 40,
      Paint()..shader = RadialGradient(colors: , stops: const )
      .createShader(Rect.fromCircle(center: Offset(size.width * 0.75, size.height / 2), radius: 40)));
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}


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

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

饼图由多个drawArc拼成,每个扇形useCenter设为true。


class _PieChartPainter extends CustomPainter {
final data = ;
final colors = ;

@override
void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = 70.0;
    final total = data.reduce((a, b) => a + b);
    double startAngle = -pi / 2;
    for (var i = 0; i < data.length; i++) {
      final sweepAngle = (data / total) * 2 * pi;
      canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle, sweepAngle, true,
      Paint()..color = colors.shade300,
      );
      // 标注圆点
      final midAngle = startAngle + sweepAngle / 2;
      final textPos = Offset(
      center.dx + (radius + 20) * cos(midAngle),
      center.dy + (radius + 20) * sin(midAngle),
      );
      canvas.drawCircle(textPos, 12, Paint()..color = colors.shade200);
      startAngle += sweepAngle;
    }
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}


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

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

这是最花心思的部分。表盘需要每秒刷新,用AnimatedBuilder + AnimationController驱动重绘,Controller设每秒repeat,每次tick触发CustomPaint重绘。


class _ClockWidget extends StatefulWidget {
const _ClockWidget();
@override
State<_ClockWidget> createState() => _ClockWidgetState();
}

class _ClockWidgetState extends State<_ClockWidget> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
    super.initState();
    _ctrl = AnimationController(vsync: this);
    _ctrl.repeat(period: const Duration(seconds: 1));
}
@override
void dispose() {
    _ctrl.dispose();
    super.dispose();
}
@override
Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _ctrl,
      builder: (context, child) {
      return SizedBox(
          height: 200,
          child: CustomPaint(
            painter: _ClockPainter(),
            size: const Size(double.infinity, 200),
          ),
      );
      },
    );
}
}


Painter每次paint取当前时间计算角度:


class _ClockPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = 80.0;
    final now = DateTime.now();
    // 表盘外圈
    canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade100..style = PaintingStyle.fill);
    canvas.drawCircle(center, radius, Paint()..color = Colors.grey.shade400..style = PaintingStyle.stroke..strokeWidth = 2);
    // 12个刻度(0/3/6/9主刻度加粗加长)
    for (var i = 0; i < 12; i++) {
      final angle = -pi / 2 + (i * 2 * pi / 12);
      final isMain = i % 3 == 0;
      final inner = radius - (isMain ? 15 : 8);
      final outer = radius - 5;
      canvas.drawLine(
      Offset(center.dx + inner * cos(angle), center.dy + inner * sin(angle)),
      Offset(center.dx + outer * cos(angle), center.dy + outer * sin(angle)),
      Paint()..color = isMain ? Colors.black87 : Colors.grey.shade500..strokeWidth = isMain ? 2.5 : 1.5,
      );
    }
    // 时针
    final hourAngle = -pi / 2 + (now.hour % 12) * 2 * pi / 12 + now.minute * 2 * pi / (12 * 60);
    canvas.drawLine(center, Offset(center.dx + 35 * cos(hourAngle), center.dy + 35 * sin(hourAngle)),
      Paint()..color = Colors.black87..strokeWidth = 4..strokeCap = StrokeCap.round);
    // 分针
    final minAngle = -pi / 2 + now.minute * 2 * pi / 60;
    canvas.drawLine(center, Offset(center.dx + 50 * cos(minAngle), center.dy + 50 * sin(minAngle)),
      Paint()..color = Colors.black87..strokeWidth = 2.5..strokeCap = StrokeCap.round);
    // 秒针
    final secAngle = -pi / 2 + now.second * 2 * pi / 60;
    canvas.drawLine(center, Offset(center.dx + 60 * cos(secAngle), center.dy + 60 * sin(secAngle)),
      Paint()..color = Colors.red.shade400..strokeWidth = 1.5..strokeCap = StrokeCap.round);
    // 中心圆
    canvas.drawCircle(center, 4, Paint()..color = Colors.black87);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}


指针长度经过调试:时针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 真机演示。

热心网友3 发表于 2026-7-22 17:05:00

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

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

热心网友3 发表于 2026-7-22 17:05:00

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

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

热心网友3 发表于 2026-7-22 17:05:00

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

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