HarmonyOS NEXT 企业级记账APP:Canvas 绘制折线图

📅 2026/8/2 4:39:40 👁️ 阅读次数 📝 编程学习
HarmonyOS NEXT 企业级记账APP:Canvas 绘制折线图

想·# Canvas 绘制折线图

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第21篇,对应 Git Tagv0.2.1。承接前篇饼图与柱状图,本篇使用 Canvas API 绘制折线图展示收支趋势曲线,封装LineChart组件支持折线连接、数据点标记、可配置线条颜色。作为图表系列的收尾篇,统一复盘 ArkTS@Prop属性命名冲突陷阱的全局解决方案。

前言

饼图展示分类占比,柱状图展示离散趋势,而折线图最适合展示连续趋势。记账 APP 的统计页需要回答"最近几周收支如何变化",折线图能直观呈现波动规律。本篇是 Canvas 图表系列三连击的收官之作,前两篇封装了CircleChartBarChart,本篇将封装LineChart,完成统计页三大图表体系。本文将带你:

  1. 设计LineDataItem数据接口与 ViewModel 周维度聚合逻辑
  2. 封装LineChart通用折线图组件
  3. 实现折线连接与数据点标记的绘制算法
  4. 理解lineColor属性与LineDataItemcolor字段的设计差异
  5. 统一复盘三个图表组件的@Prop命名冲突解决方案

企业级核心原则:图表组件体系必须接口统一、命名规范、可组合。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI Canvas 组件 掌握绘制 API。


一、需求分析

1.1 功能介绍

需求项说明
核心功能使用 Canvas API 绘制折线图展示收支趋势,封装LineChart组件
数据源ViewModel 按周聚合生成LineDataItem[]
交互方式点击数据点查看该周明细
视觉规范折线使用AppColors.Budget蓝色,数据点半径 3px
组件属性chartWidthchartHeightdatalineColor

1.2 业务流程

用户进入统计页 → 切换"趋势"Tab → 选择"周维度" ↓ StatisticsViewModel 按周聚合本月收支 ↓ 生成 weeklyTrend: LineDataItem[] ↓ LineChart 组件接收 data 并在 onReady 中绘制 ↓ 用户点击数据点 → 命中检测 → 弹出该周明细

二、数据接口设计

2.1 LineDataItem 接口定义

PieDataItemBarDataItem不同,LineDataItem不携带color字段。折线图的颜色是整条线统一的,通过@Prop lineColor传入,而非每个数据点独立着色。这是折线图与饼图、柱状图的核心设计差异。

// components/chart/LineChart.ets export interface LineDataItem { label: string; value: number; }

2.2 三个数据接口对比

接口名字段颜色来源适用组件
PieDataItemlabel/value/color数据项自带CircleChart
BarDataItemlabel/value/color数据项自带BarChart
LineDataItemlabel/value@Prop lineColorLineChart

设计要点:饼图每个扇区颜色不同,柱图每根柱子可独立着色,因此颜色随数据项走。折线图整条线颜色统一,颜色作为组件属性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 字段说明

字段类型用途
billsBill[]原始账单列表
totalExpensenumber本月支出汇总(分)
weeklyTrendLineDataItem[]每周收支净额趋势数据
isLoadingboolean加载态标志

四、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@PropLineDataItem[][]折线图数据源
chartWidth@Propnumber300画布宽度
chartHeight@Propnumber200画布高度
lineColor@PropstringAppColors.Budget折线及数据点颜色
ctx普通成员CanvasRenderingContext2Dnew绘图上下文

4.3 两阶段绘制流程

折线图绘制分为折线连接和数据点标记两个阶段:

  1. clearRect清空画布
  2. 数据少于 2 个点时直接返回(至少 2 点才能连线)
  3. 计算maxValstepX(水平步长)
  4. 第一阶段:设置strokeStylelineWidthbeginPath后逐点moveTo/lineTo,最后stroke
  5. 第二阶段:逐点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 类型检查报错。

widthheight在 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 三组件统一命名复盘

组件错误属性名正确属性名额外属性状态
CircleChartwidth/heightchartWidth/chartHeight已修复
BarChartwidth/heightchartWidth/chartHeight已修复
LineChartwidth/heightchartWidth/chartHeightlineColor已修复

复盘总结:三个图表组件统一使用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 折线图性能优化

性能优化执行流程:

  1. 使用 DevEco Studio Profiler 采集绘制帧率和内存占用
  2. 分析瓶颈:数据点过多导致lineTo调用频繁
  3. 实施数据降采样:超过 50 个点时按等间隔采样
  4. 对比优化前后帧率和内存数据验证效果
优化项说明收益
数据降采样超过 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=default

9.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组件
  • 使用 CanvasmoveTo/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