
Canvas 绘制柱状图本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第20篇对应 Git Tagv0.2.0。承接前篇饼图本篇使用 Canvas API 绘制柱状图展示每日支出趋势封装BarChart组件支持自适应柱宽、底部留白、空数据保护。重点延续讲解 ArkTSProp属性命名冲突陷阱的统一解决方案。前言饼图擅长展示分类占比而柱状图擅长展示时间序列趋势。记账 APP 的统计页需要回答每天花了多少柱状图是最直观的方案。上一篇我们封装了CircleChart本篇将封装BarChart组件并与PieDataItem形成统一的数据接口体系。本文将带你设计BarDataItem数据接口与 ViewModel 聚合逻辑封装BarChart通用柱状图组件实现自适应柱宽与底部留白的绘制算法集成到StatisticsView展示每日支出统一规避 ArkTSProp属性名冲突陷阱企业级核心原则图表组件必须自适应、可复用、可扩展。参考 HarmonyOS NEXT 开发者文档 了解官方约定配合 ArkUI Canvas 组件 掌握绘制 API。一、需求分析1.1 功能介绍需求项说明核心功能使用 Canvas API 绘制柱状图展示每日支出趋势封装BarChart组件数据源ViewModel 按天聚合生成BarDataItem[]交互方式点击柱子查看当日明细视觉规范柱体使用分类主题色底部留 20px 标签区组件属性chartWidth、chartHeight、data1.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连续趋势曲线设计要点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(): Promisevoid { 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 Mapstring, 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 组件属性一览属性装饰器类型默认值说明dataPropBarDataItem[][]柱状图数据源chartWidthPropnumber300画布宽度chartHeightPropnumber200画布高度ctx普通成员CanvasRenderingContext2Dnew绘图上下文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 统一命名规范组件错误属性名正确属性名说明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 APIAPI作用使用场景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-pproductdefault9.2 验证清单验证项预期结果切换到趋势 Tab加载数据后展示柱状图数据为空显示暂无数据占位柱宽自适应31 天数据不溢出画布切换深色模式柱体颜色同步刷新点击柱子弹出当日支出明细编译通过无width/height冲突报错十、常见问题10.1 柱子不显示// 原因value 为 0 时 h 为 0fillRect 高度为 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-mfeat(图表): Canvas 绘制柱状图 BarChart - 新增 BarDataItem 数据接口显式导出 - 封装 BarChart 通用柱状图组件 - ViewModel 按天聚合 dailyExpense - StatisticsView 集成趋势 Tab - 统一 chartWidth/chartHeight 命名规范11.2 变更日志## [v0.2.0] - 2026-07-27 ### Added - components/chart/BarChart.etsBarDataItem 接口 BarChart 组件 - StatisticsViewModel 新增 dailyExpense 聚合逻辑 ### Changed - StatisticsView 新增趋势 Tab 切换 - 统一图表组件 chartWidth/chartHeight 命名附录运行效果截图总结本文完整介绍了Canvas 绘制柱状图的全流程涵盖BarDataItem数据接口设计、StatisticsViewModel按天聚合逻辑、BarChart组件封装、自适应柱宽绘制算法、StatisticsView趋势 Tab 集成以及统一的 ArkTSProp属性命名冲突解决方案。通过本篇你可以设计与饼图统一结构的BarDataItem数据接口封装支持自适应柱宽和底部留白的BarChart组件使用 CanvasfillRectAPI 实现柱状图绘制处理空数据保护和零高度最小值兜底统一三个图表组件的chartWidth/chartHeight命名规范下一篇预告[Canvas 绘制折线图] —— 使用LineChart组件展示收支趋势曲线。如果这篇文章对你有帮助欢迎点赞、收藏、关注你的支持是我持续创作的动力在评论区告诉我你最想了解的鸿蒙开发话题我会优先安排。相关资源本篇源码GitHub Tag v0.2.0ArkUI Canvas 组件canvasArkUI CanvasRenderingContext2Dcanvasrenderingcontext2dArkUI Animation 动画animationCanvas 绘制教程canvas-tutorialArkUI 触摸事件touch-eventArkUI 图表组件实践chart-component