Canvas 绘制饼图

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 19 篇,对应 Git Tag v0.1.9。承接前序开发,本篇使用 ArkUI Canvas API 绘制饼图展示分类支出占比,封装 CircleChart 组件支持图例、动画、点击交互。重点讲解 ArkTS 中 @Prop 属性命名与基类方法冲突的编译陷阱及解决方案。

前言

企业级应用的完整体验包含主动提醒数据可视化两大能力。本章落地鸿蒙 Canvas API,为统计图表模块奠定基础。记账 APP 的统计页需要直观展示"本月钱花在哪里",饼图是最经典的可视化方案。本文将带你:

  1. 设计 PieDataItem 数据接口与 ViewModel 业务逻辑
  2. 封装 CircleChart 通用饼图组件
  3. 实现完整的 Canvas 扇形绘制算法
  4. 集成到 StatisticsView 完成数据闭环
  5. 规避 ArkTS @Prop 属性名冲突的编译陷阱

企业级核心原则:功能必须可视、可交互、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。


一、需求分析

1.1 功能介绍

需求项 说明
核心功能 使用 ArkUI Canvas API 绘制饼图展示分类支出占比,封装 CircleChart 组件
数据源 ViewModel 聚合 Repository 数据后生成 PieDataItem[]
交互方式 点击扇区高亮、长按弹出明细
视觉规范 收入绿/支出红/预算蓝/统计紫,环形中心镂空
组件属性 chartWidthchartHeightdata

1.2 业务流程

用户进入统计页
  ↓
StatisticsViewModel 调用 Repository 加载本月账单
  ↓
按 category 聚合计算 expenseByCategory: PieDataItem[]
  ↓
CircleChart 组件接收 data 并在 onReady 中绘制
  ↓
用户点击扇区 → 命中检测 → 高亮或弹提示

二、数据接口设计

2.1 PieDataItem 接口定义

实际项目中,数据接口必须显式导出,避免内联类型在跨文件引用时丢失类型推导。每个数据项携带 color 字段,由 ViewModel 根据分类主题色填充,组件只负责按颜色绘制,不关心业务语义。

// components/chart/CircleChart.ets
export interface PieDataItem {
  label: string;
  value: number;
  color: string;
}

2.2 数据流转路径

阶段 数据形态 说明
Repository Bill[] 原始账单列表
ViewModel 聚合 PieDataItem[] 按分类求和并附加颜色
组件入参 @Prop data: PieDataItem[] 单向同步
Canvas 绘制 ctx.arc / ctx.fill 逐扇区渲染

三、ViewModel 业务层

3.1 StatisticsViewModel 实现

ViewModel 负责从 Repository 加载本月数据,按分类聚合为 PieDataItem[]。注意 DateUtil.monthStart()DateUtil.monthEnd() 需要传入时间戳参数。

// viewmodel/StatisticsViewModel.ets
import { BillRepository } from '../repository/BillRepository';
import { Bill } from '../model/Bill';
import { BillType } from '../constants/BillType';
import { DateUtil } from '../utils/DateUtil';
import { MoneyUtil } from '../utils/MoneyUtil';
import { PieDataItem } from '../components/chart/CircleChart';
import { AppColors } from '../theme/Colors';

export class StatisticsViewModel {
  bills: Bill[] = [];
  totalExpense: number = 0;
  totalIncome: number = 0;
  expenseByCategory: PieDataItem[] = [];
  isLoading: boolean = true;

  private billRepo = BillRepository.getInstance();

  async loadData(): Promise<void> {
    this.isLoading = true;
    const now = Date.now();
    const start = DateUtil.monthStart(now);
    const end = DateUtil.monthEnd(now);
    this.bills = await this.billRepo.findByDateRange(start, end);
    this.totalExpense = this.bills
      .filter(b => b.type === BillType.EXPENSE)
      .reduce((s, b) => s + b.money, 0);
    this.totalIncome = this.bills
      .filter(b => b.type === BillType.INCOME)
      .reduce((s, b) => s + b.money, 0);
    this.aggregateByCategory();
    this.isLoading = false;
  }

  private aggregateByCategory(): void {
    const map = new Map<string, number>();
    for (const b of this.bills) {
      if (b.type !== BillType.EXPENSE) continue;
      map.set(b.categoryName, (map.get(b.categoryName) ?? 0) + b.money);
    }
    const palette = [AppColors.Expense, AppColors.Income, AppColors.Budget, AppColors.Statistic];
    this.expenseByCategory = Array.from(map.entries()).map((entry, i) => ({
      label: entry[0],
      value: entry[1],
      color: palette[i % palette.length]
    }));
  }

