HarmonyOS NEXT 企业级记账APP:Canvas 绘制饼图
Canvas 绘制饼图
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 19 篇,对应 Git Tag v0.1.9。承接前序开发,本篇使用 ArkUI Canvas API 绘制饼图展示分类支出占比,封装
CircleChart组件支持图例、动画、点击交互。重点讲解 ArkTS 中@Prop属性命名与基类方法冲突的编译陷阱及解决方案。
前言
企业级应用的完整体验包含主动提醒与数据可视化两大能力。本章落地鸿蒙 Canvas API,为统计图表模块奠定基础。记账 APP 的统计页需要直观展示"本月钱花在哪里",饼图是最经典的可视化方案。本文将带你:
- 设计
PieDataItem数据接口与 ViewModel 业务逻辑 - 封装
CircleChart通用饼图组件 - 实现完整的 Canvas 扇形绘制算法
- 集成到
StatisticsView完成数据闭环 - 规避 ArkTS
@Prop属性名冲突的编译陷阱
企业级核心原则:功能必须可视、可交互、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。
一、需求分析
1.1 功能介绍
| 需求项 | 说明 |
|---|---|
| 核心功能 | 使用 ArkUI Canvas API 绘制饼图展示分类支出占比,封装 CircleChart 组件 |
| 数据源 | ViewModel 聚合 Repository 数据后生成 PieDataItem[] |
| 交互方式 | 点击扇区高亮、长按弹出明细 |
| 视觉规范 | 收入绿/支出红/预算蓝/统计紫,环形中心镂空 |
| 组件属性 | chartWidth、chartHeight、data |
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 绘制执行流程
饼图绘制的完整执行步骤如下:
onReady回调触发,调用drawPie()方法clearRect清空画布,避免残留像素- 计算圆心坐标
cx/cy和半径r reduce求和数据总和,总和为 0 则直接返回- 从
-π/2(正上方)开始逐扇区绘制 - 每个扇区:
beginPath→moveTo圆心 →arc画弧 →closePath→fill - 最后在中心绘制
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不兼容,从而报出上述编译错误。
简单来说,width 和 height 在 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 性能优化
性能优化执行流程:
- 使用 DevEco Studio Profiler 采集帧率和绘制耗时
- 分析瓶颈:是否每帧重绘全量数据
- 分模块实施优化:离屏缓存、数据分页
- 对比优化前后帧率数据验证效果
| 优化项 | 说明 | 收益 |
|---|---|---|
| 减少绘制调用 | 批量 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/height与CustomComponent基类方法的命名冲突 - 处理触摸交互的角度命中检测
下一篇预告:[Canvas 绘制柱状图] —— 使用 BarChart 组件展示每日支出趋势。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!在评论区告诉我你最想了解的鸿蒙开发话题,我会优先安排。
相关资源
- 本篇源码:GitHub Tag v0.1.9
- ArkUI Canvas 组件:canvas
- ArkUI CanvasRenderingContext2D:canvasrenderingcontext2d
- ArkUI Animation 动画:animation
- Canvas 绘制教程:canvas-tutorial
- ArkUI 触摸事件:touch-event
- ArkUI 图表组件实践:chart-component
更多推荐




所有评论(0)