HarmonyOS NEXT 企业级记账APP:Canvas 绘制柱状图
Canvas 绘制柱状图
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第20篇,对应 Git Tagv0.2.0。承接前篇饼图,本篇使用 Canvas API 绘制柱状图展示每日支出趋势,封装
BarChart组件支持自适应柱宽、底部留白、空数据保护。重点延续讲解 ArkTS@Prop属性命名冲突陷阱的统一解决方案。
前言
饼图擅长展示分类占比,而柱状图擅长展示时间序列趋势。记账 APP 的统计页需要回答"每天花了多少",柱状图是最直观的方案。上一篇我们封装了CircleChart,本篇将封装BarChart组件,并与PieDataItem形成统一的数据接口体系。本文将带你:
- 设计
BarDataItem数据接口与 ViewModel 聚合逻辑 - 封装
BarChart通用柱状图组件 - 实现自适应柱宽与底部留白的绘制算法
- 集成到
StatisticsView展示每日支出 - 统一规避 ArkTS
@Prop属性名冲突陷阱
企业级核心原则:图表组件必须自适应、可复用、可扩展。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。
一、需求分析
1.1 功能介绍
| 需求项 | 说明 |
|---|---|
| 核心功能 | 使用 Canvas API 绘制柱状图展示每日支出趋势,封装BarChart组件 |
| 数据源 | ViewModel 按天聚合生成BarDataItem[] |
| 交互方式 | 点击柱子查看当日明细 |
| 视觉规范 | 柱体使用分类主题色,底部留 20px 标签区 |
| 组件属性 | chartWidth、chartHeight、data |
1.2 业务流程
用户进入统计页 → 切换"趋势"Tab ↓ StatisticsViewModel 按天聚合本月支出 ↓ 生成 dailyExpense: BarDataItem[] ↓ BarChart 组件接收 data 并在 onReady 中绘制 ↓ 用户点击柱子 → x 坐标命中检测 → 弹出当日明细二、数据接口设计
2.1 BarDataItem 接口定义
与PieDataItem保持一致的结构,BarDataItem同样显式导出并携带color字段。统一接口设计的好处是 ViewModel 可以复用同一套颜色调色板逻辑。
// components/chart/BarChart.ets export interface BarDataItem { label: string; value: number; color: string; }2.2 图表数据接口对比
| 接口名 | 字段 | 适用组件 | 使用场景 |
|---|---|---|---|
PieDataItem | label/value/color | CircleChart | 分类占比 |
BarDataItem | label/value/color | BarChart | 时间序列趋势 |
LineDataItem | label/value | LineChart | 连续趋势曲线 |
设计要点:
BarDataItem与PieDataItem结构完全一致,但分开声明以保持语义清晰。未来如果柱状图需要独立字段(如stackValue堆叠值),不会影响饼图接口。
三、ViewModel 业务层
3.1 dailyExpense 聚合逻辑
ViewModel 在加载本月账单后,按天聚合支出金额,生成BarDataItem[]。每个柱子的颜色统一使用AppColors.Expense,也可按金额梯度变色。
// 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 { BarDataItem } from '../components/chart/BarChart'; import { AppColors } from '../theme/Colors'; export class StatisticsViewModel { bills: Bill[] = []; totalExpense: number = 0; dailyExpense: BarDataItem[] = []; 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.aggregateDailyExpense(start, end); this.isLoading = false; } private aggregateDailyExpense(start: number, end: number): void { const dayMs = 24 * 60 * 60 * 1000; const map = new Map<string, number>(); for (const b of this.bills) { if (b.type !== BillType.EXPENSE) continue; const dayKey = DateUtil.formatDate(b.date, 'MM-dd'); map.set(dayKey, (map.get(dayKey) ?? 0) + b.money); } this.dailyExpense = []; for (let t = start; t <= end; t += dayMs) { const label = DateUtil.formatDate(t, 'MM-dd'); const value = map.get(label) ?? 0; this.dailyExpense.push({ label, value, color: AppColors.Expense }); } } formatMoney(cents: number): string { return MoneyUtil.formatWithComma(cents); } }3.2 字段说明
| 字段 | 类型 | 用途 |
|---|---|---|
bills | Bill[] | 原始账单列表 |
totalExpense | number | 本月支出汇总(分) |
dailyExpense | BarDataItem[] | 每日支出柱状图数据 |
isLoading | boolean | 加载态标志 |
四、BarChart 组件封装
4.1 组件完整源码
以下是实际项目中BarChart组件的完整源码。核心绘制逻辑包括:自适应柱宽计算、最大值归一化、底部 20px 留白、空数据保护。
// components/chart/BarChart.ets export interface BarDataItem { label: string; value: number; color: string; } @Component export struct BarChart { @Prop data: BarDataItem[] = []; @Prop chartWidth: number = 300; @Prop chartHeight: number = 200; private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(); build() { Column() { Canvas(this.ctx) .width(this.chartWidth) .height(this.chartHeight) .onReady(() => { this.draw(); }) }.alignItems(HorizontalAlign.Center) } private draw(): void { const ctx = this.ctx; ctx.clearRect(0, 0, this.chartWidth, this.chartHeight); if (this.data.length === 0) return; const maxVal = Math.max(...this.data.map(d => d.value), 1); const barW = Math.max(8, this.chartWidth / this.data.length - 6); for (let i = 0; i < this.data.length; i++) { const x = i * (barW + 6) + 3; const h = (this.data[i].value / maxVal) * (this.chartHeight - 30); ctx.fillStyle = this.data[i].color; ctx.fillRect(x, this.chartHeight - h - 20, barW, Math.max(h, 1)); } } }4.2 组件属性一览
| 属性 | 装饰器 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
data | @Prop | BarDataItem[] | [] | 柱状图数据源 |
chartWidth | @Prop | number | 300 | 画布宽度 |
chartHeight | @Prop | number | 200 | 画布高度 |
ctx | 普通成员 | CanvasRenderingContext2D | new | 绘图上下文 |
4.3 柱宽自适应计算
柱状图的一个关键设计是柱宽自适应。当数据项数量变化时,柱子宽度自动调整以填满画布:
- 计算
maxVal:取所有数据项的最大值,至少为 1,避免除零 - 计算
barW:画布宽度除以数据项数再减去 6px 间距,最小 8px - 逐柱绘制:x 坐标按
i * (barW + 6) + 3等间距排列 - 高度归一化:
h = (value / maxVal) * (chartHeight - 30),底部留 30px 给标签 - y 坐标:
chartHeight - h - 20,从底部向上生长
五、ArkTS 编译陷阱:width/height 属性名冲突
5.1 问题复现
与饼图组件一样,柱状图组件最初也使用了@Prop width和@Prop height,导致同样的编译错误:
错误: Property 'width' in type 'BarChart' is not assignable to the same property in base type 'CustomComponent'. Type 'number' is not assignable to type '((value: Length) => BarChart) & number'. 错误: Property 'height' in type 'BarChart' 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) => BarChart) & number不兼容,编译失败。
width和height在 ArkUI 中是保留的布局方法名,禁止作为@Prop属性名。
5.3 解决方案
全系列图表组件统一采用chartWidth/chartHeight命名,并同步更新所有内部引用:
// ❌ 错误写法:与基类方法冲突,编译报错 @Prop width: number = 300; @Prop height: number = 200; // ... ctx.clearRect(0, 0, this.width, this.height); const barW = Math.max(8, this.width / this.data.length - 6); // ✅ 正确写法:使用 chart 前缀避免冲突 @Prop chartWidth: number = 300; @Prop chartHeight: number = 200; // ... ctx.clearRect(0, 0, this.chartWidth, this.chartHeight); const barW = Math.max(8, this.chartWidth / this.data.length - 6);5.4 统一命名规范
| 组件 | 错误属性名 | 正确属性名 | 说明 |
|---|---|---|---|
CircleChart | width/height | chartWidth/chartHeight | 饼图画布尺寸 |
BarChart | width/height | chartWidth/chartHeight | 柱状图画布尺寸 |
LineChart | width/height | chartWidth/chartHeight | 折线图画布尺寸 |
| 任意自定义组件 | width/height | xxxWidth/xxxHeight | 一律加业务前缀 |
最佳实践:三个图表组件统一使用
chartWidth/chartHeight,既避免冲突又保持命名一致性,降低维护成本。
六、页面集成与调用
6.1 StatisticsView 调用方式
StatisticsView通过BarChart({ data: this.viewModel.dailyExpense })调用组件,与饼图并列展示:
// pages/StatisticsView.ets import { StatisticsViewModel } from '../viewmodel/StatisticsViewModel'; import { CircleChart } from '../components/chart/CircleChart'; import { BarChart } from '../components/chart/BarChart'; import { AppColors } from '../theme/Colors'; import { AppSpace } from '../theme/Spacing'; @Entry @Component struct StatisticsView { @State viewModel: StatisticsViewModel = new StatisticsViewModel(); @State activeTab: number = 0; // 0=占比 1=趋势 aboutToAppear() { this.viewModel.loadData(); } build() { Column() { // Tab 切换 Row({ space: AppSpace.MD }) { Text('分类占比').fontSize(14) .fontColor(this.activeTab === 0 ? AppColors.Budget : AppColors.SecondaryText) .onClick(() => { this.activeTab = 0; }) Text('每日趋势').fontSize(14) .fontColor(this.activeTab === 1 ? AppColors.Budget : AppColors.SecondaryText) .onClick(() => { this.activeTab = 1; }) }.width('100%').justifyContent(FlexAlign.Center).margin({ bottom: AppSpace.MD }) if (this.viewModel.isLoading) { Column() { Text('加载中...').fontColor(AppColors.SecondaryText) } .width('100%').height('100%').justifyContent(FlexAlign.Center) } else if (this.activeTab === 0) { CircleChart({ data: this.viewModel.expenseByCategory }) } else { BarChart({ data: this.viewModel.dailyExpense }) } } .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 draw 方法逐步解析
柱状图绘制逻辑可拆解为清屏、求最大值、计算柱宽、逐柱绘制四步:
// 第一步:清屏 ctx.clearRect(0, 0, this.chartWidth, this.chartHeight); // 第二步:空数据保护 if (this.data.length === 0) return; // 第三步:求最大值(至少为 1,防止除零) const maxVal = Math.max(...this.data.map(d => d.value), 1); // 第四步:自适应柱宽(最小 8px,间距 6px) const barW = Math.max(8, this.chartWidth / this.data.length - 6); // 第五步:逐柱绘制 for (let i = 0; i < this.data.length; i++) { const x = i * (barW + 6) + 3; const h = (this.data[i].value / maxVal) * (this.chartHeight - 30); ctx.fillStyle = this.data[i].color; ctx.fillRect(x, this.chartHeight - h - 20, barW, Math.max(h, 1)); }7.2 关键 Canvas API
| API | 作用 | 使用场景 |
|---|---|---|
clearRect(x, y, w, h) | 清空矩形区域 | 每次重绘前清屏 |
fillRect(x, y, w, h) | 绘制填充矩形 | 画柱体 |
fillStyle | 填充颜色 | 设置柱体颜色 |
Math.max(...arr, 1) | 求最大值 | 归一化基准值 |
Math.max(h, 1) | 最小高度保护 | 避免零高度不可见 |
7.3 坐标系说明
(0,0) ─────────────────────── (chartWidth, 0) │ │ │ ┌─┐ ┌─┐ ┌─┐ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ← 柱体 │ │ │ │ │ │ │ │ │ │ └─┘ └─┘ └─┘ │ │ ← 20px 底部留白 (标签区) → │ (0, chartHeight) ───────────── (chartWidth, chartHeight)八、最佳实践
8.1 柱状图性能优化
性能优化执行流程:
- 使用 DevEco Studio Profiler 采集绘制帧率
- 分析瓶颈:柱子数量过多导致单帧绘制耗时增加
- 实施数据采样:超过 31 天只取最近 31 天或按周聚合
- 对比优化前后帧率数据验证效果
| 优化项 | 说明 | 收益 |
|---|---|---|
| 数据采样 | 超过 31 项按周聚合 | 减少柱子数量 |
| 复用 fillStyle | 相同颜色柱子批量绘制 | 减少 GPU 状态切换 |
| 避免每帧 clearRect | 仅数据变化时重绘 | 减少无效绘制 |
| 离屏缓存 | 静态背景预渲染 | 降低重复计算 |
8.2 主题色响应
// 柱体颜色由 ViewModel 传入,深色模式切换时重新聚合刷新颜色 @StorageLink('color.expense') expenseColor: string = '#FF3B30'; // ViewModel 监听主题变化后重新调用 aggregateDailyExpense() 刷新 color 字段8.3 触摸交互
// Canvas 点击坐标 → x 轴命中检测 .onTouch((event: TouchEvent) => { const touchX = event.touches[0].x; const barW = Math.max(8, this.chartWidth / this.data.length - 6); const index = Math.floor((touchX - 3) / (barW + 6)); if (index >= 0 && index < this.data.length) { const item = this.data[index]; // 弹出当日明细 Toast 或 Dialog } })九、运行验证
9.1 构建命令
hvigorw assembleHap--modemodule-pproduct=default9.2 验证清单
| 验证项 | 预期结果 |
|---|---|
| 切换到趋势 Tab | 加载数据后展示柱状图 |
| 数据为空 | 显示"暂无数据"占位 |
| 柱宽自适应 | 31 天数据不溢出画布 |
| 切换深色模式 | 柱体颜色同步刷新 |
| 点击柱子 | 弹出当日支出明细 |
| 编译通过 | 无width/height冲突报错 |
十、常见问题
10.1 柱子不显示
// 原因:value 为 0 时 h 为 0,fillRect 高度为 0 不可见 // 解决:源码已用 Math.max(h, 1) 保证最小 1px 高度 ctx.fillRect(x, this.chartHeight - h - 20, barW, Math.max(h, 1));10.2 柱子溢出画布
// 原因:数据项过多,barW 计算后仍超出画布 // 解决:限制数据项数量,或启用横向滚动 const maxBars = Math.floor(this.chartWidth / 14); // 每柱至少 14px const displayData = this.data.slice(-maxBars);10.3 绘制模糊
// 原因:未处理设备像素比 dpr // 解决:在 onReady 中先 scale 适配高分屏 .onReady(() => { const dpr = display.getDefaultDisplaySync().densityPixels; this.ctx.scale(dpr, dpr); this.draw(); })10.4 颜色显示异常
// 原因:fillStyle 设置在 fillRect 之后 // 解决:必须先设 fillStyle 再调用 fillRect() ctx.fillStyle = this.data[i].color; // 先设颜色 ctx.fillRect(x, y, barW, h); // 再绘制十一、Git 提交
11.1 提交命令
gitadd.gitcommit-m"feat(图表): Canvas 绘制柱状图 BarChart - 新增 BarDataItem 数据接口(显式导出) - 封装 BarChart 通用柱状图组件 - ViewModel 按天聚合 dailyExpense - StatisticsView 集成趋势 Tab - 统一 chartWidth/chartHeight 命名规范"11.2 变更日志
## [v0.2.0] - 2026-07-27 ### Added - components/chart/BarChart.ets(BarDataItem 接口 + BarChart 组件) - StatisticsViewModel 新增 dailyExpense 聚合逻辑 ### Changed - StatisticsView 新增趋势 Tab 切换 - 统一图表组件 chartWidth/chartHeight 命名附录:运行效果截图
总结
本文完整介绍了Canvas 绘制柱状图的全流程,涵盖BarDataItem数据接口设计、StatisticsViewModel按天聚合逻辑、BarChart组件封装、自适应柱宽绘制算法、StatisticsView趋势 Tab 集成,以及统一的 ArkTS@Prop属性命名冲突解决方案。通过本篇你可以:
- 设计与饼图统一结构的
BarDataItem数据接口 - 封装支持自适应柱宽和底部留白的
BarChart组件 - 使用 Canvas
fillRectAPI 实现柱状图绘制 - 处理空数据保护和零高度最小值兜底
- 统一三个图表组件的
chartWidth/chartHeight命名规范
下一篇预告:[Canvas 绘制折线图] —— 使用LineChart组件展示收支趋势曲线。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!在评论区告诉我你最想了解的鸿蒙开发话题,我会优先安排。
相关资源
- 本篇源码:GitHub Tag v0.2.0
- ArkUI Canvas 组件:canvas
- ArkUI CanvasRenderingContext2D:canvasrenderingcontext2d
- ArkUI Animation 动画:animation
- Canvas 绘制教程:canvas-tutorial
- ArkUI 触摸事件:touch-event
- ArkUI 图表组件实践:chart-component
