最佳实践 - 基于鸿蒙生态的轻量化记账工具开发:融合 ArkUI 组件与分布式数据管理
最佳实践 - 基于鸿蒙生态的轻量化记账工具开发:融合 ArkUI 组件与分布式数据管理
最佳实践 - 基于鸿蒙生态的轻量化记账工具开发:融合 ArkUI 组件与分布式数据管理
前言
本文通过 “易记账” 鸿蒙应用实例开发过程中的关键技术场景:entry 模块构建从启动到业务交互的核心链路,借助 common 模块实现跨页面代码复用,利用 ArkUI 组件快速搭建账单录入与统计界面,以及 DatePickerDialog 在不同业务场景下的适配使用,从开发视角还原鸿蒙技术在实际项目中的落地过程,展现鸿蒙生态的实践价值与发展潜力。
项目简介

AppScope 存放应用级全局资源与配置,确保全应用样式、常量统一;common 集中管理多模块复用的通用代码、组件与工具类,提升开发效率;entry 作为应用入口模块,承载主界面与核心记账业务逻辑,是用户交互的核心;oh_modules 存储项目依赖的鸿蒙相关模块,为功能实现提供基础支持;screenshots 用于归档应用界面截图,方便项目文档说明使用
鸿蒙技术实践:易记账
1、entry目录结构:components 放可复用的 UI 组件(如账单列表、账单预览组件); data 存数据相关定义(如账单类型、默认模板);entryability 是应用启动与生命周期管理的入口;pages 包含所有业务页面(如新增账单、账单详情、首页等)
模块分层与启动管理:entryability 串联应用生命周期
1、entryability:易记账启动核心,负责 APP 启动时初始化全局上下文、数据库和设置工具,指定打开首页 pages/Index` 首页,并监控 APP 从启动到关闭的全生命周期状态,衔接底层能力和用户界面的关键
import UIAbility from '@ohos.app.ability.UIAbility'; import hilog from '@ohos.hilog'; import window from '@ohos.window'; import {SettingManager, DBManager} from '@ohos/common'; import Want from '@ohos.app.ability.Want'; import AbilityConstant from '@ohos.app.ability.AbilityConstant'; export default class EntryAbility extends UIAbility { onCreate(want:Want, launchParam:AbilityConstant.LaunchParam) { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); globalThis.context = this.context; globalThis.__settingManager__ = new SettingManager(this.context); globalThis.__dbManager__ = new DBManager(this.context); } onDestroy() { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage) { // Main window is created, set main page for this ability hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err, data) => { if (err.code) { hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); return; } hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? ''); }); } onWindowStageDestroy() { // Main window is destroyed, release UI related resources hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground() { // Ability has brought to foreground hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground() { // Ability has back to background hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground'); } }
首页账单展示:基于 ArkUI 组件的统计与列表呈现
2、index.ets 首页组件,展示用户的账单数据与核心统计信息,页面加载时会从数据库拉取所有账单,自动计算并统计总收入、总支出金额;界面上通过 BalanceViewer 组件展示收支统计结果与日期选择功能,用 BalanceList 组件列出所有账单明细,还通过 PageEntries 组件提供页面入口导航,用户查看账单汇总与明细的核心入口
import { BalanceViewer } from '../components/BalanceViewer'; import { BalanceList } from '../components/BalanceList'; import { PageEntries } from '../components/pageEntries'; import { BillingDirection, BillingInfo, BillingInfoUtils, DBManager, Logger } from '@ohos/common'; let TAG = "INDEX"; @Entry @Component struct Index { @State selectedDate: Date = new Date(); @State currentBillingInfo: BillingInfo[] = []; @State totalIncome: number = 0.00; @State totalBalance: number = 0.00; clearCache() { this.totalIncome = 0; this.totalBalance = 0; } onPageShow() { DBManager.getInstance().getAllBillingInfo() .then(r => { this.clearCache(); this.currentBillingInfo = r; this.currentBillingInfo.forEach(info => { info.direction == BillingDirection.IN ? this.totalIncome += info.amount : this.totalBalance += info.amount; }) Logger.info(TAG, "get info success ", r); Logger.info(TAG, "explode length: ", BillingInfoUtils.explodeMonthlyArray(this.currentBillingInfo, new Date())[19] .length) }) } build() { Column() { Row() { Text($r("app.string.app_title")) .fontColor(Color.White) .fontSize(24) .fontWeight(FontWeight.Normal) .textAlign(TextAlign.Center) .width('100%') } .padding(24) .width('100%') .backgroundColor($r("app.color.main_theme_blue")) BalanceViewer({ selectedDate: $selectedDate, currentBillingInfo: $currentBillingInfo, totalIncome: $totalIncome, totalBalance: $totalBalance }) PageEntries() BalanceList({ currentBillingInfo: $currentBillingInfo, selectedDate: $selectedDate, totalBalance: $totalBalance, totalIncome: $totalIncome }) } } }
账单录入交互:自定义键盘与原生组件的融合应用
3、addBalance.ets 新增账单页面组件,让用户选择收支类型、对应的具体类别,通过自定义的数字键盘输入金额,还能添加备注、选择日期,最后把这些账单信息存入数据库,完成账单记录
- 支出
- 收入
- 记账
import router from '@ohos.router'; import common from '@ohos.app.ability.common'; import { defaultExpenseType, defaultIncomeType, IBillType } from '../data/balanceTypes'; import { DBManager } from '@ohos/common'; import { BillingDirection } from '@ohos/common/src/main/ets/DataTypes/BillingInfo'; interface IKeyboardUnit{ content: string | Resource, contentType?: string, callback?: () => void, bgColor?: ResourceColor, foreColor?: ResourceColor } @Entry @Component struct AddBalance { @State activeTab: number = 0; activeType: Resource = $r("app.media.salaryIcon"); @State selectedTypeName: string = ''; @State balanceAmount: string = "0"; @State balanceTempAmount: string = "0"; @State remark: string = ""; @State calculateAction: number = 0; @State doneButtonText: string = "Ok"; @State activeYear: number = (router.getParams() as ESObject)['year']; @State activeMonth: number = (router.getParams() as ESObject)['month']; @State activeDay: number = new Date().getDate(); activeDate: Date = new Date(); context = getContext(this) as common.UIAbilityContext; filesDir = this.context.filesDir; balanceInputUnits: IKeyboardUnit[] = [ { content: "7", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "7"; } else { this.balanceAmount += "7"; } } }, { content: "8", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "8"; } else { this.balanceAmount += "8"; } } }, { content: "9", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "9"; } else { this.balanceAmount += "9"; } } }, { content: "×", callback: () => { this.calculateAction = 3; this.balanceTempAmount = this.balanceAmount; this.balanceAmount = "0"; } }, { content: "4", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "4"; } else { this.balanceAmount += "4"; } } }, { content: "5", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "5"; } else { this.balanceAmount += "5"; } } }, { content: "6", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "6"; } else { this.balanceAmount += "6"; } } }, { content: "+", callback: () => { if (this.balanceAmount.endsWith(".")) this.balanceAmount += "0"; this.balanceTempAmount = this.balanceAmount; this.balanceAmount = "0"; this.calculateAction = 1; this.doneButtonText = "="; } }, { content: "1", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "1"; } else { this.balanceAmount += "1"; } } }, { content: "2", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "2"; } else { this.balanceAmount += "2"; } } }, { content: "3", callback: () => { if (this.balanceAmount == "0") { this.balanceAmount = "3"; } else { this.balanceAmount += "3"; } } }, { content: "-", callback: () => { if (this.balanceAmount.endsWith(".")) this.balanceAmount += "0"; this.balanceTempAmount = this.balanceAmount; this.balanceAmount = "0"; this.calculateAction = 2; this.doneButtonText = "="; } }, { content: ".", callback: () => { this.balanceAmount += "." } }, { content: "0", callback: () => { if (this.balanceAmount == "0") { return; } this.balanceAmount += "0"; } }, { content: $r("app.media.delete"), contentType: "image", callback: () => { this.balanceAmount = this.balanceAmount.substring(0, this.balanceAmount.length - 1); } }, { content: `√`, bgColor: $r('app.color.main_theme_blue'), foreColor: Color.White, callback: () => { if (this.balanceTempAmount != "0") { if (this.calculateAction == 1) { this.balanceAmount = (parseFloat(this.balanceTempAmount) + parseFloat(this.balanceAmount)).toFixed(2); } else if (this.calculateAction == 2) { this.balanceAmount = (parseFloat(this.balanceTempAmount) - parseFloat(this.balanceAmount)).toFixed(2); } else if (this.calculateAction == 3) { this.balanceAmount = (parseFloat(this.balanceTempAmount) * parseFloat(this.balanceAmount)).toFixed(2); } this.calculateAction = 0; this.balanceTempAmount = "0"; this.doneButtonText = "Ok"; return; } if (this.balanceAmount == "0") return; if (this.remark == "") return; DBManager.getInstance().addBillingInfo({ type: { icon: this.activeType, name: this.selectedTypeName }, amount: parseFloat(this.balanceAmount), direction: this.activeTab == 0 ? BillingDirection.OUT : BillingDirection.IN, timestamp: this.activeDate.getTime(), remark: this.remark }) .then(v => { router.back(); }) } } ]; @State inputMarginTop: number = 1000; @State inputOpacity: number = 0; onBackPress() { if (this.selectedTypeName != '') { this.selectedTypeName = ''; return; } } build() { Stack({ alignContent: Alignment.Bottom }) { Column() { Row() { Row() { Text($r("app.string.balance")) .fontSize(16) .fontColor('white') .onClick(() => { this.activeTab = 0; this.selectedTypeName = ''; this.balanceAmount = "0"; }) .border({ width: { bottom: this.activeTab == 0 ? 2 : 0 }, color: 'white' }) .padding({ bottom: 16 }) .margin({ top: 16, right: 16, left: 16 }) Text($r("app.string.income")) .fontSize(16) .fontColor('white') .onClick(() => { this.activeTab = 1; this.selectedTypeName = ''; this.balanceAmount = "0"; }) .border({ width: { bottom: this.activeTab == 1 ? 2 : 0 }, color: 'white' }) .padding({ bottom: 16 }) .margin({ top: 16, right: 16, left: 16 }) } Text($r("app.string.cancel")) .fontSize(16) .fontColor('white') .onClick(() => { router.back() }) .textAlign(TextAlign.End) .margin({ right: 24 }) } .justifyContent(FlexAlign.SpaceBetween) .height(48) .backgroundColor($r('app.color.main_theme_blue')) .width('100%') GridRow({ columns: 4, gutter: 12 }) { ForEach(this.activeTab == 0 ? defaultExpenseType : defaultIncomeType, (item:IBillType) => { GridCol() { Column({ space: 4 }) { Row() { Image(item.img) .width(24) .height(24) .onClick(() => { this.selectedTypeName = item.title; this.activeType = item.img; animateTo({ duration: 800, curve: Curve.EaseOut }, () => { this.inputMarginTop = 0; this.inputOpacity = 1; }) }) } .shadow({ radius: 24, color: $r('app.color.main_theme_shadow') }) .borderRadius(16) .backgroundColor(this.selectedTypeName == item.title ? "#ffcfe8ff" : "white") .width(48) .height(48) .justifyContent(FlexAlign.Center) .onClick(() => { this.selectedTypeName = '' animateTo({ duration: 800, curve: Curve.EaseOut }, () => { this.inputMarginTop = 1000; this.inputOpacity = 0; }) }) Row() { Text(item.title).fontSize(12) } } .width(56) .height(68) } }) } .padding(12) } .width('100%') .height('100%') Column() { Row() { Text(`${this.balanceAmount}`).textAlign(TextAlign.End).width('100%').padding(8) .fontSize(24) }.height(36) Row() { TextInput({ placeholder: $r("app.string.add_balance_remark_placeholder") }).borderRadius(8).margin(12).onChange(value => { this.remark = value; }) } Row() { Text(`${this.activeYear} / ${(this.activeMonth).toString() .padStart(2, '0')} / ${this.activeDay.toString().padStart(2, '0')}`).fontSize(16) .margin({ bottom: 12 }).onClick(() => { DatePickerDialog.show({ start: new Date("2000-01-01"), onAccept: (v) => { this.activeYear = v.year; this.activeMonth = v.month; this.activeDay = v.day; }, selected: this.activeDate }) }) } GridRow({ columns: 4, gutter: 0 }) { ForEach(this.balanceInputUnits, (unit:IKeyboardUnit) => { GridCol() { Button({ type: ButtonType.Normal }) { if (unit.contentType == "image") { Image(unit.content).width(18) } else { Text(unit.content).fontSize(18).fontColor(unit.foreColor ?? "black") } } .height(49) .backgroundColor(unit.bgColor ?? "white") .width('100%') .borderRadius(0) .onClick(unit.callback ?? (() => { return; })) }.border({ width: { top: 0.5, right: 0.5, bottom: 0, left: 0 }, color: '#ffcdcdcd' }) }) } } .width('100%') .shadow({ radius: 20, offsetY: 16 }) .margin({ top: this.inputMarginTop }) .opacity(this.inputOpacity) .backgroundColor(Color.White) } .width('100%') .height('100%') } }
年度账单统计:数据分层处理与多维度展示
4、BillinfoPage.ets 年度账单统计页面组件,展示指定年份的收支汇总数据,页面加载时会从数据库拉取所有账单,通过工具类 BillingInfoUtils 按月份拆分数据,计算并展示 “年结余、年收入、年支出” 总览,以及每个月的收入、支出、结余明细。用户可点击年份区域,通过内置的 DatePickerDialog 选择其他年份,页面会自动更新对应年份的统计数据,是用户查看年度财务状况的核心界面
import { BillingDirection, BillingInfo, BillingInfoUtils, DBManager, Logger, StringUtils } from '@ohos/common'; let TAG = "BillInfoPage" @Entry @Component struct BillInfoPage { @State activeDate: Date = new Date(); @State monthlySepBillInfo: BillingInfo[][] = []; @State yearlyLeft: number = 0; @State yearlyOutBill: number = 0; @State yearlyIncome: number = 0; clearCache() { this.yearlyLeft = 0; this.yearlyOutBill = 0; this.yearlyIncome = 0; } onPageShow() { DBManager.getInstance().getAllBillingInfo() .then(r => { this.clearCache(); Logger.info(TAG, "activeDate:", StringUtils.formatDate(this.activeDate, 'Y-M-D')) this.monthlySepBillInfo = BillingInfoUtils.explodeYearlyArray(r, this.activeDate); this.monthlySepBillInfo.forEach(monthlyInfo => { monthlyInfo.forEach(info => { if (info.direction == BillingDirection.IN) { this.yearlyLeft += info.amount; this.yearlyIncome += info.amount; } else { this.yearlyLeft -= info.amount; this.yearlyOutBill += info.amount; } }) }) }) } build() { Column() { Row() { Text(`${this.activeDate.getFullYear()}`) .fontSize(16) .margin({ left: 16 }) Text($r("app.string.year")) .fontSize(14) Image($r("app.media.ic_public_extract_list_dark")) .width(8) .height(8) .margin({ left: 8 }) }.onClick(() => { DatePickerDialog.show({ start: new Date("2000-01-01"), onAccept: (v) => { this.activeDate.setFullYear(v.year, v.month, v.day); } }) }) .height(36) .margin(16) .width('100%') Row() { Column() { Text("年结余").fontSize(14).fontColor('#ffffff').margin(4).height(22) Text(`${this.yearlyLeft}`).fontSize(28).fontColor('#ffffff').margin(4).height(36) Row() { Text(`年收入 ${this.yearlyIncome}`) .fontColor('#ffffff') .fontSize(14) .height(30) Text(`年支出 ${this.yearlyOutBill}`) .fontColor('#ffffff') .fontSize(14) .height(30) } .justifyContent(FlexAlign.SpaceAround) .width('100%') }.padding({ left: 24, right: 24, top: 16, bottom: 16 }) } .height(132) .backgroundColor($r("app.color.main_theme_blue")) .margin({ left: 16, right: 16 }) .borderRadius(12) .shadow({ radius: 12, color: $r('app.color.main_theme_shadow') }) Row() { Column() { GridRow({ columns: 4 }) { GridCol() { Text("月份").fontSize(12).fontColor($r("app.color.text_gray")) } GridCol() { Text("月收入").fontSize(12).fontColor($r("app.color.text_gray")) } GridCol() { Text("月支出").fontSize(12).fontColor($r("app.color.text_gray")) } GridCol() { Text("月结余").fontSize(12).fontColor($r("app.color.text_gray")) } } .width('100%') .margin({ bottom: 8 }) Row() { List() { ForEach(this.monthlySepBillInfo, (monthlyInfo: BillingInfo[], index: number) => { ListItem() { GridRow({ columns: 4 }) { GridCol() { Text(`${index + 1}月`).fontSize(16) } GridCol() { Text(`${BillingInfoUtils.calculateTotalIncome(monthlyInfo)}`).fontSize(14) } GridCol() { Text(`${BillingInfoUtils.calculateTotalOutBill(monthlyInfo)}`).fontSize(14) } GridCol() { Text(`${BillingInfoUtils.calculateTotalLeft(monthlyInfo)}`).fontSize(14) } } .padding(12) .border({ width: { top: 0.5 }, color: $r("app.color.text_gray") }) .width('100%') } }) }.listDirection(Axis.Vertical) } .width('100%') } } .padding(16) .width('100%') } } }
鸿蒙原生组件实践:DatePickerDialog 的差异化场景应用
| 维度 | AddBalance.ets | BillInfoPage.ets |
|---|---|---|
| 用途 | 选择单条账单的具体日期(精确到日) | 选择年度统计的年份(核心是年份) |
| 触发元素 | 页面中部的 “年 / 月 / 日” 文本 | 页面顶部的 “年份 + 年” 文本 |
| 数据更新 | 分别更新activeYearactiveMonthactiveDay | 更新 activeDate 对象的年份 |
| 弹窗作用 | 确定单条账单的记录时间 | 切换需要统计的年度数据 |
AddBalance.ets(新增账单页):选择单条账单的具体日期
- 触发时机:点击页面中显示的 “年 / 月 / 日” 文本时触发,用于指定当前记录账单的具体日期
Text(`${this.activeYear} / ${this.activeMonth.toString().padStart(2, '0')} / ${this.activeDay.toString().padStart(2, '0')}`) .onClick(() => { DatePickerDialog.show({ start: new Date("2000-01-01"), // 限制最早可选择2000年1月1日 selected: this.activeDate, // 弹窗默认选中当前日期(this.activeDate) onAccept: (v) => { // 点击“确定”后更新日期 this.activeYear = v.year; // 更新选中的年份 this.activeMonth = v.month; // 更新选中的月份 this.activeDay = v.day; // 更新选中的日期 } }) })
- 特点:需精确到 “日”,因为单条账单需要具体的记录日期,且通过 activeYear、activeMonth、activeDay 三个变量分别存储,便于后续格式化展示和存入数据库
BillInfoPage.ets(年度账单页):选择统计数据的年份
触发时机:点击页面顶部显示的年份文本时触发,用于切换需要查看的年度账单统计数据
Row() { Text(`${this.activeDate.getFullYear()}`).fontSize(16) Text($r("app.string.year")).fontSize(14) }.onClick(() => { DatePickerDialog.show({ start: new Date("2000-01-01"), // 限制最早可选择2000年 onAccept: (v) => { // 点击“确定”后更新年份 this.activeDate.setFullYear(v.year, v.month, v.day); } }) })
- 特点:核心是选择 “年份”,月份和日期不影响统计结果,因此直接通过 this.activeDate(完整日期对象)的 setFullYear 方法更新年份,后续统计逻辑会基于该年份筛选数据
两者均依赖鸿蒙内置的 DatePickerDialog 实现日期选择,通过 show 方法配置可选范围和默认值,再通过 onAccept 回调更新页面状态,实现 “点击→选择→更新” 的完整交互
鸿蒙开发实践总结:轻量化应用开发的效率与体验
易记账轻量化记账应用的鸿蒙开发过程中,从架构搭建到功能落地,深刻感受到鸿蒙生态对轻量化应用开发的适配性与效率优势
从开发效率来看,鸿蒙的模块化目录设计(如AppScope统一全局资源、common封装通用工具)让代码复用率显著提升 ,DBManager 数据库管理、BillingInfoUtils 数据处理等通用逻辑只需开发一次,即可在首页、新增账单页、年度统计页跨页面调用;ArkUI 框架的声明式语法则大幅简化了界面开发,像Column/Row布局、ForEach 循环渲染账单列表,配合 @State 状态管理实现数据与 UI 的自动联动,相比传统开发减少了近 30% 的模板代码,尤其是原生 DatePickerDialog 组件的应用,无需自定义滚轮逻辑或适配样式,仅通过简单配置就能满足 “新增账单精确选日/年度统计选年” 两种差异化场景,极大降低了组件开发成本
从用户体验优化来看,鸿蒙的特性让轻量化应用也能具备流畅的交互表现,新增账单页通过 animateTo 实现输入面板的平滑弹出 / 隐藏,避免界面跳转的割裂感;年度统计页基于 BillingInfoUtils 的月份拆分逻辑,实现账单数据的实时计算与展示,页面切换时无明显卡顿,同时, entryability 对应用生命周期的统一管理,确保了 APP 启动时数据库初始化、全局上下文配置的稳定性,从底层保障了用户操作的流畅性
此外,鸿蒙的生态兼容性也为轻量化应用预留了扩展空间 —— 当前 “易记账” 虽聚焦单机记账,但基于 common 模块的分层设计,后续若需拓展多设备同步,如手机与平板账单互通,只需在通用模块中补充分布式数据逻辑,无需重构核心业务代码,这种 “轻量化起步、可拓展演进” 的特性,恰好契合了中小体量应用的开发需求
总结

“易记账” 鸿蒙开发实践是轻量化应用与鸿蒙生态高效适配:模块化目录设计降低代码冗余,ArkUI 声明式语法减少界面开发工作量,原生组件DatePickerDialog省去大量自定义适配成本。
同时,生命周期管理与状态联动特性从底层保障应用稳定性与交互流畅性。这种 “低开发成本、高功能完整性” 的体验,适配轻量化工具的开发需求,实现 “开发效率” 与 “用户体验” 双重平衡
👉如果你也在探索鸿蒙轻量化应用开发,或是想第一时间 get 鸿蒙新特性适配,点击链接加入和开发者们一起交流经验吧!https://work.weixin.qq.com/gm/afdd8c7246e72c0e94abdbd21bc9c5c1
更多推荐













所有评论(0)