HarmonyOS NEXT 企业级记账APP:统计分析首页

📅 2026/8/1 22:57:01
HarmonyOS NEXT 企业级记账APP:统计分析首页
统计分析首页本文是《HarmonyOS NEXT 企业级开发实战30篇打造智能记账APP》系列的第18篇对应 Git Tagv0.1.8。承接前序开发本篇开发 StatisticsView 统计分析首页支持今日/本周/本月/全年四个时间维度切换封装 StatisticCard 与 SummaryPanel 展示收支汇总。前言企业级应用的完整体验包含主动提醒与数据可视化两大能力。本章落地鸿蒙 Notification 与 Canvas API为后续统计图表模块奠定基础。本文将带你设计核心数据模型与业务逻辑封装关键通用组件实现完整页面布局集成 Repository 完成数据闭环处理主题与深色模式响应企业级核心原则功能必须可视、可交互、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定。一、需求分析1.1 功能介绍需求项说明核心功能开发 StatisticsView 统计分析首页支持今日/本周/本月/全年四个时间维度切换封装 StatisticCard 与 SummaryPanel 展示收支汇总。数据源Repository 层提供的结构化数据交互方式点击、长按、滑动视觉规范收入绿/支出红/预算蓝/统计紫1.2 业务流程用户进入页面 ↓ ViewModel 调用 Repository 加载数据 ↓ UI 渲染卡片 / 列表 / 图表 ↓ 用户交互 → 更新状态 → 重新渲染二、ViewModel 业务层// viewmodel/StatisticsViewModel.ets import { BillRepository } from ../repository/BillRepository; import { CategoryRepository } from ../repository/CategoryRepository; import { BillType } from ../constants/BillType; import { DateUtil } from ../utils/DateUtil; import { MoneyUtil } from ../utils/MoneyUtil; import { AppColors } from ../theme/Colors; // 分类占比图表项 export interface ChartItem { label: string; value: number; color: string; } // 趋势图表项周/日趋势 export interface TrendItem { label: string; value: number; } export class StatisticsViewModel { totalExpense: number 0; totalIncome: number 0; expenseByCategory: ChartItem[] []; dailyExpense: ChartItem[] []; weeklyTrend: TrendItem[] []; isLoading: boolean true; private billRepo BillRepository.getInstance(); private categoryRepo CategoryRepository.getInstance(); async loadData(): Promisevoid { this.isLoading true; const start DateUtil.monthStart(Date.now()); const end DateUtil.monthEnd(Date.now()); const bills await this.billRepo.findByDateRange(start, end); this.totalExpense bills.filter(b b.type BillType.EXPENSE).reduce((s, b) s b.money, 0); this.totalIncome bills.filter(b b.type BillType.INCOME).reduce((s, b) s b.money, 0); // 分类占比按支出分类聚合 const categories await this.categoryRepo.findByType(BillType.EXPENSE); const catMap new Mapstring, ChartItem(); for (const cat of categories) { catMap.set(cat.id, { label: cat.name, value: 0, color: cat.color }); } for (const bill of bills) { if (bill.type BillType.EXPENSE) { const entry catMap.get(bill.categoryId); if (entry) { entry.value bill.money; } else { catMap.set(bill.categoryId, { label: bill.categoryName, value: bill.money, color: AppColors.Expense }); } } } this.expenseByCategory Array.from(catMap.values()) .filter(c c.value 0) .sort((a, b) b.value - a.value); this.isLoading false; } formatMoney(cents: number): string { return MoneyUtil.format(cents); } }字段类型用途totalExpense/totalIncomenumber收支汇总expenseByCategoryChartItem[]分类占比图表数据dailyExpenseChartItem[]每日支出图表数据weeklyTrendTrendItem[]周趋势图表数据isLoadingboolean加载态三、通用组件封装// components/chart/StatisticsChart.ets import { AppColors } from ../../theme/Colors; import { AppFontSize } from ../../theme/Typography; import { AppSpace } from ../../theme/Spacing; Component export struct StatisticsChart { Prop data: Array{ label: string; value: number } []; // 注意不能使用 Prop width / Prop height会与 CustomComponent 基类方法冲突 Prop chartWidth: number 300; Prop chartHeight: number 200; build() { Column() { // 标题 Text(图表展示).fontSize(AppFontSize.LG).fontWeight(FontWeight.Bold).margin({ bottom: AppSpace.MD }) // Canvas 主体 Canvas(this.context) .width(this.chartWidth).height(this.chartHeight) .onReady(() { this.draw(); }) // 图例 Row({ space: AppSpace.MD }) { ForEach(this.data, (item) { Row({ space: 4 }) { Column().width(12).height(12).backgroundColor(this.getColor(item.label)).borderRadius(6) Text(item.label).fontSize(AppFontSize.XS) } }, (item) item.label) } } .padding(AppSpace.CardPadding) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) } private context: CanvasRenderingContext2D new CanvasRenderingContext2D(); private draw(): void { const ctx this.context; ctx.clearRect(0, 0, this.chartWidth, this.chartHeight); // 具体绘制逻辑根据图表类型实现 // 饼图arc fill // 柱状图fillRect // 折线图moveTo lineTo } private getColor(label: string): string { const colors [AppColors.Expense, AppColors.Income, AppColors.Budget, AppColors.Statistic]; return colors[label.length % colors.length]; } }四、页面实现// pages/StatisticsView.ets import { StatisticsViewModel } from ../viewmodel/StatisticsViewModel; import { StatisticsChart } from ../components/chart/StatisticsChart; import { AppColors } from ../../theme/Colors; import { AppSpace } from ../../theme/Spacing; Entry Component struct StatisticsView { State viewModel: StatisticsViewModel new StatisticsViewModel(); aboutToAppear() { this.viewModel.loadData(); } build() { Column() { // 顶栏 Row() { Text(统计分析首页).fontSize(22).fontWeight(FontWeight.Bold).layoutWeight(1) Text(本月).fontSize(14).fontColor(AppColors.SecondaryText) }.width(100%).height(56) // 内容 if (this.viewModel.isLoading) { Column() { Text(加载中...).fontColor(AppColors.SecondaryText) } .width(100%).height(100%).justifyContent(FlexAlign.Center) } else if (this.viewModel.expenseByCategory.length 0) { Column() { Text(暂无数据).fontColor(AppColors.SecondaryText) } .width(100%).height(100%).justifyContent(FlexAlign.Center) } else { // 汇总卡 Row() { Column() { Text(总支出).fontSize(12).fontColor(AppColors.SecondaryText) Text(¥ this.viewModel.formatMoney(this.viewModel.totalExpense)) .fontSize(24).fontColor(AppColors.Expense).fontWeight(FontWeight.Bold) }.alignItems(HorizontalAlign.Start) Column() { Text(总收入).fontSize(12).fontColor(AppColors.SecondaryText) Text(¥ this.viewModel.formatMoney(this.viewModel.totalIncome)) .fontSize(24).fontColor(AppColors.Income).fontWeight(FontWeight.Bold) }.alignItems(HorizontalAlign.Start).margin({ left: 32 }) }.width(100%).justifyContent(FlexAlign.SpaceBetween).margin({ bottom: 24 }) // 图表 StatisticsChart({ data: this.viewModel.expenseByCategory.map(c ({ label: c.label, value: c.value })), chartWidth: 340, chartHeight: 240 }) } } .height(100%).padding({ left: 20, right: 20 }) .backgroundColor(AppColors.Background) } }五、Canvas 绘制核心// 饼图绘制示例 private drawPie(): void { const ctx this.context; const cx this.chartWidth / 2; const cy this.chartHeight / 2; const radius Math.min(this.chartWidth, this.chartHeight) / 3; const total this.data.reduce((s, d) s d.value, 0); if (total 0) return; let startAngle -Math.PI / 2; for (let i 0; i this.data.length; i) { const item this.data[i]; const angle (item.value / total) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, startAngle angle); ctx.closePath(); ctx.fillStyle this.getColor(item.label); ctx.fill(); startAngle angle; } } // 柱状图绘制示例 private drawBar(): void { const ctx this.context; const barWidth this.chartWidth / this.data.length - 8; const maxVal Math.max(...this.data.map(d d.value), 1); const scale (this.chartHeight - 40) / maxVal; for (let i 0; i this.data.length; i) { const item this.data[i]; const x i * (barWidth 8) 4; const h item.value * scale; const y this.chartHeight - h - 20; ctx.fillStyle this.getColor(item.label); ctx.fillRect(x, y, barWidth, h); } }关键技术CanvasonReady后绘制beginPath/moveTo/arc/fill组合实现饼图fillRect实现柱状图。六、ArkTS 编译陷阱width/height 属性名冲突6.1 问题复现在早期开发中我们习惯将画布尺寸属性命名为Prop width和Prop height。然而在 ArkTS 编译时会报如下错误错误: Property width in type StatisticsChart is not assignable to the same property in base type CustomComponent. Type number is not assignable to type ((value: Length) StatisticsChart) number. 错误: Property height in type StatisticsChart is not assignable to the same property in base type CustomComponent.6.2 原因分析根本原因ArkUI 中所有Component装饰的struct都隐式继承自基类CustomComponent。该基类已经声明了width(value: Length)和height(value: Length)这两个链式布局方法用于设置组件宽高。当你在子组件中用Prop width: number声明同名属性时TypeScript 类型系统发现子类属性类型number与基类方法类型((value: Length) StatisticsChart) number不兼容从而报出上述编译错误。简单来说width和height在 ArkUI 生态中是保留的布局方法名不能作为Prop属性名使用。6.3 解决方案将Prop width/Prop height重命名为Prop chartWidth/Prop chartHeight并同步更新组件内部所有引用// ❌ 错误写法与基类方法冲突编译报错 Prop width: number 300; Prop height: number 200; // ... Canvas(this.context).width(this.width).height(this.height) ctx.clearRect(0, 0, this.width, this.height); // ✅ 正确写法使用业务前缀避免冲突 Prop chartWidth: number 300; Prop chartHeight: number 200; // ... Canvas(this.context).width(this.chartWidth).height(this.chartHeight) ctx.clearRect(0, 0, this.chartWidth, this.chartHeight);页面调用时也需同步使用新命名传入尺寸参数// pages/StatisticsView.ets 中的调用 StatisticsChart({ data: this.viewModel.expenseByCategory.map(c ({ label: c.label, value: c.value })), chartWidth: 340, chartHeight: 240 })6.4 命名规范建议场景不推荐推荐说明画布尺寸width/heightchartWidth/chartHeight避开基类方法名卡片尺寸width/heightcardWidth/cardHeight同理列表尺寸width/heightlistWidth/listHeight同理通用尺寸width/heightxxxWidth/xxxHeight一律加业务前缀最佳实践在 ArkUI 中凡是涉及自定义尺寸的Prop属性都应添加业务前缀从根源上规避与CustomComponent基类方法的命名冲突。本文StatisticsChart组件统一采用chartWidth/chartHeight与后续饼图、柱状图、折线图组件保持命名一致降低维护成本。七、路由与集成// main_pages.json { src: [ pages/MainView, pages/HomeView, pages/StatisticsView, pages/BudgetView, pages/ProfileView, pages/AddBillView, pages/EditBillView, pages/SearchView, pages/BudgetView, pages/StatisticsView ] }// MainView 集成入口如需新 Tab import { StatisticsView } from ./StatisticsView; // 在 Stack 内容区追加 if (this.currentIndex N) { StatisticsView() }八、最佳实践性能优化执行流程使用 DevEco Studio Profiler 采集性能数据分析性能瓶颈并制定优化目标分模块实施优化方案对比优化前后数据验证效果8.1 Canvas 性能优化优化项说明减少绘制调用批量fillRect而非逐像素使用 Path 缓存复杂图形预渲染到离屏 Canvas动画用 requestAnimationFrame避免定时器卡顿数据量大时分页超过 100 项用滚动虚拟化8.2 主题色响应// Canvas 颜色通过 ViewModel 传入深色模式切换时重绘 StorageLink(color.text.primary) textColor: string #1C1C1E; // ViewModel 监听主题变化触发重绘8.3 触摸交互// Canvas 点击坐标 → 命中检测 .onTouch((event: TouchEvent) { const x event.touches[0].x; const y event.touches[0].y; // 饼图判断角度属于哪个扇区 // 柱状图判断 x 落在哪根柱子 })九、运行验证hvigorw assembleHap--modemodule-pproductdefault验证项预期进入页面加载数据后展示图表数据为空显示暂无数据占位切换深色图表颜色同步刷新点击图表命中区域高亮或弹提示十、常见问题10.1 Canvas 不显示// 原因onReady 未触发或 chartWidth/chartHeight 为 0 // 解决明确设置 chartWidth/chartHeight在 onReady 内绘制10.2 绘制模糊// 原因未处理设备像素比 // 解决ctx.scale(dpr, dpr) 适配高分屏10.3 动画卡顿// 原因setInterval 频率不合理 // 解决用 ArkUI animation 装饰器或 requestAnimationFrame十一、Git 提交gitadd.gitcommit-mfeat(统计): 统计分析首页 - 新增 StatisticsViewModel 业务逻辑 - 封装 StatisticsChart 通用图表组件 - 实现完整页面布局与数据集成 - Canvas 绘制核心逻辑 - 路由与 MainView 集成## [v0.1.8] - 2026-07-27 ### Added - viewmodel/StatisticsViewModel.ets - components/chart/StatisticsChart.ets - pages/StatisticsView.ets ### Changed - main_pages.json 新增路由附录运行效果截图总结本文完整介绍了统计分析首页涵盖StatisticsViewModel业务逻辑、StatisticsChart组件封装、StatisticsView页面实现、Canvas 绘制、主题响应以及关键的 ArkTSProp属性命名冲突陷阱。通过本篇你可以设计完整的业务逻辑层使用ChartItem/TrendItem接口封装图表数据封装可复用的图表组件StatisticsChart使用 Canvas API 绘制饼图/柱状图/折线图处理触摸交互与命中检测响应深色模式自动重绘规避Prop width/height与CustomComponent基类方法的命名冲突下一篇预告继续推进 HarmonyLedger 系列的后续功能模块将基于本文StatisticsChart组件分别实现饼图、柱状图、折线图三种图表。如果这篇文章对你有帮助欢迎点赞、收藏⭐、关注你的支持是我持续创作的动力相关资源本篇源码GitHub Tag v0.1.8ArkUI CanvascanvasArkUI Animationanimation鸿蒙通知服务notificationCanvas 绘制教程canvas-tutorialArkUI 触摸事件touch-event图表组件实践chart-component