鸿蒙ArkTS个人记账本技术博客


基于 HarmonyOS ArkTS 的个人记账本应用:从零构建到功能完善的完整技术实践
摘要
本文深入剖析了一个基于 HarmonyOS ArkTS 开发的个人记账本应用,详细阐述了从项目架构设计、数据模型构建、UI 界面开发到数据持久化的完整技术实现路径。通过分析超过 900 行 ArkTS 代码的演进过程,本文揭示了 HarmonyOS 应用开发中的关键技术要点,包括状态管理、组件化 UI 设计、类型安全实践以及与底层数据存储的交互机制。文章不仅提供了具体的技术实现方案,还深入探讨了在严格的 ArkTS 类型系统约束下进行应用开发的策略与技巧,为 HarmonyOS 开发者提供了一份详实的技术参考。
关键词: HarmonyOS;ArkTS;个人记账本;状态管理;数据持久化;组件化开发
一、引言:鸿蒙生态与个人财务管理的新机遇
1.1 HarmonyOS 生态系统的崛起
随着华为 HarmonyOS(鸿蒙操作系统)的快速发展和生态建设的持续完善,越来越多的开发者开始关注这一新兴的移动操作系统平台。HarmonyOS 作为面向万物互联时代的全场景分布式操作系统,其设计理念与技术架构与传统的 Android 和 iOS 存在显著差异。HarmonyOS 采用统一的系统架构,支持多种终端设备,包括智能手机、平板电脑、智能穿戴设备、智慧屏以及车载系统等,这种"一次开发,多端部署"的能力为开发者提供了前所未有的机遇。
在 HarmonyOS 应用开发中,ArkTS(Ark TypeScript)作为官方推荐的开发语言,基于 TypeScript 进行扩展,提供了声明式 UI 开发范式和强大的状态管理能力。ArkTS 在保持 TypeScript 类型系统优势的同时,针对 UI 开发场景进行了专门优化,引入了诸如 @State、@Builder、@Entry、@Component 等装饰器,使得开发者能够以更加简洁、直观的方式构建用户界面。
1.2 个人记账应用的价值与意义
个人财务管理是每个人日常生活中不可或缺的重要组成部分。在数字化时代,虽然各种支付工具提供了交易记录功能,但这些记录往往分散在不同的平台和应用中,难以形成统一的财务视图。一个功能完善、操作便捷的个人记账应用能够帮助用户:
第一,实现收支的精细化管理。 通过分类记录每一笔收入和支出,用户可以清晰地了解自己的资金流向,识别消费热点,从而制定更加合理的预算计划。
第二,培养理性的消费习惯。 定期的财务复盘能够让用户意识到不必要的开支,逐步建立起量入为出的消费观念,这对于年轻人的财务健康尤为重要。
第三,提供数据驱动的决策支持。 通过统计图表和趋势分析,用户可以基于历史数据做出更加明智的财务决策,例如调整储蓄比例、优化投资组合等。
第四,满足隐私保护需求。 相比将财务数据存储在第三方云服务中,本地化的记账应用能够更好地保护用户的隐私信息,避免敏感数据泄露的风险。
1.3 技术选型与项目目标
本项目选择 HarmonyOS ArkTS 作为开发技术栈,主要基于以下几方面的考量:
技术前瞻性: HarmonyOS 作为国产操作系统的代表,其生态正处于快速发展阶段。掌握 ArkTS 开发技术不仅具有实际的应用价值,也代表了技术发展的前沿方向。随着鸿蒙设备用户基数的持续增长,基于 HarmonyOS 的应用将拥有广阔的市场空间。
开发效率优势: ArkTS 的声明式 UI 开发范式极大地提高了界面开发效率。通过描述 UI 的状态和结构,框架会自动处理界面的更新和渲染,开发者无需手动操作 DOM 或处理复杂的 UI 更新逻辑。这种开发模式与 Flutter、SwiftUI 等现代 UI 框架类似,代表了移动应用开发的主流趋势。
类型安全保证: ArkTS 基于 TypeScript,提供了强大的静态类型检查能力。这在大型应用开发中尤为重要,能够在编译阶段发现大量潜在的错误,减少运行时异常,提高代码的可维护性和可靠性。
系统级能力集成: 作为原生开发框架,ArkTS 能够无缝集成 HarmonyOS 提供的系统级能力,包括数据存储、网络通信、多媒体处理等。特别是 @ohos.data.preferences 等系统 API 的调用,确保了应用能够高效、安全地处理本地数据。
本项目的目标是构建一个功能完整、界面美观、操作流畅的个人记账本应用,具体包括以下核心功能模块:
- 首页概览: 展示总金额、本月收入、本月支出和结余等核心财务指标,以及最近的交易记录。
- 记账功能: 支持选择收支类型(收入/支出)、分类(餐饮、交通、购物等),输入金额和备注,完成记账操作。
- 明细查看: 按月份展示所有交易记录,支持月份切换和记录删除。
- 统计分析: 展示支出分类占比和收支对比统计,帮助用户了解消费结构。
二、HarmonyOS ArkTS 开发基础
2.1 ArkTS 语言特性概述
ArkTS 是 HarmonyOS 应用开发的核心编程语言,它在标准 TypeScript 的基础上进行了扩展和增强,以更好地支持声明式 UI 开发和状态管理。理解 ArkTS 的核心特性是掌握 HarmonyOS 应用开发的基础。
类型系统的增强: ArkTS 继承了 TypeScript 强大的类型系统,并在此基础上增加了对 UI 开发的专门支持。与标准 TypeScript 相比,ArkTS 对类型使用有更加严格的限制,例如禁止使用 any 和 unknown 类型,这迫使开发者必须显式声明类型,从而在编译阶段就能够捕获大量类型错误。这种严格性虽然在初期会增加一定的开发成本,但从长远来看,它显著提高了代码的质量和可维护性。
声明式 UI 范式: ArkTS 采用声明式 UI 开发范式,开发者通过描述 UI 的"状态"和"结构"来构建界面,而不是通过命令式地操作 UI 元素。在这种范式下,当应用的状态发生变化时,UI 框架会自动计算并应用最小的界面更新,确保界面与数据状态保持同步。这种开发模式与 React、Vue 等前端框架的设计理念一脉相承,但 ArkTS 将其与静态类型系统深度融合,提供了更加可靠的开发体验。
装饰器机制: ArkTS 引入了多种装饰器来支持不同的开发场景。@Entry 装饰器标记应用的入口页面,@Component 装饰器定义可复用的 UI 组件,@State 装饰器声明组件内部的状态变量,@Builder 装饰器定义可复用的 UI 构建函数。这些装饰器构成了 ArkTS 应用开发的基础框架,使得代码结构清晰、职责分明。
2.2 状态管理机制
状态管理是声明式 UI 开发的核心。在 ArkTS 中,@State 装饰器用于声明组件内部的状态变量。当 @State 修饰的变量发生变化时,框架会自动触发依赖该状态的 UI 部分的重新渲染。
@State currentTab: number = 0;
@State transactions: TransactionModel[] = [];
@State totalAmount: number = 0;
上述代码展示了本应用中几个关键的状态变量。currentTab 控制底部导航栏的当前选中项,transactions 存储所有的交易记录,totalAmount 维护当前的账户总金额。当用户完成一笔记账操作后,这些状态变量会相应更新,界面会自动刷新以反映最新的数据状态。
需要注意的是,ArkTS 对状态变量的类型有严格要求。不支持使用 any 和 unknown 类型作为状态变量的类型,也不支持在 @Builder 方法中声明局部变量进行数据计算。这些限制要求开发者将数据处理逻辑从 UI 构建方法中分离出来,放到普通的方法中处理,然后通过参数传递给 @Builder 方法。这种设计虽然在一定程度上增加了代码的复杂度,但它确保了 UI 构建函数的纯粹性,使得 UI 逻辑更加清晰、易于维护。
2.3 组件化架构
ArkTS 采用组件化的架构设计,将界面拆分为多个独立、可复用的组件。每个组件由 @Component 装饰器定义,包含自己的状态、UI 结构和生命周期方法。
在本应用中,所有的 UI 都封装在一个 Index 组件中。虽然从严格意义上说,将如此多的功能集中在一个组件中并不符合组件化的最佳实践,但在小型应用或原型开发阶段,这种设计可以简化数据传递和状态共享的复杂度。通过 @Builder 装饰器,我们将不同的页面模块(首页、记账页、明细页、统计页)拆分为独立的构建函数,每个函数负责渲染特定页面的 UI 结构。
@Builder
buildHomePage(): void { /* 首页 UI */ }
@Builder
buildAddPage(): void { /* 记账页 UI */ }
@Builder
buildDetailPage(year: number, month: number, filtered: TransactionModel[]): void { /* 明细页 UI */ }
@Builder
buildStatsPage(income: number, expense: number, categoryStats: CategoryStat[]): void { /* 统计页 UI */ }
这种设计模式使得代码结构清晰,每个 @Builder 方法只关注特定页面的 UI 渲染,数据处理逻辑通过参数传入,实现了 UI 与业务逻辑的分离。
2.4 生命周期管理
ArkTS 组件提供了完整的生命周期方法,允许开发者在组件的不同阶段执行特定的逻辑。本应用主要使用了 aboutToAppear 生命周期方法,在组件即将显示时初始化数据存储并加载历史交易记录。
aboutToAppear(): void {
this.initPreferences();
this.loadData();
}
aboutToAppear 方法是组件生命周期的第一个重要节点,此时组件已经创建但尚未渲染到屏幕上。在这个方法中进行数据初始化操作,可以确保当用户看到界面时,所有的数据已经准备就绪,提供流畅的用户体验。
三、应用架构设计与数据模型
3.1 整体架构设计
本应用采用经典的 MVC(Model-View-Controller)架构模式的变体。在这种架构下,数据模型(Model)负责定义数据结构和业务逻辑,视图(View)负责渲染用户界面,而控制器(Controller)则处理用户输入并协调模型和视图之间的交互。
在 ArkTS 的实现中,这种架构体现为:
数据模型层: 由 TransactionModel、CategoryModel、CategoryStat、DateResult 等类以及 TransactionData 接口构成,定义了应用中所有的数据结构。
视图层: 由一系列 @Builder 方法构成,负责根据当前的状态渲染对应的用户界面。
业务逻辑层: 由普通的方法(非 @Builder 方法)构成,处理用户交互、数据计算、数据持久化等业务逻辑。
这种分层架构使得代码职责清晰,便于维护和扩展。当需要添加新功能时,开发者可以清晰地知道应该在哪个层次进行修改。
3.2 核心数据模型设计
数据模型的设计是应用架构的基础。合理的数据模型不仅能够准确地表达业务概念,还能够简化后续的业务逻辑实现。
3.2.1 交易记录模型
TransactionModel 是应用中最核心的数据模型,代表一笔交易记录:
class TransactionModel {
id: number = 0;
amount: number = 0;
category: string = '';
type: string = 'expense';
remark: string = '';
date: string = '';
timestamp: number = 0;
}
该模型包含以下字段:
- id: 交易的唯一标识符,使用
Date.now()生成,确保唯一性。 - amount: 交易金额,使用
number类型存储。 - category: 交易分类,如"餐饮"、"交通"等,使用字符串存储分类名称。
- type: 交易类型,分为 ‘income’(收入)和 ‘expense’(支出),默认值为 ‘expense’。
- remark: 交易备注,可选字段,用于记录交易的详细信息。
- date: 交易日期,使用 ‘YYYY-MM-DD’ 格式的字符串存储,便于展示和按日期筛选。
- timestamp: 交易时间戳,用于排序和精确的时间判断。
这种设计同时包含了日期字符串和时间戳两种时间表示方式,兼顾了展示需求(date 字段)和计算需求(timestamp 字段)。日期字符串的格式统一为 ‘YYYY-MM-DD’,便于按年、月、日进行解析和筛选。
3.2.2 分类模型
CategoryModel 定义了交易分类的数据结构:
class CategoryModel {
name: string = '';
icon: string = '';
constructor(name: string, icon: string) {
this.name = name;
this.icon = icon;
}
}
每个分类包含名称(name)和图标(icon)两个属性。图标使用 Unicode Emoji 字符表示,这种设计简化了图标资源的管理,无需引入额外的图片资源文件。应用预定义了两组分类:
支出分类: 餐饮、交通、购物、学习、娱乐、医疗、住房、其他
收入分类: 工资、兼职、红包、理财
这种分类体系覆盖了日常生活中绝大多数的收支场景,用户可以快速找到对应的分类完成记账。
3.2.3 统计数据模型
CategoryStat 用于存储分类统计信息:
class CategoryStat {
name: string = '';
amount: number = 0;
percent: string = '';
constructor(name: string, amount: number, percent: string) {
this.name = name;
this.amount = amount;
this.percent = percent;
}
}
该模型包含分类名称、金额和占比百分比三个字段。百分比以字符串形式存储,保留了小数精度,便于直接展示在 UI 上。
3.2.4 日期解析模型
DateResult 用于存储日期解析的结果:
class DateResult {
year: number = 0;
month: number = 0;
day: number = 0;
constructor(year: number, month: number, day: number) {
this.year = year;
this.month = month;
this.day = day;
}
}
该模型将日期字符串解析为年、月、日三个独立的数字字段。值得注意的是,month 字段使用 0-11 的表示方式(JavaScript 的 Date.getMonth() 返回值),这与日常生活中的 1-12 月份表示有所不同,在进行月份比较时需要特别注意。
3.3 数据持久化策略
数据的持久化存储是记账应用的关键需求。用户记录的交易数据需要在应用关闭后仍然保存,并在下次打开应用时恢复。本应用采用 HarmonyOS 提供的 @ohos.data.preferences API 实现数据的本地持久化。
3.3.1 Preferences 存储机制
@ohos.data.preferences 提供了一种轻量级的键值对数据存储方案,类似于 Web 开发中的 localStorage。它适合存储结构化的配置信息和小规模的数据集合。在本应用中,我们将所有的交易记录序列化为 JSON 字符串,以 ‘transactions’ 为键存储到 Preferences 中。
import dataPreferences from '@ohos.data.preferences';
async initPreferences(): Promise<void> {
try {
this.preferences = await dataPreferences.getPreferences(this.context, 'accounting_data');
} catch (e) {
console.error('initPreferences failed:', e);
}
}
dataPreferences.getPreferences 方法接收应用上下文和存储名称两个参数,返回一个 Preferences 实例。通过该实例,可以进行数据的读写操作。使用 await 关键字处理异步操作,确保在 Preferences 初始化完成后再进行后续的数据操作。
3.3.2 数据的序列化与反序列化
交易记录数组需要序列化为 JSON 字符串才能存储到 Preferences 中。在保存数据时:
async saveData(): Promise<void> {
if (!this.preferences) {
await this.initPreferences();
}
if (!this.preferences) {
return;
}
try {
let dataStr = JSON.stringify(this.transactions);
await this.preferences.put('transactions', dataStr);
await this.preferences.flush();
} catch (e) {
console.error('saveData failed:', e);
}
}
JSON.stringify 将交易记录数组转换为 JSON 字符串,preferences.put 方法将数据写入存储,preferences.flush 方法确保数据立即持久化到磁盘。这种三步操作确保了数据的可靠保存。
在加载数据时,需要进行反序列化操作:
async loadData(): Promise<void> {
if (!this.preferences) {
await this.initPreferences();
}
if (!this.preferences) {
this.initDefaultData();
return;
}
try {
let saved = await this.preferences.get('transactions', '');
if (saved && saved !== '') {
let parsed = JSON.parse(saved as string) as TransactionData[];
this.transactions = this.parseTransactions(parsed);
this.calculateTotalAmount();
} else {
this.initDefaultData();
this.calculateTotalAmount();
await this.saveData();
}
} catch (e) {
console.error('loadData failed:', e);
this.initDefaultData();
this.calculateTotalAmount();
}
}
反序列化过程中,JSON.parse 将 JSON 字符串转换为 JavaScript 对象。由于 JSON 解析的结果类型不确定,需要使用类型断言 as TransactionData[] 来指定类型。然后通过 parseTransactions 方法将解析后的数据转换为 TransactionModel 实例数组。
3.3.3 数据迁移与兼容性
parseTransactions 方法不仅进行数据转换,还处理了数据兼容性问题:
parseTransactions(data: TransactionData[]): TransactionModel[] {
let result: TransactionModel[] = [];
for (let i = 0; i < data.length; i++) {
let item = data[i];
let t = new TransactionModel();
t.id = typeof item.id === 'number' ? item.id : Date.now();
t.amount = typeof item.amount === 'number' ? item.amount : 0;
t.category = typeof item.category === 'string' ? item.category : '其他';
t.type = typeof item.type === 'string' ? item.type : 'expense';
t.remark = typeof item.remark === 'string' ? item.remark : '';
t.date = typeof item.date === 'string' ? item.date : this.getCurrentDateString();
t.timestamp = typeof item.timestamp === 'number' ? item.timestamp : Date.now();
result.push(t);
}
return result;
}
通过 typeof 类型检查,该方法确保每个字段都有合理的默认值。即使存储的数据格式发生变化(例如某些字段缺失或类型不匹配),应用也能够优雅地处理,避免崩溃。这种防御式编程策略在实际应用中非常重要,特别是当应用经历多次迭代更新后,存储的数据格式可能会有所变化。
四、用户界面设计与实现
4.1 整体布局设计
本应用采用经典的底部 Tab 导航布局,包含四个主要页面:首页、记账、明细、统计。底部导航栏固定在屏幕底部,用户可以通过点击不同的 Tab 切换页面。这种布局模式在移动应用中非常常见,用户熟悉度高,操作便捷。
build() {
Column() {
this.buildTabContent();
this.buildTabBar();
if (this.showAmountDialog) {
this.buildAmountDialog();
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
根布局采用 Column 组件,垂直方向排列内容区域和底部导航栏。内容区域使用 layoutWeight(1) 占据剩余空间,导航栏固定在底部。#F5F5F5 的浅灰色背景色为整个应用提供了统一的视觉基调。
4.2 首页设计与实现
首页是用户打开应用后看到的第一个页面,承担着信息概览和快速入口的重要职责。
4.2.1 核心指标展示
首页顶部展示"总金额"指标,这是用户最关心的财务数据:
Column() {
Text('总金额')
.fontSize(14)
.fontColor('#999999')
.padding({ top: 16, left: 16 })
Text('¥' + this.formatNumber(this.totalAmount))
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(this.totalAmount >= 0 ? '#27AE60' : '#E74C3C')
.padding({ left: 16 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.margin({ left: 16, right: 16 })
.borderRadius(12)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.05)' })
总金额使用 36 号大字体突出显示,颜色根据金额正负动态变化:正值显示绿色(#27AE60),负值显示红色(#E74C3C)。这种颜色编码直观地传达了财务状况的好坏。卡片采用白色背景、圆角边框和轻微阴影,营造出悬浮的视觉效果,提升了界面的层次感。
在总金额下方,使用三个等宽的统计卡片展示本月收入、本月支出和结余:
Row({ space: 12 }) {
this.buildStatCard('本月收入', this.getMonthIncome())
this.buildStatCard('本月支出', this.getMonthExpense())
this.buildStatCard('结余', this.getMonthIncome() - this.getMonthExpense())
}
.padding({ left: 16, right: 16 })
三个卡片横向等分排列,space: 12 设置了卡片之间的间距。每个卡片的高度固定为 80,使用白色背景和圆角设计,与总金额卡片保持一致的视觉风格。
4.2.2 最近记录列表
首页底部展示最近的交易记录,帮助用户快速回顾近期的收支情况:
List({ space: 8 }) {
ForEach(this.getRecentTransactions(), (item: TransactionModel) => {
ListItem() {
this.buildTransactionItem(item)
}
})
if (this.transactions.length === 0) {
ListItem() {
Text('暂无记录')
.fontSize(14)
.fontColor('#999999')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 30, bottom: 30 })
}
}
}
.width('100%')
.padding({ left: 16, right: 16 })
使用 List 组件展示交易记录,ForEach 遍历最近的交易数据。每条记录使用 buildTransactionItem 方法渲染,包含分类图标、分类名称、日期和金额等信息。当没有交易记录时,显示"暂无记录"的提示文本。
4.2.3 交易记录项设计
交易记录项采用卡片式布局,清晰展示交易的关键信息:
Row({ space: 12 }) {
Text(this.getCategoryIconByName(item.category))
.fontSize(28)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(item.category)
.fontSize(14)
.fontColor('#333333')
if (item.remark.length > 0) {
Text(item.remark)
.fontSize(12)
.fontColor('#999999')
}
}
Text(item.date)
.fontSize(12)
.fontColor('#999999')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Row({ space: 0 }) {
Text(item.type === 'income' ? '+' : '-')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(item.type === 'income' ? '#27AE60' : '#E74C3C')
Text(this.formatNumber(item.amount))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(item.type === 'income' ? '#27AE60' : '#E74C3C')
}
}
左侧显示分类的 Emoji 图标(28号字体),中间区域展示分类名称、备注和日期,右侧显示金额。收入使用绿色前缀"+“,支出使用红色前缀”-",颜色编码与首页的总金额保持一致,形成了统一的视觉语言。layoutWeight(1) 使中间区域占据剩余空间,确保金额始终右对齐。
4.3 记账页面设计
记账页面是应用的核心功能入口,设计目标是让用户以最少的操作步骤完成记账。
4.3.1 收支类型切换
页面顶部提供支出/收入类型切换按钮:
Row({ space: 16 }) {
this.buildTypeButton('支出', 'expense')
this.buildTypeButton('收入', 'income')
}
.padding({ top: 20, left: 16, right: 16 })
.justifyContent(FlexAlign.SpaceBetween)
两个按钮横向排列,分别占据约 45% 的宽度。选中状态的按钮使用蓝色背景(#3498DB)和白色文字,未选中状态使用浅蓝色背景(#E8F4FC)和蓝色文字,形成清晰的视觉对比。
4.3.2 分类网格选择
分类采用网格布局,每行显示 4 个分类:
Grid() {
ForEach(this.getCurrentCategories(), (cat: CategoryModel) => {
GridItem() {
Column({ space: 4 }) {
Text(cat.icon)
.fontSize(28)
Text(cat.name)
.fontSize(12)
.fontColor('#666666')
}
.width('100%')
.height(64)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.selectedCategory = cat.name;
this.selectedCategoryIcon = cat.icon;
this.showAmountDialog = true;
})
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)
Grid 组件配合 columnsTemplate('1fr 1fr 1fr 1fr') 实现四列等宽布局。每个分类项显示 Emoji 图标和分类名称,采用白色背景卡片设计。点击分类项后,设置选中的分类信息并弹出金额输入弹窗。
4.3.3 金额输入弹窗
弹窗采用模态对话框的形式,覆盖在整个页面上方:
@Builder
buildAmountDialog(): void {
Column() {
Column() {
Text('记一笔')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.padding({ top: 20, bottom: 16 })
Row({ space: 8 }) {
Text(this.selectedCategoryIcon)
.fontSize(24)
Text(this.selectedCategory)
.fontSize(16)
.fontColor('#666666')
}
.padding({ bottom: 20 })
Text('金额')
.fontSize(14)
.fontColor('#999999')
.width('100%')
.padding({ left: 16 })
.textAlign(TextAlign.Start)
Row({ space: 8 }) {
Text('¥')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
TextInput({ placeholder: '0.00', text: this.dialogAmount })
.type(InputType.Number)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.onChange((val: string) => {
this.dialogAmount = val;
})
}
.width('100%')
.height(56)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.margin({ left: 16, right: 16, top: 8 })
Text('备注(可选)')
.fontSize(14)
.fontColor('#999999')
.width('100%')
.padding({ left: 16, top: 16 })
.textAlign(TextAlign.Start)
TextInput({ placeholder: '添加备注', text: this.dialogRemark })
.fontSize(14)
.fontColor('#333333')
.width('100%')
.height(44)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.padding({ left: 16, right: 16 })
.margin({ left: 16, right: 16, top: 8 })
.onChange((val: string) => {
this.dialogRemark = val;
})
Row({ space: 12 }) {
Button('取消')
.width('45%')
.height(48)
.backgroundColor('#E0E0E0')
.fontColor('#666666')
.fontSize(16)
.borderRadius(24)
.onClick(() => {
this.showAmountDialog = false;
this.dialogAmount = '';
this.dialogRemark = '';
})
Button('确认')
.width('45%')
.height(48)
.backgroundColor(this.dialogAmount.length > 0 ? '#3498DB' : '#B0C4DE')
.fontColor('#FFFFFF')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.borderRadius(24)
.enabled(this.dialogAmount.length > 0)
.onClick(() => {
this.confirmTransaction();
})
}
.padding({ top: 24, left: 16, right: 16, bottom: 20 })
}
.width('85%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
}
弹窗设计遵循以下原则:
信息清晰: 顶部明确显示已选择的分类图标和名称,让用户确认当前正在记录的交易类型。
输入便捷: 金额输入框使用数字键盘(InputType.Number),大字体显示(28号),配合人民币符号"¥",输入体验流畅。
操作明确: 取消和确认按钮并排显示,确认按钮在未输入金额时置灰(enabled(false)),避免误操作。
视觉层次: 弹窗使用白色背景卡片,外部覆盖半透明黑色遮罩(rgba(0,0,0,0.5)),聚焦用户注意力到弹窗内容上。
4.4 明细页面设计
明细页面按月份展示所有交易记录,支持月份切换和记录删除。
4.4.1 月份导航
页面顶部提供月份切换控件:
Row({ space: 16 }) {
Button() {
Text('◀')
.fontSize(20)
.fontColor('#333333')
}
.width(44)
.height(44)
.backgroundColor('#FFFFFF')
.borderRadius(22)
.onClick(() => {
this.detailDate = new Date(year, month - 1, 1);
})
Text(`${year}年${month + 1}月`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Button() {
Text('▶')
.fontSize(20)
.fontColor('#333333')
}
.width(44)
.height(44)
.backgroundColor('#FFFFFF')
.borderRadius(22)
.onClick(() => {
let now = new Date();
let nextDate = new Date(year, month + 1, 1);
if (nextDate.getTime() <= now.getTime()) {
this.detailDate = nextDate;
}
})
}
左右箭头按钮分别用于切换到上一月和下一月。月份文本居中显示,使用粗体突出。切换到下一月时增加了边界检查,防止切换到未来的月份。
4.4.2 交易列表与删除
交易列表与首页的最近记录类似,但增加了删除功能:
ForEach(filtered, (item: TransactionModel) => {
ListItem() {
Row({ space: 8 }) {
this.buildTransactionItem(item)
Button('删除')
.width(60)
.height(32)
.backgroundColor('#E74C3C')
.fontColor('#FFFFFF')
.fontSize(12)
.borderRadius(8)
.onClick(() => {
this.deleteTransaction(item.id);
})
}
.width('100%')
}
})
每条记录右侧添加红色的"删除"按钮,点击后调用 deleteTransaction 方法删除该记录。删除操作会同步更新交易数组和总金额,并持久化到本地存储。
4.5 统计页面设计
统计页面提供支出分类占比和收支对比的可视化展示。
4.5.1 支出分类占比
@Builder
buildExpenseCategoryStats(categoryStats: CategoryStat[]): void {
Text('支出分类占比')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.padding({ left: 16 })
.width('100%')
.textAlign(TextAlign.Start)
Column({ space: 8 }) {
ForEach(categoryStats, (stat: CategoryStat) => {
Row({ space: 8 }) {
Text(this.getCategoryIconByName(stat.name))
.fontSize(16)
Text(stat.name)
.fontSize(14)
.fontColor('#333333')
.layoutWeight(1)
Text(stat.percent + '%')
.fontSize(14)
.fontColor('#666666')
}
.width('100%')
.padding({ left: 16, right: 16 })
})
}
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 16, right: 16 })
.padding({ top: 12, bottom: 12 })
}
分类占比以列表形式展示,每个分类显示图标、名称和占比百分比。使用白色背景卡片包裹列表内容,视觉层次清晰。
4.5.2 收支对比
Row({ space: 32 }) {
Column({ space: 8 }) {
Text('收入')
.fontSize(14)
.fontColor('#999999')
Text('¥' + this.formatNumber(income))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#27AE60')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 8 }) {
Text('支出')
.fontSize(14)
.fontColor('#999999')
Text('¥' + this.formatNumber(expense))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#E74C3C')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
收入和支出并排显示,收入使用绿色,支出使用红色,形成鲜明的视觉对比。layoutWeight(1) 使两列等分宽度,alignItems(HorizontalAlign.Center) 使内容水平居中。
五、业务逻辑实现
5.1 交易记录的创建与确认
当用户在弹窗中输入金额并点击"确认"后,触发 confirmTransaction 方法:
confirmTransaction(): void {
if (this.dialogAmount.length === 0) {
return;
}
let amountValue = parseFloat(this.dialogAmount);
if (isNaN(amountValue) || amountValue <= 0) {
return;
}
let newTransaction = new TransactionModel();
newTransaction.id = Date.now();
newTransaction.amount = amountValue;
newTransaction.category = this.selectedCategory;
newTransaction.type = this.addType;
newTransaction.remark = this.dialogRemark;
newTransaction.date = this.getCurrentDateString();
newTransaction.timestamp = Date.now();
this.transactions.unshift(newTransaction);
if (this.addType === 'income') {
this.totalAmount += amountValue;
} else {
this.totalAmount -= amountValue;
}
this.saveData();
this.dialogAmount = '';
this.dialogRemark = '';
this.showAmountDialog = false;
this.currentTab = 0;
}
该方法的处理流程如下:
参数校验: 首先检查金额是否为空,然后使用 parseFloat 将字符串转换为数字,并检查是否为有效数值且大于零。这种多层校验确保了数据的合法性。
创建交易对象: 使用 Date.now() 生成唯一 ID 和时间戳,使用 getCurrentDateString() 获取当前日期字符串,构建完整的交易记录对象。
更新状态: 将新交易添加到交易数组的开头(unshift),以便在列表中优先显示。同时更新总金额,收入增加金额,支出减少金额。
持久化数据: 调用 saveData() 将更新后的交易数组保存到本地存储。
重置状态: 清空弹窗输入,关闭弹窗,自动切换到首页(currentTab = 0),让用户立即看到更新后的数据。
5.2 日期处理与解析
日期处理是记账应用中的常见需求,包括获取当前日期、解析日期字符串、按月份筛选等。
5.2.1 日期格式化
getCurrentDateString(): string {
let now = new Date();
let year = now.getFullYear();
let month = now.getMonth() + 1;
let day = now.getDate();
let monthStr = month < 10 ? '0' + month : month.toString();
let dayStr = day < 10 ? '0' + day : day.toString();
return year + '-' + monthStr + '-' + dayStr;
}
该方法生成 ‘YYYY-MM-DD’ 格式的日期字符串。对于月份和日期小于 10 的情况,在前面补零,确保字符串长度统一。这种格式化的日期字符串便于展示和按日期筛选。
5.2.2 日期解析
parseDate(dateStr: string): DateResult {
let parts = dateStr.split('-');
return new DateResult(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
将日期字符串解析为 DateResult 对象。注意月份需要减 1,因为 JavaScript 的 Date.getMonth() 返回 0-11 的月份值,而日期字符串中的月份是 1-12。
5.3 统计计算
5.3.1 月收入与支出计算
getMonthIncome(): number {
let now = new Date();
let year = now.getFullYear();
let month = now.getMonth();
let income = 0;
for (let i = 0; i < this.transactions.length; i++) {
let t = this.transactions[i];
let d = this.parseDate(t.date);
if (d.year === year && d.month === month && t.type === 'income') {
income += t.amount;
}
}
return income;
}
遍历所有交易记录,筛选出当前年份和月份的收入交易,累加金额。支出计算逻辑类似,只是筛选条件为 t.type === 'expense'。
5.3.2 分类占比计算
getCategoryStats(): CategoryStat[] {
let now = new Date();
let year = now.getFullYear();
let month = now.getMonth();
let expense = this.getMonthExpense();
let categoryMap: Record<string, number> = {};
for (let i = 0; i < this.expenseCategories.length; i++) {
categoryMap[this.expenseCategories[i].name] = 0;
}
for (let i = 0; i < this.transactions.length; i++) {
let t = this.transactions[i];
let d = this.parseDate(t.date);
if (d.year === year && d.month === month && t.type === 'expense') {
if (categoryMap[t.category] !== undefined) {
categoryMap[t.category] += t.amount;
}
}
}
let result: CategoryStat[] = [];
for (let i = 0; i < this.expenseCategories.length; i++) {
let name = this.expenseCategories[i].name;
let amount = categoryMap[name];
if (amount > 0) {
let percent = expense > 0 ? this.formatPercent((amount / expense) * 100) : '0';
result.push(new CategoryStat(name, amount, percent));
}
}
return result;
}
分类占比的计算逻辑如下:
- 初始化分类映射表,将所有支出分类的金额置为 0。
- 遍历交易记录,累加每个分类的支出金额。
- 遍历分类映射表,计算每个分类的占比百分比。
- 仅保留金额大于 0 的分类,避免显示没有交易的分类。
百分比计算使用了自定义的 formatPercent 方法,保留一位小数:
formatPercent(num: number): string {
let str = num.toString();
let dotIndex = str.indexOf('.');
if (dotIndex === -1) {
return str;
}
if (dotIndex + 2 >= str.length) {
return str;
}
return str.substring(0, dotIndex + 2);
}
该方法通过字符串操作实现百分比格式化,避免了使用 toFixed 方法(ArkTS 限制标准库的使用)。
5.3.3 总金额计算
calculateTotalAmount(): void {
let total = 0;
for (let i = 0; i < this.transactions.length; i++) {
let t = this.transactions[i];
if (t.type === 'income') {
total += t.amount;
} else {
total -= t.amount;
}
}
this.totalAmount = total;
}
总金额通过遍历所有交易记录计算得出,收入加、支出减。该方法在数据加载和记录删除时被调用,确保总金额的准确性。
5.4 交易记录的删除
deleteTransaction(id: number): void {
let index = -1;
for (let i = 0; i < this.transactions.length; i++) {
if (this.transactions[i].id === id) {
index = i;
break;
}
}
if (index !== -1) {
let deletedAmount = this.transactions[index].amount;
let deletedType = this.transactions[index].type;
this.transactions.splice(index, 1);
if (deletedType === 'income') {
this.totalAmount -= deletedAmount;
} else {
this.totalAmount += deletedAmount;
}
this.saveData();
}
}
删除操作的处理逻辑:
- 通过 ID 查找要删除的记录索引。
- 记录删除前的金额和类型。
- 从数组中移除该记录。
- 反向更新总金额:删除收入则减去对应金额,删除支出则加上对应金额。
- 持久化更新后的数据。
5.5 数字格式化
由于 ArkTS 限制标准库的使用,不能使用 toFixed 方法,因此需要自定义数字格式化方法:
formatNumber(num: number): string {
let str = num.toString();
if (str.indexOf('.') === -1) {
str += '.00';
} else {
let parts = str.split('.');
if (parts[1].length === 1) {
str += '0';
} else if (parts[1].length > 2) {
str = parts[0] + '.' + parts[1].substring(0, 2);
}
}
return str;
}
该方法将数字格式化为保留两位小数的字符串:
- 如果没有小数部分,添加 “.00”。
- 如果只有一位小数,补零到两位。
- 如果小数部分超过两位,截断到两位。
六、开发过程中的技术挑战与解决方案
6.1 ArkTS 类型系统的严格约束
ArkTS 对类型系统有严格的约束,禁止使用 any 和 unknown 类型,这在与 JSON 数据打交道时带来了挑战。在解析从 Preferences 中读取的数据时,原始的 JSON 解析结果类型不确定,需要使用显式类型声明:
解决方案: 定义 TransactionData 接口,明确声明数据的类型结构,然后使用类型断言将解析结果转换为指定类型:
interface TransactionData {
id: number;
amount: number;
category: string;
type: string;
remark: string;
date: string;
timestamp: number;
}
let parsed = JSON.parse(saved as string) as TransactionData[];
在 parseTransactions 方法中,使用 typeof 进行运行时类型检查,为每个字段提供默认值,确保数据的完整性:
t.id = typeof item.id === 'number' ? item.id : Date.now();
t.amount = typeof item.amount === 'number' ? item.amount : 0;
6.2 @Builder 方法的纯 UI 约束
ArkTS 要求 @Builder 方法中只能包含 UI 组件代码,不能包含变量声明和数据处理逻辑。这在需要将数据传递给子组件时带来了困难。
解决方案: 将数据处理逻辑从 @Builder 方法中移出,放到普通的方法中执行,然后将处理结果通过参数传递给 @Builder 方法:
// 在 build() 或普通方法中处理数据
let income = this.getMonthIncome();
let expense = this.getMonthExpense();
let categoryStats = this.getCategoryStats();
// 将处理结果通过参数传递给 @Builder 方法
this.buildStatsPage(income, expense, categoryStats);
@Builder
buildStatsPage(income: number, expense: number, categoryStats: CategoryStat[]): void {
// 纯 UI 代码
}
6.3 状态变量不能声明在 build() 方法中
ArkTS 不允许在 build() 方法中声明局部变量,这限制了在该方法中进行复杂的数据准备。
解决方案: 将数据准备工作封装在独立的普通方法中(如 getCurrentYear()、getCurrentMonth()),然后在 build() 方法中直接调用这些方法:
getCurrentYear(): number {
return this.detailDate.getFullYear();
}
getCurrentMonth(): number {
return this.detailDate.getMonth();
}
或者在 @Builder 方法中直接使用内联表达式替代变量声明:
@Builder
buildStatCard(title: string, value: number): void {
Column({ space: 8 }) {
Text(title)
Text('¥' + this.formatNumber(value))
.fontColor(value >= 0 ? '#27AE60' : '#E74C3C')
}
}
6.4 数据持久化的异步处理
Preferences API 的操作是异步的,需要使用 async/await 处理。在 aboutToAppear 生命周期方法中调用异步方法时,需要注意异步操作的执行时机。
解决方案: 在 aboutToAppear 中同时调用 initPreferences 和 loadData,由于 loadData 内部已经包含了 Preferences 初始化的检查,即使 initPreferences 的异步操作尚未完成,loadData 也会尝试重新初始化:
aboutToAppear(): void {
this.initPreferences();
this.loadData();
}
在 loadData 和 saveData 方法中,首先检查 Preferences 是否已初始化,如果没有则调用 initPreferences 进行初始化,然后继续执行后续操作。
6.5 弹窗的状态管理
弹窗的显示和隐藏通过 @State 变量控制,但弹窗内部的状态(如输入的金额和备注)需要与外部状态隔离,避免在未确认的情况下影响主界面的数据。
解决方案: 使用独立的 @State 变量管理弹窗状态:
@State showAmountDialog: boolean = false;
@State dialogAmount: string = '';
@State dialogRemark: string = '';
弹窗内部的操作只修改这些独立的状态变量,只有在点击"确认"按钮后,才将 dialogAmount 和 dialogRemark 的值用于创建交易记录。点击"取消"按钮时,直接清空这些临时状态并关闭弹窗,不会影响主界面的数据。
七、应用价值与使用场景
7.1 个人财务管理的价值
个人财务管理是现代人生活中不可或缺的一部分。通过本应用,用户可以实现:
收支可视化: 通过首页的总金额、月收入、月支出和结余等指标,用户可以直观地了解自己的财务状况。收支对比和分类占比统计帮助用户发现消费热点,制定合理的预算计划。
消费习惯培养: 定期的记账行为本身就是一种消费反思。当用户需要手动记录每一笔支出时,会更加谨慎地对待每一次消费决策。长期坚持记账,能够有效培养量入为出的消费习惯。
历史数据查询: 明细页面支持按月份浏览历史交易记录,用户可以回顾过去的消费情况,分析消费趋势,为未来的财务规划提供数据支持。
7.2 教育价值
对于 HarmonyOS 开发者而言,本项目具有较高的学习和参考价值:
ArkTS 实战范例: 项目涵盖了 ArkTS 的核心特性,包括状态管理、组件化开发、声明式 UI、生命周期管理等,为初学者提供了完整的实战范例。
类型安全实践: 项目严格遵循 ArkTS 的类型系统约束,展示了如何在强类型环境下进行应用开发,包括接口定义、类型断言、运行时类型检查等技术。
数据持久化方案: 项目展示了如何使用 @ohos.data.preferences 进行本地数据存储,包括数据的序列化、反序列化、错误处理等关键环节。
7.3 扩展性分析
本项目的设计具有良好的扩展性,可以在此基础上添加更多功能:
数据图表: 可以集成 HarmonyOS 提供的图表组件,将收支数据以柱状图、饼图、折线图等形式展示,提升数据的可读性。
预算管理: 可以为每个分类设置月度预算,当某类支出接近或超过预算时给出提醒,帮助用户控制消费。
数据导出: 可以将交易数据导出为 CSV 或 Excel 文件,便于用户在电脑上进行更复杂的分析。
多账户支持: 可以扩展为多账户模式,支持现金、银行卡、支付宝、微信等多种支付方式的独立记账。
云同步: 可以集成华为账号服务,实现数据的云端同步,确保数据在多个设备之间保持一致。
八、总结与展望
8.1 项目总结
本文详细介绍了一个基于 HarmonyOS ArkTS 开发的个人记账本应用的技术实现。从数据模型设计到用户界面开发,从业务逻辑实现到数据持久化,涵盖了应用开发的完整生命周期。
在技术层面,本项目展示了如何在 ArkTS 的严格类型约束下进行应用开发,如何设计合理的数据模型和状态管理机制,如何实现流畅的用户交互体验。特别是在处理 @Builder 方法的纯 UI 约束、状态变量的类型限制、异步数据操作等 ArkTS 特有的问题时,项目提供了实用的解决方案。
在功能层面,应用实现了收支记录、分类统计、明细查询、数据持久化等核心功能,满足了个人财务管理的基本需求。界面设计简洁美观,操作流程清晰流畅,为用户提供了良好的使用体验。
8.2 技术展望
随着 HarmonyOS 生态的不断完善,ArkTS 开发框架也在持续演进。未来可以期待以下技术发展方向:
更丰富的组件库: HarmonyOS 将持续丰富官方组件库,提供更多开箱即用的 UI 组件,降低界面开发的工作量。
更强大的状态管理: 除了 @State,ArkTS 可能会引入更多高级状态管理方案,如跨组件状态共享、状态持久化等,满足复杂应用的需求。
更完善的开发工具: DevEco Studio 作为 HarmonyOS 的官方 IDE,将持续优化开发体验,提供更智能的代码提示、更便捷的调试工具和更完善的性能分析能力。
更广泛的多端部署: HarmonyOS 的分布式能力将进一步增强,开发者可以更轻松地实现应用在手机、平板、智慧屏、车机等多种设备上的无缝部署和协同工作。
8.3 结语
HarmonyOS 作为中国自主研发的操作系统,正以其独特的技术优势和生态价值吸引着越来越多的开发者。ArkTS 作为 HarmonyOS 应用开发的核心语言,融合了声明式 UI 和强类型系统的优势,为开发者提供了高效、可靠的开发体验。
本项目的实践表明,基于 ArkTS 开发功能完善、体验优秀的移动应用是完全可行的。虽然在开发过程中会遇到类型约束、异步处理等技术挑战,但通过合理的设计和最佳实践,这些挑战都可以得到有效解决。
对于有志于投身 HarmonyOS 生态建设的开发者而言,掌握 ArkTS 开发技术不仅是一种技术储备,更是参与国产操作系统生态建设、推动技术自主可控的重要一步。随着 HarmonyOS 生态的蓬勃发展,ArkTS 开发技能将拥有越来越广阔的应用前景和市场价值。
附录:完整代码目录结构
entry/src/main/ets/pages/
└── Index.ets # 主页面,包含所有 UI 和逻辑
entry/src/main/resources/base/profile/
└── main_pages.json # 页面路由配置
参考文献
- 华为开发者联盟. HarmonyOS 应用开发文档. https://developer.harmonyos.com/
- 华为开发者联盟. ArkTS 语言快速入门. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-get-started-0000001637740666-V3
- 华为开发者联盟. 声明式 UI 开发指南. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-declarative-ui-description-0000001524535265-V3
- TypeScript 官方文档. https://www.typescriptlang.org/docs/
- 华为开发者联盟. 数据管理开发指南. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/data-persistence-preferences-0000001505555321-V3
本文基于 HarmonyOS ArkTS 个人记账本应用的技术实践撰写,旨在为 HarmonyOS 开发者提供技术参考和学习资料。文中涉及的代码和技术方案均经过实际验证,可作为类似应用开发的参考实现。
更多推荐



所有评论(0)