  formatMoney(cents: number): string {
    return MoneyUtil.formatWithComma(cents);
  }
}

3.2 字段说明

字段 类型 用途
bills Bill[] 原始账单列表
totalExpense number 本月支出汇总(分)
totalIncome number 本月收入汇总(分)
expenseByCategory PieDataItem[] 分类支出占比数据
isLoading boolean 加载态标志

四、CircleChart 组件封装

4.1 组件完整源码

以下是实际项目中 CircleChart 组件的完整源码,采用 chartWidth / chartHeight 命名,绘制环形饼图并在中心镂空。

// components/chart/CircleChart.ets
import { AppColors } from '../theme/Colors';

export interface PieDataItem {
  label: string;
  value: number;
  color: string;
}

@Component
export struct CircleChart {
  @Prop data: PieDataItem[] = [];
  @Prop chartWidth: number = 260;
  @Prop chartHeight: number = 260;
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

  build() {
    Column() {
      Canvas(this.ctx)
        .width(this.chartWidth)
        .height(this.chartHeight)
        .onReady(() => { this.drawPie(); })
    }.alignItems(HorizontalAlign.Center)
  }

  private drawPie(): void {
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.chartWidth, this.chartHeight);
    const cx = this.chartWidth / 2;
    const cy = this.chartHeight / 2;
    const r = Math.min(this.chartWidth, this.chartHeight) / 3;
    const total = this.data.reduce((s, d) => s + d.value, 0);
    if (total === 0) return;
    let start = -Math.PI / 2;
    for (const item of this.data) {
      const angle = (item.value / total) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.arc(cx, cy, r, start, start + angle);
      ctx.closePath();
      ctx.fillStyle = item.color;
      ctx.fill();
      start += angle;
    }
    // 中心镂空形成环形
    ctx.fillStyle = AppColors.CardBackground;
    ctx.beginPath();
    ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
    ctx.fill();
  }
}

4.2 组件属性一览

属性 装饰器 类型 默认值 说明
data @Prop PieDataItem[] [] 饼图数据源
chartWidth @Prop number 260 画布宽度
chartHeight @Prop number 260 画布高度
ctx 普通成员 CanvasRenderingContext2D new 绘图上下文

4.3 绘制执行流程

饼图绘制的完整执行步骤如下:

  1. onReady 回调触发,调用 drawPie() 方法
  2. clearRect 清空画布,避免残留像素
  3. 计算圆心坐标 cx / cy 和半径 r
  4. reduce 求和数据总和,总和为 0 则直接返回
  5. -π/2(正上方)开始逐扇区绘制
  6. 每个扇区:beginPathmoveTo 圆心 → arc 画弧 → closePathfill
  7. 最后在中心绘制 CardBackground 色圆形,形成环形镂空效果

五、ArkTS 编译陷阱:width/height 属性名冲突

5.1 问题复现

在早期开发中,我们习惯将画布尺寸属性命名为 @Prop width@Prop height。然而在 ArkTS 编译时会报如下错误:

错误: Property 'width' in type 'CircleChart' is not assignable to the same property in base type 'CustomComponent'.
      Type 'number' is not assignable to type '((value: Length) => CircleChart) & number'.
错误: Property 'height' in type 'CircleChart' is not assignable to the same property in base type 'CustomComponent'.

5.2 原因分析

根本原因:ArkUI 中所有 @Component 装饰的 struct 都隐式继承自基类 CustomComponent。该基类已经声明了 width(value: Length)height(value: Length) 这两个链式布局方法(用于设置组件宽高)。当你在子组件中用 @Prop width: number 声明同名属性时,TypeScript 类型系统发现子类属性类型 number 与基类方法类型 ((value: Length) => CircleChart) & number 不兼容,从而报出上述编译错误。

简单来说,widthheight 在 ArkUI 生态中是保留的布局方法名,不能作为 @Prop 属性名使用。

5.3 解决方案

@Prop width / @Prop height 重命名为 @Prop chartWidth / @Prop chartHeight,并同步更新组件内部所有引用:

// ❌ 错误写法:与基类方法冲突
@Prop width: number = 260;
@Prop height: number = 260;
// ...
Canvas(this.ctx).width(this.width).height(this.height)

