行业资讯
HarmonyOS NEXT 企业级记账APP:Canvas 绘制折线图
想·# Canvas 绘制折线图本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第21篇对应 Git Tagv0.2.1。承接前篇饼图与柱状图本篇使用 Canvas API 绘制折线图展示收支趋势曲线封装LineChart组件支持折线连接、数据点标记、可配置线条颜色。作为图表系列的收尾篇统一复盘 ArkTSProp属性命名冲突陷阱的全局解决方案。前言饼图展示分类占比柱状图展示离散趋势而折线图最适合展示连续趋势。记账 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、lineColor1.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 三个数据接口对比接口名字段颜色来源适用组件PieDataItemlabel/value/color数据项自带CircleChartBarDataItemlabel/value/color数据项自带BarChartLineDataItemlabel/valueProp 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(): 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.aggregateWeeklyTrend(start, end); this.isLoading false; } private aggregateWeeklyTrend(start: number, end: number): void { const weekMs 7 * 24 * 60 * 60 * 1000; const map new Mapstring, 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 组件属性一览属性装饰器类型默认值说明dataPropLineDataItem[][]折线图数据源chartWidthPropnumber300画布宽度chartHeightPropnumber200画布高度lineColorPropstringAppColors.Budget折线及数据点颜色ctx普通成员CanvasRenderingContext2Dnew绘图上下文4.3 两阶段绘制流程折线图绘制分为折线连接和数据点标记两个阶段clearRect清空画布数据少于 2 个点时直接返回至少 2 点才能连线计算maxVal和stepX水平步长第一阶段设置strokeStyle和lineWidthbeginPath后逐点moveTo/lineTo最后stroke第二阶段逐点arcfill绘制半径 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 三组件统一命名复盘组件错误属性名正确属性名额外属性状态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 APIAPI作用使用场景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-pproductdefault9.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-mfeat(图表): Canvas 绘制折线图 LineChart - 新增 LineDataItem 数据接口显式导出无 color 字段 - 封装 LineChart 通用折线图组件 - ViewModel 按周聚合 weeklyTrend - StatisticsView 新增周趋势 Tab - 图表系列三组件统一 chartWidth/chartHeight 命名11.2 变更日志## [v0.2.1] - 2026-07-27 ### Added - components/chart/LineChart.etsLineDataItem 接口 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.1ArkUI Canvas 组件canvasArkUI CanvasRenderingContext2Dcanvasrenderingcontext2dArkUI Animation 动画animationCanvas 绘制教程canvas-tutorialArkUI 触摸事件touch-eventArkUI 图表组件实践chart-component
郑州网站建设
网页设计
企业官网