HarmonyOS NEXT 企业级记账APP:Canvas 绘制折线图
想·# Canvas 绘制折线图
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第21篇,对应 Git Tagv0.2.1。承接前篇饼图与柱状图,本篇使用 Canvas API 绘制折线图展示收支趋势曲线,封装
LineChart组件支持折线连接、数据点标记、可配置线条颜色。作为图表系列的收尾篇,统一复盘 ArkTS@Prop属性命名冲突陷阱的全局解决方案。
前言
饼图展示分类占比,柱状图展示离散趋势,而折线图最适合展示连续趋势。记账 APP 的统计页需要回答"最近几周收支如何变化",折线图能直观呈现波动规律。本篇是 Canvas 图表系列三连击的收官之作,前两篇封装了CircleChart和BarChart,本篇将封装LineChart,完成统计页三大图表体系。本文将带你:
- 设计
LineDataItem数据接口与 ViewModel 周维度聚合逻辑 - 封装
LineChart通用折线图组件 - 实现折线连接与数据点标记的绘制算法
- 理解
lineColor属性与LineDataItem无color字段的设计差异 - 统一复盘三个图表组件的
@Prop命名冲突解决方案
企业级核心原则:图表组件体系必须接口统一、命名规范、可组合。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。
一、需求分析
1.1 功能介绍
| 需求项 | 说明 |
|---|---|
| 核心功能 | 使用 Canvas API 绘制折线图展示收支趋势,封装LineChart组件 |
| 数据源 | ViewModel 按周聚合生成LineDataItem[] |
| 交互方式 | 点击数据点查看该周明细 |
| 视觉规范 | 折线使用AppColors.Budget蓝色,数据点半径 3px |
| 组件属性 | chartWidth、chartHeight、data、lineColor |
1.2 业务流程
用户进入统计页 → 切换"趋势"Tab → 选择"周维度" ↓ StatisticsViewModel 按周聚合本月收支 ↓ 生成 weeklyTrend: LineDataItem[] ↓ LineChart 组件接收 data 并在 onReady 中绘制 ↓ 用户点击数据点 → 命中检测 → 弹出该周明细二、数据接口设计
2.1 LineDataItem 接口定义
与PieDataItem和BarDataItem不同,LineDataItem不携带color字段。折线图的颜色是整条线统一的,通过@Prop lineColor传入,而非每个数据点独立着色。这是折线图与饼图、柱状图的核心设计差异。
// components/chart/LineChart.ets export interface LineDataItem { label: string; value: number; }2.2 三个数据接口对比
| 接口名 | 字段 | 颜色来源 | 适用组件 |
|---|---|---|---|
PieDataItem | label/value/color | 数据项自带 | CircleChart |
BarDataItem | label/value/color | 数据项自带 | BarChart |
LineDataItem | label/value | @Prop lineColor | LineChart |
设计要点:饼图每个扇区颜色不同,柱图每根柱子可独立着色,因此颜色随数据项走。折线图整条线颜色统一,颜色作为组件属性
lineColor传入,LineDataItem只保留纯数据字段,接口更简洁。
三、ViewModel 业务层
3.1 weeklyTrend 聚合逻辑
ViewModel 按周聚合本月收支净额,生成LineDataItem[]。每周一个数据点,展示收支趋势变化。
// 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 { LineDataItem } from '../components/chart/LineChart'; export class StatisticsViewModel { bills: Bill[] = []; totalExpense: number = 0; weeklyTrend: LineDataItem[] = []; 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.aggregateWeeklyTrend(start, end); this.isLoading = false; } private aggregateWeeklyTrend(start: number, end: number): void { const weekMs = 7 * 24 * 60 * 60 * 1000; const map = new Map<string, number>(); for (const b of this.bills) { const weekIndex = Math.floor((b.date - start) / weekMs); const label = `第${weekIndex + 1}周`; const delta = b.type === BillType.EXPENSE ? -b.money : b.money; map.set(label, (map.get(label) ?? 0) + delta); } this.weeklyTrend = []; const weekCount = Math.ceil((end - start) / weekMs); for (let i = 0; i < weekCount; i++) { const label = `第${i + 1}周`; this.weeklyTrend.push({ label, value: map.get(label) ?? 0 }); } } formatMoney(cents: number): string { return MoneyUtil.formatWithComma(cents); } }3.2 字段说明
| 字段 | 类型 | 用途 |
|---|---|---|
bills | Bill[] | 原始账单列表 |
totalExpense | number | 本月支出汇总(分) |
weeklyTrend | LineDataItem[] | 每周收支净额趋势数据 |
isLoading | boolean | 加载态标志 |
四、LineChart 组件封装
4.1 组件完整源码
以下是实际项目中LineChart组件的完整源码。与饼图、柱状图不同,折线图额外引入@Prop lineColor属性控制线条颜色,绘制分两阶段:先画折线,再画数据点。
// components/chart/LineChart.ets import { AppColors } from '../theme/Colors'; export interface LineDataItem { label: string; value: number; } @Component export struct LineChart { @Prop data: LineDataItem[] = []; @Prop chartWidth: number = 300; @Prop chartHeight: number = 200; @Prop lineColor: string = AppColors.Budget; 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 < 2) return; const maxVal = Math.max(...this.data.map(d => d.value), 1); const stepX = (this.chartWidth - 30) / (this.data.length - 1); // 第一阶段:绘制折线 ctx.strokeStyle = this.lineColor; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < this.data.length; i++) { const x = 15 + i * stepX; const y = this.chartHeight - 15 - (this.data[i].value / maxVal) * (this.chartHeight - 30); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); // 第二阶段:绘制数据点 for (let i = 0; i < this.data.length; i++) { const x = 15 + i * stepX; const y = this.chartHeight - 15 - (this.data[i].value / maxVal) * (this.chartHeight - 30); ctx.fillStyle = this.lineColor; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); } } }4.2 组件属性一览
| 属性 | 装饰器 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
data | @Prop | LineDataItem[] | [] | 折线图数据源 |
chartWidth | @Prop | number | 300 | 画布宽度 |
chartHeight | @Prop | number | 200 | 画布高度 |
lineColor | @Prop | string | AppColors.Budget | 折线及数据点颜色 |
ctx | 普通成员 | CanvasRenderingContext2D | new | 绘图上下文 |
4.3 两阶段绘制流程
折线图绘制分为折线连接和数据点标记两个阶段:
clearRect清空画布- 数据少于 2 个点时直接返回(至少 2 点才能连线)
- 计算
maxVal和stepX(水平步长) - 第一阶段:设置
strokeStyle和lineWidth,beginPath后逐点moveTo/lineTo,最后stroke - 第二阶段:逐点
arc+fill绘制半径 3px 的实心圆
五、ArkTS 编译陷阱:width/height 属性名冲突
5.1 问题复现
折线图组件同样遭遇了@Prop width/@Prop height的编译冲突,这是三个图表组件共同的问题:
错误: Property 'width' in type 'LineChart' is not assignable to the same property in base type 'CustomComponent'. Type 'number' is not assignable to type '((value: Length) => LineChart) & number'. 错误: Property 'height' in type 'LineChart' 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声明同名属性时,子类number类型与基类方法签名((value: Length) => LineChart) & number不兼容,TypeScript 类型检查报错。
width和height在 ArkUI 中是保留的布局方法名,任何@Prop/@State/ 普通成员都不能与之重名。
5.3 解决方案
折线图同样采用chartWidth/chartHeight命名,与饼图、柱状图保持一致:
// ❌ 错误写法:与基类方法冲突,编译报错 @Prop width: number = 300; @Prop height: number = 200; // ... ctx.clearRect(0, 0, this.width, this.height); const stepX = (this.width - 30) / (this.data.length - 1); // ✅ 正确写法:使用 chart 前缀避免冲突 @Prop chartWidth: number = 300; @Prop chartHeight: number = 200; // ... ctx.clearRect(0, 0, this.chartWidth, this.chartHeight); const stepX = (this.chartWidth - 30) / (this.data.length - 1);5.4 三组件统一命名复盘
| 组件 | 错误属性名 | 正确属性名 | 额外属性 | 状态 |
|---|---|---|---|---|
CircleChart | width/height | chartWidth/chartHeight | 无 | 已修复 |
BarChart | width/height | chartWidth/chartHeight | 无 | 已修复 |
LineChart | width/height | chartWidth/chartHeight | lineColor | 已修复 |
复盘总结:三个图表组件统一使用
chartWidth/chartHeight命名,从根源上消除了与CustomComponent基类方法的冲突。这一命名规范已沉淀为团队开发约定,后续新增图表组件一律遵循。
六、页面集成与调用
6.1 StatisticsView 调用方式
StatisticsView通过LineChart({ data: this.viewModel.weeklyTrend })调用组件,与饼图、柱状图形成三图表体系:
// pages/StatisticsView.ets import { StatisticsViewModel } from '../viewmodel/StatisticsViewModel'; import { CircleChart } from '../components/chart/CircleChart'; import { BarChart } from '../components/chart/BarChart'; import { LineChart } from '../components/chart/LineChart'; 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=日趋势 2=周趋势 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; }) Text('每周趋势').fontSize(14) .fontColor(this.activeTab === 2 ? AppColors.Budget : AppColors.SecondaryText) .onClick(() => { this.activeTab = 2; }) }.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 if (this.activeTab === 1) { BarChart({ data: this.viewModel.dailyExpense }) } else { LineChart({ data: this.viewModel.weeklyTrend }) } } .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); // 第二阶段:至少 2 个数据点才能连线 if (this.data.length < 2) return; // 第三阶段:计算最大值和水平步长 const maxVal = Math.max(...this.data.map(d => d.value), 1); const stepX = (this.chartWidth - 30) / (this.data.length - 1); // 第四阶段:绘制折线(moveTo + lineTo + stroke) ctx.strokeStyle = this.lineColor; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < this.data.length; i++) { const x = 15 + i * stepX; const y = this.chartHeight - 15 - (this.data[i].value / maxVal) * (this.chartHeight - 30); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); // 第五阶段:绘制数据点(arc + fill) for (let i = 0; i < this.data.length; i++) { const x = 15 + i * stepX; const y = this.chartHeight - 15 - (this.data[i].value / maxVal) * (this.chartHeight - 30); ctx.fillStyle = this.lineColor; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); }7.2 关键 Canvas API
| API | 作用 | 使用场景 |
|---|---|---|
clearRect(x, y, w, h) | 清空矩形区域 | 每次重绘前清屏 |
beginPath() | 开始新路径 | 折线路径/数据点路径 |
moveTo(x, y) | 移动画笔不绘制 | 折线起点 |
lineTo(x, y) | 连线到目标点 | 折线后续点 |
stroke() | 描边路径 | 绘制折线 |
arc(cx, cy, r, s, e) | 绘制圆弧 | 数据点圆形 |
fill() | 填充路径 | 实心数据点 |
strokeStyle | 描边颜色 | 折线颜色 |
fillStyle | 填充颜色 | 数据点颜色 |
lineWidth | 线宽 | 折线粗细 |
7.3 坐标系与留白说明
(0,0) ─────────────────────── (chartWidth, 0) │ ← 15px 左留白 → │ │ ●─────●─────● │ │ / \ \ │ │ ● ● ● │ │ ← 15px 底部留白 → │ (0, chartHeight) ───────────── (chartWidth, chartHeight) 水平步长 stepX = (chartWidth - 30) / (data.length - 1) x 坐标 = 15 + i * stepX y 坐标 = chartHeight - 15 - (value / maxVal) * (chartHeight - 30)八、最佳实践
8.1 折线图性能优化
性能优化执行流程:
- 使用 DevEco Studio Profiler 采集绘制帧率和内存占用
- 分析瓶颈:数据点过多导致
lineTo调用频繁 - 实施数据降采样:超过 50 个点时按等间隔采样
- 对比优化前后帧率和内存数据验证效果
| 优化项 | 说明 | 收益 |
|---|---|---|
| 数据降采样 | 超过 50 点等间隔采样 | 减少lineTo调用 |
| 合并 Path | 折线和数据点用独立 Path | 避免状态污染 |
| 避免每帧重绘 | 仅数据变化时触发draw() | 减少无效绘制 |
| 复用坐标计算 | xy 坐标缓存到数组 | 避免重复运算 |
8.2 主题色响应
// lineColor 默认 AppColors.Budget,深色模式切换时颜色自动跟随主题 @StorageLink('color.budget') budgetColor: string = '#007AFF'; // 组件使用 @Prop lineColor 接收,ViewModel 传入主题色即可 LineChart({ data: this.viewModel.weeklyTrend, lineColor: this.budgetColor })8.3 触摸交互
// Canvas 点击坐标 → 最近数据点命中检测 .onTouch((event: TouchEvent) => { const touchX = event.touches[0].x; const stepX = (this.chartWidth - 30) / (this.data.length - 1); const rawIndex = (touchX - 15) / stepX; const index = Math.round(rawIndex); if (index >= 0 && index < this.data.length) { const item = this.data[index]; // 弹出该周收支明细 } })8.4 多折线扩展
// 如需支持多条折线,可扩展 data 为二维数组或引入 series 概念 @Prop series: LineDataItem[][] = []; @Prop colors: string[] = [AppColors.Budget, AppColors.Expense]; // 每条线独立 strokeStyle 和 beginPath九、运行验证
9.1 构建命令
hvigorw assembleHap--modemodule-pproduct=default9.2 验证清单
| 验证项 | 预期结果 |
|---|---|
| 切换到周趋势 Tab | 加载数据后展示折线图 |
| 数据少于 2 点 | 不绘制(空白画布) |
| 折线颜色 | 蓝色AppColors.Budget |
| 数据点 | 每个点 3px 实心圆 |
| 切换深色模式 | 折线颜色同步刷新 |
| 点击数据点 | 弹出该周收支明细 |
| 编译通过 | 无width/height冲突报错 |
十、常见问题
10.1 折线不显示
// 原因:数据少于 2 个点,draw 方法直接 return // 解决:确保至少传入 2 个数据点 if (this.data.length < 2) return; // ViewModel 聚合时保证 weeklyTrend 至少 2 项10.2 折线断裂
// 原因:beginPath 在循环内调用,导致每段线独立 // 解决:整个折线用一个 beginPath/stroke 包裹 ctx.beginPath(); for (let i = 0; i < this.data.length; i++) { i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); // 一次性描边整条线10.3 绘制模糊
// 原因:未处理设备像素比 dpr // 解决:在 onReady 中先 scale 适配高分屏 .onReady(() => { const dpr = display.getDefaultDisplaySync().densityPixels; this.ctx.scale(dpr, dpr); this.draw(); })10.4 数据点颜色与折线不一致
// 原因:数据点绘制时未重新设置 fillStyle // 解决:每段绘制前显式设置颜色 ctx.strokeStyle = this.lineColor; // 折线颜色 // ... stroke ... ctx.fillStyle = this.lineColor; // 数据点颜色(重新设置) // ... arc + fill ...十一、Git 提交
11.1 提交命令
gitadd.gitcommit-m"feat(图表): Canvas 绘制折线图 LineChart - 新增 LineDataItem 数据接口(显式导出,无 color 字段) - 封装 LineChart 通用折线图组件 - ViewModel 按周聚合 weeklyTrend - StatisticsView 新增周趋势 Tab - 图表系列三组件统一 chartWidth/chartHeight 命名"11.2 变更日志
## [v0.2.1] - 2026-07-27 ### Added - components/chart/LineChart.ets(LineDataItem 接口 + LineChart 组件) - StatisticsViewModel 新增 weeklyTrend 聚合逻辑 ### Changed - StatisticsView 新增周趋势 Tab - 图表系列三组件命名统一完成附录:运行效果截图
总结
本文完整介绍了Canvas 绘制折线图的全流程,涵盖LineDataItem数据接口设计(无color字段)、StatisticsViewModel按周聚合逻辑、LineChart组件封装、两阶段绘制算法(折线 + 数据点)、StatisticsView周趋势 Tab 集成,以及三个图表组件@Prop命名冲突的统一复盘。通过本篇你可以:
- 设计无
color字段的LineDataItem接口,颜色由@Prop lineColor统一控制 - 封装支持折线连接和数据点标记的
LineChart组件 - 使用 Canvas
moveTo/lineTo/stroke绘制折线,arc/fill绘制数据点 - 理解折线图与饼图、柱状图在颜色设计上的差异
- 完成图表系列三组件
chartWidth/chartHeight命名统一
图表系列完结:至此,CircleChart(饼图)、BarChart(柱状图)、LineChart(折线图)三大图表组件已全部封装完成,统计页支持分类占比、每日趋势、每周趋势三种可视化视图。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!在评论区告诉我你最想了解的鸿蒙开发话题,我会优先安排。
相关资源
- 本篇源码:GitHub Tag v0.2.1
- ArkUI Canvas 组件:canvas
- ArkUI CanvasRenderingContext2D:canvasrenderingcontext2d
- ArkUI Animation 动画:animation
- Canvas 绘制教程:canvas-tutorial
- ArkUI 触摸事件:touch-event
- ArkUI 图表组件实践:chart-component