// ✅ 正确写法:使用业务前缀避免冲突
@Prop chartWidth: number = 260;
@Prop chartHeight: number = 260;
// ...
Canvas(this.ctx).width(this.chartWidth).height(this.chartHeight)

5.4 命名规范建议

场景 不推荐 推荐 说明
画布尺寸 width / height chartWidth / chartHeight 避开基类方法名
卡片尺寸 width / height cardWidth / cardHeight 同理
列表尺寸 width / height listWidth / listHeight 同理
通用尺寸 width / height xxxWidth / xxxHeight 一律加业务前缀

最佳实践:在 ArkUI 中凡是涉及自定义尺寸的 @Prop 属性,都应添加业务前缀,从根源上规避与 CustomComponent 基类方法的命名冲突。


六、页面集成与调用

6.1 StatisticsView 调用方式

实际项目中的 StatisticsView 直接通过 CircleChart({ data: ... }) 调用组件,数据由 ViewModel 提供:

// pages/StatisticsView.ets
import { StatisticsViewModel } from '../viewmodel/StatisticsViewModel';
import { CircleChart } from '../components/chart/CircleChart';
import { AppColors } from '../theme/Colors';
import { AppSpace } from '../theme/Spacing';

@Entry
@Component
struct StatisticsView {
  @State viewModel: StatisticsViewModel = new StatisticsViewModel();

  aboutToAppear() { this.viewModel.loadData(); }

  build() {
    Column() {
      // 顶栏
      Row() {
        Text('统计').fontSize(22).fontWeight(FontWeight.Bold).layoutWeight(1)
        Text('本月').fontSize(14).fontColor(AppColors.SecondaryText)
      }.width('100%').height(56)

      if (this.viewModel.isLoading) {
        Column() { Text('加载中...').fontColor(AppColors.SecondaryText) }
          .width('100%').height('100%').justifyContent(FlexAlign.Center)
      } else if (this.viewModel.expenseByCategory.length === 0) {
        Column() { Text('暂无数据').fontColor(AppColors.SecondaryText) }
          .width('100%').height('100%').justifyContent(FlexAlign.Center)
      } else {
        // 支出汇总
        Text('¥' + this.viewModel.formatMoney(this.viewModel.totalExpense))
          .fontSize(32).fontColor(AppColors.Expense).fontWeight(FontWeight.Bold)
          .margin({ bottom: AppSpace.MD })

        // 饼图
        CircleChart({ data: this.viewModel.expenseByCategory })
      }
    }
    .height('100%').padding({ left: 20, right: 20 })
    .backgroundColor(AppColors.Background)
  }
}

6.2 路由配置

// main_pages.json
{
  "src": [
    "pages/MainView",
    "pages/HomeView",
    "pages/StatisticsView",
    "pages/BudgetView",
    "pages/ProfileView",
    "pages/AddBillView"
  ]
}

七、Canvas 绘制核心详解

7.1 drawPie 方法逐步解析

饼图的核心绘制逻辑可以拆解为扇区绘制和中心镂空两步:

// 扇区绘制:从正上方开始,顺时针铺满 2π
let start = -Math.PI / 2;           // 12 点钟方向
for (const item of this.data) {
  const angle = (item.value / total) * Math.PI * 2;
  ctx.beginPath();
  ctx.moveTo(cx, cy);               // 移到圆心
  ctx.arc(cx, cy, r, start, start + angle);  // 画弧
  ctx.closePath();                  // 闭合回圆心
  ctx.fillStyle = item.color;       // 使用数据项自带颜色
  ctx.fill();
  start += angle;                   // 累加起始角
}

// 中心镂空:用背景色覆盖一个小圆,形成环形
ctx.fillStyle = AppColors.CardBackground;
ctx.beginPath();
ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
ctx.fill();

7.2 关键 Canvas API

API 作用 使用场景
clearRect(x, y, w, h) 清空矩形区域 每次重绘前清屏
beginPath() 开始新路径 每个扇区独立路径
moveTo(x, y) 移动画笔 定位到圆心
arc(cx, cy, r, s, e) 绘制圆弧 画扇区/圆形
closePath() 闭合路径 连接终点与起点
fill() 填充路径 实心扇区
fillStyle 填充颜色 设置扇区/背景色

八、最佳实践

8.1 Canvas 性能优化

性能优化执行流程:

  1. 使用 DevEco Studio Profiler 采集帧率和绘制耗时
  2. 分析瓶颈:是否每帧重绘全量数据
  3. 分模块实施优化:离屏缓存、数据分页
  4. 对比优化前后帧率数据验证效果
