当前位置: 首页 > news >正文

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
http://www.jsqmd.com/news/1311205/

相关文章:

  • 从混沌到秩序:构建海量非结构化数据智能处理平台
  • PyTorch模型在NPU上训练:从环境搭建到性能调优实战指南
  • 好书推荐 ▏儿童诗集《醉享爱的光阴》 金融作家虹笙 著
  • 数字孪生软件选型实战指南:12款工具深度解析与避坑路线图
  • 贾子新体系战略落地问题与免疫机制总论 |Strategic Implementation Problems and Immune Mechanisms of the Kucius New System
  • 实操手册-OpenClaw备用模型机制
  • LeetCode 0486.预测赢家:深度优先搜索(DFS)
  • 非标LCD屏驱动实战:从HDMI/Type-C接口到RK3588系统集成
  • 智选波段主 同花顺期货通指标
  • 2026年万能断路器回收厂家怎么选?重庆本地正规回收企业推荐指南 - 优质品牌商家
  • Hive数组高阶应用:从建模到性能优化的实战指南
  • 数学奇点全解析:从函数失效到系统平衡,理解技术中的临界点
  • 去AI痕迹怎么操作?2026年论文AI率从70%降到8%的实测记录
  • classpath到底是干嘛的
  • 期货分割趋势 同花顺期货通指标
  • 工业树莓派reComputer R20xx eMMC系统刷写全攻略:从原理到实践
  • 贾子理论新学术体系:可持续运营、反垄断与民间求真者组织的三大免疫机制
  • 数组传参、指针函数、函数指针
  • 大数据转大模型:算法是入场券,权限日志才是护城河
  • Node.js Excel读写全攻略:从SheetJS/xlsx入门到实战应用
  • 2026 年怀化可靠的市政管道公司有哪些,楼下那根看不见的管子,竟藏着关乎你家钱包的大秘密?-禹顺管道 - 实业推荐官【官方】
  • 华为MetaERP 在 Fusion 里“用 AutoAccounting 派生项目利润中心“这个说法需要稍微修正一下:Fusion PPM(项目组合管理)的会计分录不再走传统的 AutoAccoun
  • LSTM时间序列预测中滑动窗口的陷阱与最佳实践
  • 桓台宾馆(中心大街县政府店)的6个产品特色体验分享
  • 世界模型让生命科学即将进入“可计算演化”时代
  • Wand-Enhancer终极指南:3步免费解锁WeMod无限游戏时间与专业功能
  • ESP32S3文件系统实战:LittleFS与FATFS选型、集成与避坑指南
  • 【单片机毕设案例分享】基于 STM32 的 IC 卡车辆出入计时收费终端设计 嵌入式 RFID 刷卡智能停车闸道管控系统开发(016501)
  • Redis在CAP定理下的真实定位:从AP倾向到CP权衡的实战解析
  • 【翼型】基于matlab风洞压力数据自动处理计算气动系数(Cp、Cl、Cd、Cm)(生成与XFIL和薄翼型理论的对比可视化)【含Matlab源码 15912期】含报告