HarmonyOS NEXT 企业级记账APP:Canvas 绘制柱状图

📅 2026/8/1 21:35:24 👁️ 阅读次数 📝 编程学习
HarmonyOS NEXT 企业级记账APP:Canvas 绘制柱状图

Canvas 绘制柱状图

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第20篇,对应 Git Tagv0.2.0。承接前篇饼图,本篇使用 Canvas API 绘制柱状图展示每日支出趋势,封装BarChart组件支持自适应柱宽、底部留白、空数据保护。重点延续讲解 ArkTS@Prop属性命名冲突陷阱的统一解决方案。

前言

饼图擅长展示分类占比,而柱状图擅长展示时间序列趋势。记账 APP 的统计页需要回答"每天花了多少",柱状图是最直观的方案。上一篇我们封装了CircleChart,本篇将封装BarChart组件,并与PieDataItem形成统一的数据接口体系。本文将带你:

  1. 设计BarDataItem数据接口与 ViewModel 聚合逻辑
  2. 封装BarChart通用柱状图组件
  3. 实现自适应柱宽与底部留白的绘制算法
  4. 集成到StatisticsView展示每日支出
  5. 统一规避 ArkTS@Prop属性名冲突陷阱

企业级核心原则:图表组件必须自适应、可复用、可扩展。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。


一、需求分析

1.1 功能介绍

需求项说明
核心功能使用 Canvas API 绘制柱状图展示每日支出趋势,封装BarChart组件
数据源ViewModel 按天聚合生成BarDataItem[]
交互方式点击柱子查看当日明细
视觉规范柱体使用分类主题色,底部留 20px 标签区
组件属性chartWidthchartHeightdata

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 图表数据接口对比

接口名字段适用组件使用场景
PieDataItemlabel/value/colorCircleChart分类占比
BarDataItemlabel/value/colorBarChart时间序列趋势
LineDataItemlabel/valueLineChart连续趋势曲线

设计要点BarDataItemPieDataItem结构完全一致,但分开声明以保持语义清晰。未来如果柱状图需要独立字段(如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 字段说明

字段类型用途
billsBill[]原始账单列表
totalExpensenumber本月支出汇总(分)
dailyExpenseBarDataItem[]每日支出柱状图数据
isLoadingboolean加载态标志

四、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@PropBarDataItem[][]柱状图数据源
chartWidth@Propnumber300画布宽度
chartHeight@Propnumber200画布高度
ctx普通成员CanvasRenderingContext2Dnew绘图上下文

4.3 柱宽自适应计算

柱状图的一个关键设计是柱宽自适应。当数据项数量变化时,柱子宽度自动调整以填满画布:

  1. 计算maxVal:取所有数据项的最大值,至少为 1,避免除零
  2. 计算barW:画布宽度除以数据项数再减去 6px 间距,最小 8px
  3. 逐柱绘制:x 坐标按i * (barW + 6) + 3等间距排列
  4. 高度归一化:h = (value / maxVal) * (chartHeight - 30),底部留 30px 给标签
  5. 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不兼容,编译失败。

widthheight在 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 统一命名规范

组件错误属性名正确属性名说明
CircleChartwidth/heightchartWidth/chartHeight饼图画布尺寸
BarChartwidth/heightchartWidth/chartHeight柱状图画布尺寸
LineChartwidth/heightchartWidth/chartHeight折线图画布尺寸
任意自定义组件width/heightxxxWidth/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 柱状图性能优化

性能优化执行流程:

  1. 使用 DevEco Studio Profiler 采集绘制帧率
  2. 分析瓶颈:柱子数量过多导致单帧绘制耗时增加
  3. 实施数据采样:超过 31 天只取最近 31 天或按周聚合
  4. 对比优化前后帧率数据验证效果
优化项说明收益
数据采样超过 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=default

9.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组件
  • 使用 CanvasfillRectAPI 实现柱状图绘制
  • 处理空数据保护和零高度最小值兜底
  • 统一三个图表组件的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