尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

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

HarmonyOS NEXT 企业级记账APP:Canvas 绘制柱状图
📅 发布时间:2026/8/1 21:35:24

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 标签区
组件属性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 图表数据接口对比

接口名字段适用组件使用场景
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(): 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不兼容,编译失败。

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 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

相关新闻

  • MMRecord缓存机制详解:Core Data持久化与网络请求缓存策略
  • 科研绘图与数据可视化:做出专业级图表的方法与工具
  • Hydro-SDK高级技巧:提升TypeScript Flutter应用性能的10个秘诀

最新新闻

  • Windows 提权实战:从普通用户到 SYSTEM 的 Metasploit 全链路攻击复现与检测防御
  • 猫抓浏览器插件终极指南:三步快速下载网页视频的完整教程
  • 1688一件代发完整流程(含抖掌柜实操适配)完整操作步骤与常见问题详解 - 电商分享
  • 【解读ByteByteGo 长文】ChatGPT Agent Loop 深度性能优化全解:Harness、API 与推理三层工程落地
  • CozyPixels壁纸分类完全手册:从抽象艺术到赛博朋克的视觉之旅
  • Raven-Storm完整安装教程:从入门到精通的Python DDoS工具部署指南

日新闻

  • ClickHouse版本管理深度实战:4步构建零风险升级与回滚体系
  • Java 23 种设计模式:从踩坑到精通 | 番外:责任链模式 —— 物流审批流程实战
  • 华硕笔记本性能解放指南:G-Helper轻量级控制工具全面解析

周新闻

  • 大连理工大学与东京大学联手打造的“主动型AI助手“
  • 170.2026年国家级科研瓶颈:超精密单点金刚石切削(SPDT)光学表面生成
  • SongBloom:革命性歌曲生成框架深度解析——如何通过交织自回归与扩散模型创作完整音乐

月新闻

  • ClickHouse版本管理深度实战:4步构建零风险升级与回滚体系
  • Java 23 种设计模式:从踩坑到精通 | 番外:责任链模式 —— 物流审批流程实战
  • 华硕笔记本性能解放指南:G-Helper轻量级控制工具全面解析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号