鸿蒙原生应用从0到1记账本模块 —— 数据聚合与可视化实战系列第三篇深入「记账本」页面开发重点讲解数据汇总计算、分类统计、收支双模式录入等核心能力。一、功能概览记账本是一个数据密集型页面核心挑战在于如何高效地聚合原始记录数据并以清晰直观的方式呈现给用户。┌─────────────────────────────────┐ │ ← 返回 记账本 │ ├─────────────────────────────────┤ │ 本月汇总 │ │ ¥10,478.00 │ ← 结余收入-支出 │ 收入 ¥15,500 │ 支出 ¥5,022 │ ├─────────────────────────────────┤ │ 支出分类统计 │ │ ️ 餐饮 ████████ ¥160 │ ← 条形图比例 │ 交通 ████ ¥15 │ │ ️ 购物 █████████ ¥268 │ │ ... │ ├─────────────────────────────────┤ │ [全部] [支出] [收入] │ ← 筛选标签 ├─────────────────────────────────┤ │ ️ 餐饮 -¥32.00 │ ← 记录列表 │ 公司食堂午餐 2025-01-15 ️│ │ 交通 -¥15.00 │ │ 地铁通勤 2025-01-15 ️│ │ 工资 ¥15,000.00 │ │ 1月工资 2025-01-10 ️│ ├─────────────────────────────────┤ │ [ 记一笔] │ └─────────────────────────────────┘二、数据模型设计2.1 记录模型interfaceFinanceRecord{id:number;// 唯一标识type:string;// 类型收入 | 支出category:string;// 分类餐饮/交通/购物/工资等amount:number;// 金额note:string;// 备注date:string;// 日期}2.2 汇总模型interfaceFinanceSummary{income:number;// 总收入expense:number;// 总支出balance:number;// 结余}设计思路将汇总数据单独抽取为接口让数据聚合逻辑与 UI 解耦。getSummary()方法返回FinanceSummary对象UI 层只负责展示。三、数据聚合与计算3.1 月度汇总这是记账本最核心的计算逻辑getSummary():FinanceSummary{letincome:number0;letexpense:number0;if(this.records!undefined){for(leti:number0;ithis.records.length;i){constitem:FinanceRecordthis.records[i];if(item.type收入){incomeitem.amount;}else{expenseitem.amount;}}}return{income:income,expense:expense,balance:income-expense};}为什么不用reduceArkTS 严格模式下reduce的回调函数类型推断有时会遇到问题使用for循环更清晰可控。在实际项目中可以根据团队风格选择。3.2 分类统计支出分类的聚合分析// 计算某分类的支出占比返回百分比字符串getCategoryRatio(category:string):string{consttotalExpense:numberthis.records.filter((r:FinanceRecord)r.type支出).reduce((sum:number,r:FinanceRecord)sumr.amount,0);constcatExpense:numberthis.records.filter((r:FinanceRecord)r.type支出r.categorycategory).reduce((sum:number,r:FinanceRecord)sumr.amount,0);constratio:numbertotalExpense0?catExpense/totalExpense:0;return${Math.max(ratio*100,1)}%;// 最小 1% 保证条形可见}// 计算某分类的支出金额文本getCategoryExpenseText(category:string):string{constcatExpense:numberthis.records.filter((r:FinanceRecord)r.type支出r.categorycategory).reduce((sum:number,r:FinanceRecord)sumr.amount,0);return¥${catExpense};}关键细节Math.max(ratio * 100, 1)确保即使占比极小条形图也能显示一点颜色避免视觉空白金额格式化直接用模板字符串保持简单四、筛选与过滤4.1 筛选选项privatefilterOptions:string[][全部,支出,收入];4.2 过滤逻辑getFilteredRecords():FinanceRecord[]{if(this.recordsundefined){return[];}if(this.activeFilter全部){returnthis.records;}returnthis.records.filter((item:FinanceRecord)item.typethis.activeFilter);}4.3 筛选标签 UIRow(){ForEach(this.filterOptions,(filter:string){Text(filter).fontSize($r(app.float.small_font_size)).fontColor(this.activeFilterfilter?Color.White:$r(app.color.text_primary)).padding({left:20,right:20,top:6,bottom:6}).backgroundColor(this.activeFilterfilter?$r(app.color.primary):$r(app.color.bg_card)).borderRadius(14).onClick((){this.activeFilterfilter;})},(filter:string)filter)}.padding({left:20,right:20})UI 交互反馈选中的标签变为蓝色背景白色文字未选中的保持白色背景。五、分类管理设计5.1 支出与收入分类记账本的特色是支出和收入有不同的分类体系。privateexpenseCategories:string[][餐饮,交通,购物,娱乐,医疗,通讯,住房,其他];privateincomeCategories:string[][工资,兼职,投资,红包,退款,其他];5.2 动态切换分类列表getCurrentCategories():string[]{returnthis.newType支出?this.expenseCategories:this.incomeCategories;}新增记录时用户选择收入或支出下方的分类标签自动切换。5.3 图标与颜色映射// 每个分类对应一个 Emoji 图标getCategoryIcon(category:string):string{consticons:Recordstring,string{餐饮:️,交通:,购物:️,娱乐:,医疗:,通讯:,住房:,其他:,工资:,兼职:,投资:,红包:,退款:↩️};returnicons[category]||;}// 每个分类对应一种颜色getCategoryColor(category:string):string{constcolors:Recordstring,string{餐饮:#FF3B30,交通:#FF9500,购物:#AF52DE,娱乐:#5AC8FA,医疗:#34C759,通讯:#5B7FFF,住房:#FF9500,其他:#6B7280,工资:#34C759,兼职:#5AC8FA,投资:#FF9500,红包:#FF3B30,退款:#AF52DE};returncolors[category]||#6B7280;}映射表模式比switch-case更简洁易于维护和扩展。新增分类只需添加一条映射。六、新增记录功能6.1 表单校验addRecord():void{constamountNum:numberparseFloat(this.newAmount);if(isNaN(amountNum)||amountNum0){return;// 金额必须为正数}// ...}防御性校验parseFloat将字符串转为数字isNaN检查转换是否成功amountNum 0排除负数或零6.2 记录构建constnewId:numberthis.records.length0?this.records[this.records.length-1].id1:1;consttoday:DatenewDate();constdateStr:string${today.getFullYear()}-${today.getMonth()1}-${today.getDate()};constnewRecord:FinanceRecord{id:newId,type:this.newType,category:this.newCategory,amount:amountNum,note:this.newNote.trim(),date:dateStr};this.records.push(newRecord);this.newAmount;this.newNote;this.showAddDialogfalse;6.3 删除记录deleteRecord(id:number):void{this.recordsthis.records.filter((item:FinanceRecord)item.id!id);}七、UI 构建详解7.1 月度摘要卡片Column(){Text(本月汇总).fontSize($r(app.float.small_font_size)).fontColor($r(app.color.text_secondary))Text(¥${this.getSummary().balance.toFixed(2)}).fontSize(36).fontWeight(FontWeight.Bold).width(100%).textAlign(TextAlign.Center)Row(){Column(){Text(收入)Text(¥${this.getSummary().income.toFixed(2)}).fontColor(#34C759)// 绿色表示收入}Column(){Text(支出)Text(¥${this.getSummary().expense.toFixed(2)}).fontColor(#FF3B30)// 红色表示支出}}}设计细节结余金额用大号字体36fp突出显示收入用绿色#34C759支出用红色#FF3B30符合财务应用的色彩心理学使用toFixed(2)保留两位小数7.2 分类统计条形图这是最引人注目的功能——纯 ArkTS 实现的条形图无需引入第三方图表库BuildercategoryBar(category:string):void{Row(){Text(this.getCategoryIcon(category)).fontSize(16).width(28)Text(category).fontSize($r(app.float.tiny_font_size)).width(40)// 条形图Column(){Row(){Text().height(8).width(this.getCategoryRatio(category))// 动态宽度百分比.backgroundColor(this.getCategoryColor(category)).borderRadius(4)}.width(100%).backgroundColor(#F5F7FA).borderRadius(4)}.layoutWeight(1).margin({left:8,right:8})Text(this.getCategoryExpenseText(category)).width(60).textAlign(TextAlign.End)}.width(100%).margin({bottom:8})}实现原理条形图的宽度通过width(this.getCategoryRatio(category))动态设置getCategoryRatio返回百分比字符串如35%外层 Column 的layoutWeight(1)让条形占满剩余空间背景灰色#F5F7FA填充未达到的部分7.3 记录行BuilderrecordRow(record:FinanceRecord):void{Row(){Text(this.getCategoryIcon(record.category)).fontSize(22).width(40).height(40).textAlign(TextAlign.Center)Column(){Text(record.category).fontWeight(FontWeight.Medium)Text(record.note?record.note:record.date)// 备注优先无备注显示日期}.layoutWeight(1).margin({left:10})Column(){Text(${record.type收入?:-}¥${record.amount.toFixed(2)}).fontColor(record.type收入?#34C759:#FF3B30)Text(record.date)}.alignItems(HorizontalAlign.End)Text(️).fontSize(16).onClick((){this.deleteRecord(record.id);})}}金额显示的细节收入前加号绿色支出前加-号红色toFixed(2)保证统一的小数位数备注为空时显示日期作为替代文字7.4 新增弹窗// 顶部类型切换Row(){Text(支出).onClick((){this.newType支出;})Text(收入).onClick((){this.newType收入;})}// 动态分类选择Row(){ForEach(this.getCurrentCategories(),(cat:string){Text(cat).backgroundColor(this.newCategorycat?this.getCategoryColor(cat):#F5F7FA).onClick((){this.newCategorycat;})},(cat:string)cat)}// 金额输入TextInput({placeholder:输入金额,text:this.newAmount}).onChange((value:string){this.newAmountvalue;})// 备注输入TextInput({placeholder:备注可选,text:this.newNote}).onChange((value:string){this.newNotevalue;})切换类型时的体验优化当用户切换支出/收入时分类列表自动更新newCategory需要重置到新类型的第一个分类。这部分逻辑可以在newType的 setter 中处理。八、与 ToDo 页面的架构对比维度待办页面记账本页面数据结构简单 flat 数组数组 聚合计算筛选逻辑分类名匹配类型匹配支出/收入/全部核心计算计数统计金额汇总 比例计算视觉复杂度列表 标签条形图 摘要卡片 列表数据关联无多个分类间比例关系记账本的数据处理复杂度远超待办页面主要体现在**数据聚合Aggregation**上。如果你对数据处理感兴趣记账本是一个非常好的练习场景。九、数据一致性与性能9.1 实时计算 vs 缓存当前实现中getSummary()和getCategoryRatio()每次渲染都重新计算。对于几百条级别的数据这完全够用计算耗时 1ms。// 如果需要优化可以添加缓存privatecachedSummary:FinanceSummary|nullnull;privatedirty:booleantrue;getSummary():FinanceSummary{if(this.dirty||this.cachedSummarynull){this.cachedSummarythis.computeSummary();this.dirtyfalse;}returnthis.cachedSummary;}9.2 金额精度使用number存储金额显示时toFixed(2)。对于记账场景这种方式足够。金融级应用则需要使用定点数或分单位存储。十、本篇总结核心知识点知识点实战应用数据聚合for 循环汇总收入/支出动态比例计算分类占比条形图收支双分类不同分类体系动态切换金额格式化toFixed(2) ±符号条形图纯 ArkTS百分比宽度 背景填充映射表模式分类→图标/颜色的 Record 字典一条完整的记账流程用户点击 “ 记一笔” → 弹出弹窗选择支出 → 显示支出分类列表选择餐饮 → 输入金额 “32” → 输入备注 “午餐”点击保存 → 记录写入this.recordsUI 自动重新渲染 → 月度汇总更新 → 统计条形图更新 → 记录列表更新十一、下篇预告下一篇将开发**「备忘录」页面**这是功能最丰富的页面涵盖多分类筛选 关键词搜索笔记详情查看编辑已有笔记完整 CRUD 操作敬请期待
鸿蒙原生应用从0到1:记账本模块 —— 数据聚合与可视化实战
鸿蒙原生应用从0到1记账本模块 —— 数据聚合与可视化实战系列第三篇深入「记账本」页面开发重点讲解数据汇总计算、分类统计、收支双模式录入等核心能力。一、功能概览记账本是一个数据密集型页面核心挑战在于如何高效地聚合原始记录数据并以清晰直观的方式呈现给用户。┌─────────────────────────────────┐ │ ← 返回 记账本 │ ├─────────────────────────────────┤ │ 本月汇总 │ │ ¥10,478.00 │ ← 结余收入-支出 │ 收入 ¥15,500 │ 支出 ¥5,022 │ ├─────────────────────────────────┤ │ 支出分类统计 │ │ ️ 餐饮 ████████ ¥160 │ ← 条形图比例 │ 交通 ████ ¥15 │ │ ️ 购物 █████████ ¥268 │ │ ... │ ├─────────────────────────────────┤ │ [全部] [支出] [收入] │ ← 筛选标签 ├─────────────────────────────────┤ │ ️ 餐饮 -¥32.00 │ ← 记录列表 │ 公司食堂午餐 2025-01-15 ️│ │ 交通 -¥15.00 │ │ 地铁通勤 2025-01-15 ️│ │ 工资 ¥15,000.00 │ │ 1月工资 2025-01-10 ️│ ├─────────────────────────────────┤ │ [ 记一笔] │ └─────────────────────────────────┘二、数据模型设计2.1 记录模型interfaceFinanceRecord{id:number;// 唯一标识type:string;// 类型收入 | 支出category:string;// 分类餐饮/交通/购物/工资等amount:number;// 金额note:string;// 备注date:string;// 日期}2.2 汇总模型interfaceFinanceSummary{income:number;// 总收入expense:number;// 总支出balance:number;// 结余}设计思路将汇总数据单独抽取为接口让数据聚合逻辑与 UI 解耦。getSummary()方法返回FinanceSummary对象UI 层只负责展示。三、数据聚合与计算3.1 月度汇总这是记账本最核心的计算逻辑getSummary():FinanceSummary{letincome:number0;letexpense:number0;if(this.records!undefined){for(leti:number0;ithis.records.length;i){constitem:FinanceRecordthis.records[i];if(item.type收入){incomeitem.amount;}else{expenseitem.amount;}}}return{income:income,expense:expense,balance:income-expense};}为什么不用reduceArkTS 严格模式下reduce的回调函数类型推断有时会遇到问题使用for循环更清晰可控。在实际项目中可以根据团队风格选择。3.2 分类统计支出分类的聚合分析// 计算某分类的支出占比返回百分比字符串getCategoryRatio(category:string):string{consttotalExpense:numberthis.records.filter((r:FinanceRecord)r.type支出).reduce((sum:number,r:FinanceRecord)sumr.amount,0);constcatExpense:numberthis.records.filter((r:FinanceRecord)r.type支出r.categorycategory).reduce((sum:number,r:FinanceRecord)sumr.amount,0);constratio:numbertotalExpense0?catExpense/totalExpense:0;return${Math.max(ratio*100,1)}%;// 最小 1% 保证条形可见}// 计算某分类的支出金额文本getCategoryExpenseText(category:string):string{constcatExpense:numberthis.records.filter((r:FinanceRecord)r.type支出r.categorycategory).reduce((sum:number,r:FinanceRecord)sumr.amount,0);return¥${catExpense};}关键细节Math.max(ratio * 100, 1)确保即使占比极小条形图也能显示一点颜色避免视觉空白金额格式化直接用模板字符串保持简单四、筛选与过滤4.1 筛选选项privatefilterOptions:string[][全部,支出,收入];4.2 过滤逻辑getFilteredRecords():FinanceRecord[]{if(this.recordsundefined){return[];}if(this.activeFilter全部){returnthis.records;}returnthis.records.filter((item:FinanceRecord)item.typethis.activeFilter);}4.3 筛选标签 UIRow(){ForEach(this.filterOptions,(filter:string){Text(filter).fontSize($r(app.float.small_font_size)).fontColor(this.activeFilterfilter?Color.White:$r(app.color.text_primary)).padding({left:20,right:20,top:6,bottom:6}).backgroundColor(this.activeFilterfilter?$r(app.color.primary):$r(app.color.bg_card)).borderRadius(14).onClick((){this.activeFilterfilter;})},(filter:string)filter)}.padding({left:20,right:20})UI 交互反馈选中的标签变为蓝色背景白色文字未选中的保持白色背景。五、分类管理设计5.1 支出与收入分类记账本的特色是支出和收入有不同的分类体系。privateexpenseCategories:string[][餐饮,交通,购物,娱乐,医疗,通讯,住房,其他];privateincomeCategories:string[][工资,兼职,投资,红包,退款,其他];5.2 动态切换分类列表getCurrentCategories():string[]{returnthis.newType支出?this.expenseCategories:this.incomeCategories;}新增记录时用户选择收入或支出下方的分类标签自动切换。5.3 图标与颜色映射// 每个分类对应一个 Emoji 图标getCategoryIcon(category:string):string{consticons:Recordstring,string{餐饮:️,交通:,购物:️,娱乐:,医疗:,通讯:,住房:,其他:,工资:,兼职:,投资:,红包:,退款:↩️};returnicons[category]||;}// 每个分类对应一种颜色getCategoryColor(category:string):string{constcolors:Recordstring,string{餐饮:#FF3B30,交通:#FF9500,购物:#AF52DE,娱乐:#5AC8FA,医疗:#34C759,通讯:#5B7FFF,住房:#FF9500,其他:#6B7280,工资:#34C759,兼职:#5AC8FA,投资:#FF9500,红包:#FF3B30,退款:#AF52DE};returncolors[category]||#6B7280;}映射表模式比switch-case更简洁易于维护和扩展。新增分类只需添加一条映射。六、新增记录功能6.1 表单校验addRecord():void{constamountNum:numberparseFloat(this.newAmount);if(isNaN(amountNum)||amountNum0){return;// 金额必须为正数}// ...}防御性校验parseFloat将字符串转为数字isNaN检查转换是否成功amountNum 0排除负数或零6.2 记录构建constnewId:numberthis.records.length0?this.records[this.records.length-1].id1:1;consttoday:DatenewDate();constdateStr:string${today.getFullYear()}-${today.getMonth()1}-${today.getDate()};constnewRecord:FinanceRecord{id:newId,type:this.newType,category:this.newCategory,amount:amountNum,note:this.newNote.trim(),date:dateStr};this.records.push(newRecord);this.newAmount;this.newNote;this.showAddDialogfalse;6.3 删除记录deleteRecord(id:number):void{this.recordsthis.records.filter((item:FinanceRecord)item.id!id);}七、UI 构建详解7.1 月度摘要卡片Column(){Text(本月汇总).fontSize($r(app.float.small_font_size)).fontColor($r(app.color.text_secondary))Text(¥${this.getSummary().balance.toFixed(2)}).fontSize(36).fontWeight(FontWeight.Bold).width(100%).textAlign(TextAlign.Center)Row(){Column(){Text(收入)Text(¥${this.getSummary().income.toFixed(2)}).fontColor(#34C759)// 绿色表示收入}Column(){Text(支出)Text(¥${this.getSummary().expense.toFixed(2)}).fontColor(#FF3B30)// 红色表示支出}}}设计细节结余金额用大号字体36fp突出显示收入用绿色#34C759支出用红色#FF3B30符合财务应用的色彩心理学使用toFixed(2)保留两位小数7.2 分类统计条形图这是最引人注目的功能——纯 ArkTS 实现的条形图无需引入第三方图表库BuildercategoryBar(category:string):void{Row(){Text(this.getCategoryIcon(category)).fontSize(16).width(28)Text(category).fontSize($r(app.float.tiny_font_size)).width(40)// 条形图Column(){Row(){Text().height(8).width(this.getCategoryRatio(category))// 动态宽度百分比.backgroundColor(this.getCategoryColor(category)).borderRadius(4)}.width(100%).backgroundColor(#F5F7FA).borderRadius(4)}.layoutWeight(1).margin({left:8,right:8})Text(this.getCategoryExpenseText(category)).width(60).textAlign(TextAlign.End)}.width(100%).margin({bottom:8})}实现原理条形图的宽度通过width(this.getCategoryRatio(category))动态设置getCategoryRatio返回百分比字符串如35%外层 Column 的layoutWeight(1)让条形占满剩余空间背景灰色#F5F7FA填充未达到的部分7.3 记录行BuilderrecordRow(record:FinanceRecord):void{Row(){Text(this.getCategoryIcon(record.category)).fontSize(22).width(40).height(40).textAlign(TextAlign.Center)Column(){Text(record.category).fontWeight(FontWeight.Medium)Text(record.note?record.note:record.date)// 备注优先无备注显示日期}.layoutWeight(1).margin({left:10})Column(){Text(${record.type收入?:-}¥${record.amount.toFixed(2)}).fontColor(record.type收入?#34C759:#FF3B30)Text(record.date)}.alignItems(HorizontalAlign.End)Text(️).fontSize(16).onClick((){this.deleteRecord(record.id);})}}金额显示的细节收入前加号绿色支出前加-号红色toFixed(2)保证统一的小数位数备注为空时显示日期作为替代文字7.4 新增弹窗// 顶部类型切换Row(){Text(支出).onClick((){this.newType支出;})Text(收入).onClick((){this.newType收入;})}// 动态分类选择Row(){ForEach(this.getCurrentCategories(),(cat:string){Text(cat).backgroundColor(this.newCategorycat?this.getCategoryColor(cat):#F5F7FA).onClick((){this.newCategorycat;})},(cat:string)cat)}// 金额输入TextInput({placeholder:输入金额,text:this.newAmount}).onChange((value:string){this.newAmountvalue;})// 备注输入TextInput({placeholder:备注可选,text:this.newNote}).onChange((value:string){this.newNotevalue;})切换类型时的体验优化当用户切换支出/收入时分类列表自动更新newCategory需要重置到新类型的第一个分类。这部分逻辑可以在newType的 setter 中处理。八、与 ToDo 页面的架构对比维度待办页面记账本页面数据结构简单 flat 数组数组 聚合计算筛选逻辑分类名匹配类型匹配支出/收入/全部核心计算计数统计金额汇总 比例计算视觉复杂度列表 标签条形图 摘要卡片 列表数据关联无多个分类间比例关系记账本的数据处理复杂度远超待办页面主要体现在**数据聚合Aggregation**上。如果你对数据处理感兴趣记账本是一个非常好的练习场景。九、数据一致性与性能9.1 实时计算 vs 缓存当前实现中getSummary()和getCategoryRatio()每次渲染都重新计算。对于几百条级别的数据这完全够用计算耗时 1ms。// 如果需要优化可以添加缓存privatecachedSummary:FinanceSummary|nullnull;privatedirty:booleantrue;getSummary():FinanceSummary{if(this.dirty||this.cachedSummarynull){this.cachedSummarythis.computeSummary();this.dirtyfalse;}returnthis.cachedSummary;}9.2 金额精度使用number存储金额显示时toFixed(2)。对于记账场景这种方式足够。金融级应用则需要使用定点数或分单位存储。十、本篇总结核心知识点知识点实战应用数据聚合for 循环汇总收入/支出动态比例计算分类占比条形图收支双分类不同分类体系动态切换金额格式化toFixed(2) ±符号条形图纯 ArkTS百分比宽度 背景填充映射表模式分类→图标/颜色的 Record 字典一条完整的记账流程用户点击 “ 记一笔” → 弹出弹窗选择支出 → 显示支出分类列表选择餐饮 → 输入金额 “32” → 输入备注 “午餐”点击保存 → 记录写入this.recordsUI 自动重新渲染 → 月度汇总更新 → 统计条形图更新 → 记录列表更新十一、下篇预告下一篇将开发**「备忘录」页面**这是功能最丰富的页面涵盖多分类筛选 关键词搜索笔记详情查看编辑已有笔记完整 CRUD 操作敬请期待