优化项 说明 收益
减少绘制调用 批量 fill 而非逐像素 降低 GPU 负载
离屏 Canvas 缓存 复杂图形预渲染 避免重复计算
数据分页 超过 20 项分类只取 Top N 减少扇区数量
避免频繁 clearRect 仅在数据变化时重绘 减少无效绘制

8.2 主题色响应

// Canvas 颜色通过 ViewModel 传入,深色模式切换时重新聚合数据触发重绘
@StorageLink('color.card.background') cardBg: string = '#FFFFFF';
// ViewModel 监听主题变化后重新调用 aggregateByCategory() 刷新颜色

8.3 触摸交互

// Canvas 点击坐标 → 角度命中检测
.onTouch((event: TouchEvent) => {
  const x = event.touches[0].x - cx;
  const y = event.touches[0].y - cy;
  const dist = Math.sqrt(x * x + y * y);
  if (dist > r || dist < r * 0.55) return; // 落在环形外或镂空区
  let angle = Math.atan2(y, x) + Math.PI / 2;
  if (angle < 0) angle += Math.PI * 2;
  // 根据 angle 匹配对应扇区
})

九、运行验证

9.1 构建命令

hvigorw assembleHap --mode module -p product=default

9.2 验证清单

验证项 预期结果
进入统计页 加载数据后展示环形饼图
数据为空 显示"暂无数据"占位
切换深色模式 图表背景色与扇区颜色同步刷新
点击扇区 命中区域高亮或弹出分类明细
编译通过 width/height 冲突报错

十、常见问题

10.1 Canvas 不显示

// 原因:onReady 未触发或 chartWidth/chartHeight 为 0
// 解决:明确设置 chartWidth/chartHeight,确保在 onReady 回调内绘制
Canvas(this.ctx)
  .width(this.chartWidth)
  .height(this.chartHeight)
  .onReady(() => { this.drawPie(); })

10.2 绘制模糊

// 原因:未处理设备像素比 dpr
// 解决:在 onReady 中先 scale 适配高分屏
.onReady(() => {
  const dpr = display.getDefaultDisplaySync().densityPixels;
  this.ctx.scale(dpr, dpr);
  this.drawPie();
})

10.3 动画卡顿

// 原因:setInterval 频率不合理
// 解决:用 ArkUI animateTo 装饰器或 Canvas requestAnimationFrame
animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
  this.drawPie();
})

10.4 颜色显示异常

// 原因:fillStyle 设置在 fill 之后
// 解决:必须先设 fillStyle 再调用 fill()
ctx.fillStyle = item.color;  // 先设颜色
ctx.fill();                  // 再填充

十一、Git 提交

11.1 提交命令

git add .
git commit -m "feat(图表): Canvas 绘制饼图 CircleChart

- 新增 PieDataItem 数据接口(显式导出)
- 封装 CircleChart 通用饼图组件
- ViewModel 按分类聚合 expenseByCategory
- 集成到 StatisticsView 页面
- 修复 @Prop width/height 与基类方法冲突"

11.2 变更日志

## [v0.1.9] - 2026-07-27
### Added
- components/chart/CircleChart.ets(PieDataItem 接口 + CircleChart 组件)
- viewmodel/StatisticsViewModel.ets(expenseByCategory 聚合逻辑)
### Changed
- StatisticsView 集成 CircleChart 组件
- DateUtil.monthStart/End 传入 Date.now() 参数

附录:运行效果截图

在这里插入图片描述


总结

本文完整介绍了 Canvas 绘制饼图 的全流程,涵盖 PieDataItem 数据接口设计、StatisticsViewModel 业务聚合、CircleChart 组件封装、Canvas 扇形绘制算法、StatisticsView 页面集成,以及关键的 ArkTS @Prop 属性命名冲突陷阱。通过本篇你可以:

  • 设计显式导出的数据接口,保证跨文件类型安全
  • 封装可复用的 CircleChart 环形饼图组件
  • 使用 Canvas API 实现 arc + fill 扇区绘制与中心镂空
  • 规避 @Prop width/heightCustomComponent 基类方法的命名冲突
  • 处理触摸交互的角度命中检测

下一篇预告:[Canvas 绘制柱状图] —— 使用 BarChart 组件展示每日支出趋势。


如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!在评论区告诉我你最想了解的鸿蒙开发话题,我会优先安排。


相关资源

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