日常收支记账本 APP——鸿蒙 ArkTS 本地数据 CRUD 完整实战



技术栈: HarmonyOS 5.0 (API 24) · ArkTS · ArkUI · relationalStore(关系型数据库)
📋 目录
- 项目概述
- 技术选型与架构设计
- 数据库层设计
- 状态管理设计
- UI 布局拆解
- CRUD 完整流程
- 表单处理与状态同步
- 组件化与 @Builder 复用
- 用户交互反馈
- 完整代码解析
- 踩坑记录与最佳实践
- 总结与扩展方向
1. 项目概述
1.1 这是什么?
"日常收支记账本"是一个运行在鸿蒙系统上的单文件记账应用。它使用 ArkTS 语言和 ArkUI 框架构建,通过鸿蒙内置的 relationalStore(关系型数据库引擎)实现本地数据的持久化存储。用户可以在手机上记录每天的收支流水,查看余额、收入和支出汇总,以及对记录进行删除管理。
1.2 为什么选这个项目?
对于鸿蒙开发者来说,数据持久化是几乎所有应用的基础能力。relationalStore 是 API 24 中用于关系型数据存储的核心模块,其底层基于 SQLite,提供了完整的 SQL 能力。通过这个项目,你可以学到:
- 如何在 ArkTS 中操作关系型数据库(建表、增删查改)
- 如何用
@State驱动 UI 自动更新 - 如何构建完整的表单页面并处理用户输入
- 如何用
@Builder实现组件复用 - 如何做 Toast 反馈、确认弹窗等用户交互
1.3 最终效果
应用界面分为三个区域:
| 区域 | 功能 |
|---|---|
| 顶部卡片 | 本月结余、收入汇总、支出汇总 |
| 中间表单 | 类型切换(支出/收入)、分类选择、金额/日期/备注输入、添加按钮 |
| 底部列表 | 按时间倒序显示所有记录,每条可删除 |
整体采用深色主题,视觉风格统一简洁。
2. 技术选型与架构设计
2.1 ArkTS — 鸿蒙的声明式语言
ArkTS 是 TypeScript 的超集,在标准 TypeScript 的基础上增加了鸿蒙的声明式 UI 能力。与 React/Vue 的思路类似,ArkTS 使用装饰器(Decorators)来标记状态变量和组件。
核心装饰器速览:
| 装饰器 | 作用 |
|---|---|
@Entry |
标记页面入口 |
@Component |
标记自定义组件 |
@State |
声明状态变量,变化时自动触发 UI 更新 |
@Builder |
标记可复用的 UI 片段 |
2.2 架构层次
整个应用虽然只用一个文件 Index.ets,但代码逻辑清晰地分为三层:
┌─────────────────────────────────────┐
│ UI 层 (build) │
│ 余额卡片 · 表单 · 记录列表 · Toast │
├─────────────────────────────────────┤
│ 业务逻辑层 (methods) │
│ addRecord · deleteRecord · calc... │
├─────────────────────────────────────┤
│ 数据层 (relationalStore) │
│ initDB · loadRecords · insert/del │
└─────────────────────────────────────┘
这种分层让代码易于维护和扩展。
2.3 API 版本说明
本文使用 HarmonyOS 5.0 API 24。relationalStore 在 API 24 中已经成熟稳定,与 API 23 相比,主要区别在于:
getContext(this)替代了getContext(UIAbility)的写法AlertDialog.show()参数形式保持一致relationalStore.RdbPredicates的 API 签名没有破坏性变化
3. 数据库层设计
3.1 数据模型
每条记账记录的数据结构如下:
interface RecordRow {
id: number // 主键,自增
type: number // 0=支出, 1=收入
category: string // 分类名,如"餐饮"、"工资"
amount: number // 金额
date: string // 日期,格式 YYYY-MM-DD
note: string // 备注
createdAt: string // 创建时间,ISO 8601
}
3.2 数据库配置类型
ArkTS 要求显式声明内联对象的类型,因此我们额外定义了一个配置接口:
interface RdbStoreConfig {
name: string
securityLevel: number
}
3.3 数据库初始化
数据库初始化放在 aboutToAppear 生命周期中,这是组件初始化时最早执行的回调:
aboutToAppear() {
this.formDate = this.getToday()
this.initDB()
}
initDB() 的核心流程:
getContext(this) → 获取上下文
↓
relationalStore.getRdbStore() → 打开/创建数据库
↓
store.executeSql() → 执行 CREATE TABLE IF NOT EXISTS
↓
this.loadRecords() → 加载已有数据
关键代码:
initDB() {
try {
const context = getContext(this)
const config: RdbStoreConfig = {
name: 'accountbook.db',
securityLevel: relationalStore.SecurityLevel.S1
}
relationalStore.getRdbStore(context, config).then((store) => {
this.rdbStore = store
return store.executeSql(
`CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type INTEGER NOT NULL,
category TEXT NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
note TEXT DEFAULT '',
createdAt TEXT NOT NULL
)`
)
}).then(() => {
this.loadRecords()
}).catch((e: Error) => {
console.error('DB init error:', JSON.stringify(e))
})
} catch (e) {
console.error('DB init exception:', JSON.stringify(e))
}
}
注意:
relationalStore.SecurityLevel.S1表示安全等级 S1(低安全),适合本地记账数据。如果涉及敏感数据,应使用 S2/S3/S4。
3.4 安全等级说明
| 等级 | 常量 | 说明 |
|---|---|---|
| S1 | SecurityLevel.S1 |
低安全,数据丢失后不影响用户核心资产 |
| S2 | SecurityLevel.S2 |
中安全,数据丢失后有一定影响 |
| S3 | SecurityLevel.S3 |
高安全,数据丢失后有严重影响 |
| S4 | SecurityLevel.S4 |
极高安全,关键隐私数据 |
记账应用用 S1 足够了。
3.5 SQL 建表语句解析
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键
type INTEGER NOT NULL, -- 收支类型
category TEXT NOT NULL, -- 分类
amount REAL NOT NULL, -- 金额(浮点数)
date TEXT NOT NULL, -- 日期(文本存储)
note TEXT DEFAULT '', -- 备注(可空)
createdAt TEXT NOT NULL -- 创建时间
)
几个设计决策:
- 日期用 TEXT 而非 DATE 类型:SQLite 没有原生 DATE 类型,TEXT 存储 YYYY-MM-DD 格式便于排序和查询。
- 金额用 REAL 而非 INTEGER:涉及小数(分),使用浮点数更自然。注意不要直接用浮点数做等值比较运算。
- id 用 AUTOINCREMENT:保证每次插入生成唯一 ID,删除后不会复用旧 ID。
4. 状态管理设计
4.1 @State 变量一览
ArkTS 的 @State 装饰器标记的变量一旦变化,所有依赖它的 UI 部分会自动重新渲染。
本应用共使用 9 个 @State 变量:
@State records: RecordRow[] = [] // 数据库记录列表
@State totalIncome: number = 0 // 收入总计
@State totalExpense: number = 0 // 支出总计
@State balance: number = 0 // 结余
@State recordCount: number = 0 // 记录条数
@State formType: number = 0 // 表单:类型
@State formCategory: string = '餐饮' // 表单:分类
@State formAmount: string = '' // 表单:金额
@State formDate: string = '' // 表单:日期
@State formNote: string = '' // 表单:备注
@State toastMsg: string = '' // Toast 反馈消息
@State showForm: boolean = true // 控制表单显示(用于重置)
4.2 数据流
用户操作 → 修改 @State → UI 自动更新
↓
触发方法(addRecord/deleteRecord)
↓
操作数据库 → 重新 loadRecords → calcTotals
↓
修改 @State records/totalIncome/... → UI 刷新
这是一个典型的单向数据流模式:
- 用户点击"添加记录"
addRecord()被调用- 数据写入数据库
loadRecords()从数据库重新查询calcTotals()计算汇总值- 所有
@State变量更新 - ArkUI 自动重新渲染 UI
4.3 为什么表单字段不用双向绑定
你可能注意到了,代码中并没有使用 ArkUI 的双向绑定语法(如 TextInput({ text: $$this.formAmount })),而是统一使用单向绑定加 onChange:
TextInput({ placeholder: '输入金额' })
.onChange((v: string) => { this.formAmount = v })
原因有二:
- 更灵活:可以在
onChange中添加过滤、格式化等逻辑。 - 更可控:双向绑定在 ArkUI 中有时会出现状态同步滞后的问题,单向绑定 + onChange 更可靠。
4.4 @State 的更新时机
ArkTS 的 @State 更新是异步批量处理的。同一个事件循环内的多次赋值会合并为一次渲染。这意味着:
this.totalIncome = 1000
this.totalExpense = 500
this.balance = 500
这三行只会触发一次 UI 更新,而不是三次。
5. UI 布局拆解
5.1 整体结构
build() 方法返回一个纵向布局(Column),内部包含三个主要区域:
build() {
Column() {
// 1. 顶部 - 余额卡片
// 2. 中间 - 记录表单
// 3. 底部 - 记录列表 + Toast
}
}
5.2 顶部余额卡片
余额卡片的 UI 设计要点:
- 使用
Column包裹,整体圆角 16,深色背景#1A2744 - 结余文字大小 36,加粗,收入/支出则为 18
- 结余颜色根据正负切换:收入时绿色
#2ECC71,支出时红色#E74C3C - 收入与支出之间用一条细线(1px 宽 36px 高的 Column)分隔
金额格式化函数:
formatAmount(val: number): string {
if (val === 0) return '0.00'
return val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
这里用正则 /\B(?=(\d{3})+(?!\d))/g 实现千分位逗号分隔,例如 1234567.89 显示为 1,234,567.89。
5.3 中间表单
表单是整个应用的数据录入入口,包含以下控件:
| 控件 | 类型 | 说明 |
|---|---|---|
| 类型切换 | Button × 2 | 支出/收入,高亮选中项 |
| 分类选择 | Scroll + Row + 标签 | 支出/收入各有独立分类组 |
| 金额 | TextInput(Number) | 数字键盘,右对齐 |
| 日期 | Text + TextInput | Text 显示当前值,TextInput 供修改 |
| 备注 | TextInput | 可选输入 |
| 添加按钮 | Button | 触发 addRecord |
5.4 底部记录列表
使用 List 组件配合 ForEach 渲染记录:
List({ space: 8 }) {
ForEach(this.records, (record: RecordRow) => {
ListItem() {
this.RecordRow(record)
}
}, (record: RecordRow) => String(record.id))
}
List 相对于 Scroll+Column 的优势:
- 内置懒加载:只渲染可见区域的项
- 原生滚动性能:更流畅
- 支持列表专属属性:如
space(项间距)
每条记录行显示:
- 左侧:分类 emoji 图标(28px 大小)
- 中间:分类名 + 金额(右上角)+ 日期 + 备注
- 右侧:删除按钮(×)
金额显示带正负号:
Text((record.type === 0 ? '-' : '+') + ' ¥' + this.formatAmount(record.amount))
.fontColor(record.type === 0 ? '#E74C3C' : '#2ECC71')
5.5 Toast 反馈
Toast 是用纯 ArkUI 组件手写的,而不是调用系统弹窗:
if (this.toastMsg !== '') {
Text(this.toastMsg)
.fontSize(15)
.fontColor('#FFFFFF')
.backgroundColor('#334466CC')
.borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ bottom: 20 })
.shadow({ radius: 8, color: '#00000044' })
.transition({ type: TransitionType.Insert, opacity: 0 })
}
这种方式的优点:
- 不打断用户操作(非模态,不影响背景交互)
- 完全自定义样式
- 自带的
transition动画实现淡入效果
6. CRUD 完整流程
6.1 Create — 添加记录
完整流程图:
用户点击"添加记录"
↓
addRecord() 被调用
↓
① 校验金额:parseFloat > 0
↓ 失败 → showToast('请输入有效金额')
② 校验日期:length >= 8
↓ 失败 → showToast('请输入有效日期')
③ 校验数据库:rdbStore != null
↓ 失败 → showToast('数据库未就绪')
④ 构造 ValuesBucket
↓
⑤ rdbStore.insert('records', row)
↓ 成功 ↓ 失败
showToast('✅ 添加成功') showToast('❌ 添加失败')
重置表单 输出错误日志
重新加载列表
代码实现:
addRecord() {
const amount = parseFloat(this.formAmount)
if (isNaN(amount) || amount <= 0) {
this.showToast('请输入有效金额')
return
}
if (!this.formDate || this.formDate.length < 8) {
this.showToast('请输入有效日期')
return
}
if (!this.rdbStore) {
this.showToast('数据库未就绪')
return
}
const now = new Date().toISOString()
const row: relationalStore.ValuesBucket = {
type: this.formType,
category: this.formCategory,
amount: amount,
date: this.formDate,
note: this.formNote,
createdAt: now
}
this.rdbStore.insert('records', row).then(() => {
this.showToast('✅ 添加成功')
// 重置表单
this.formAmount = ''
this.formNote = ''
this.formDate = this.getToday()
this.showForm = false
setTimeout(() => { this.showForm = true }, 50)
this.loadRecords()
}).catch((e: Error) => {
this.showToast('❌ 添加失败:' + e.message)
console.error('Insert error:', JSON.stringify(e))
})
}
6.2 Read — 查询记录
loadRecords() 从数据库读取所有记录,按创建时间倒序排列:
loadRecords() {
if (!this.rdbStore) return
try {
const predicates = new relationalStore.RdbPredicates('records')
predicates.orderByDesc('createdAt')
this.rdbStore.query(predicates, [
'id', 'type', 'category', 'amount', 'date', 'note', 'createdAt'
]).then((resultSet: relationalStore.ResultSet) => {
const list: RecordRow[] = []
while (resultSet.goToNextRow()) {
const row: RecordRow = {
id: resultSet.getLong(0),
type: resultSet.getLong(1),
category: resultSet.getString(2),
amount: resultSet.getDouble(3),
date: resultSet.getString(4),
note: resultSet.getString(5),
createdAt: resultSet.getString(6)
}
list.push(row)
}
resultSet.close()
this.records = list
this.calcTotals()
})
} catch (e) {
console.error('Load error:', JSON.stringify(e))
}
}
关键点:
- RdbPredicates:相当于 SQL 的 WHERE/ORDER BY 子句构建器。这里使用
orderByDesc('createdAt')让最新记录排在最前。 - ResultSet 遍历:
goToNextRow()逐行移动指针,getLong/getString/getDouble按列索引取值。 - 务必关闭 ResultSet:
resultSet.close()释放数据库资源,否则可能造成内存泄漏。 - 列索引从 0 开始:对应查询时传入的列名数组顺序。
查询性能优化建议(当数据量增大时):
- 使用
limit和offset实现分页 - 添加 WHERE 条件按月份筛选,而非全表查询
- 为
createdAt字段建立索引
6.3 Delete — 删除记录
删除流程分为两步:
- 确认弹窗(防止误删)
- 执行删除
confirmDelete(id: number) {
AlertDialog.show({
title: '删除记录',
message: '确定要删除这条记录吗?',
autoCancel: true,
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '删除',
fontColor: '#E74C3C',
action: () => {
this.deleteRecord(id)
}
}
})
}
deleteRecord(id: number) {
if (!this.rdbStore) return
const predicates = new relationalStore.RdbPredicates('records')
predicates.equalTo('id', id)
this.rdbStore.delete(predicates).then(() => {
this.loadRecords()
}).catch((e: Error) => {
console.error('Delete error:', JSON.stringify(e))
})
}
AlertDialog.show() 的参数结构:
| 参数 | 类型 | 说明 |
|---|---|---|
| title | string | 弹窗标题 |
| message | string | 弹窗内容 |
| autoCancel | boolean | 点击遮蔽层是否自动关闭 |
| primaryButton | Button | 主按钮(通常是取消) |
| secondaryButton | Button | 次按钮(通常是确认操作) |
6.4 Update — 更新记录
当前版本没有实现修改功能。实际上,修改的实现思路和删除类似:
// 思路示例(非本应用代码)
updateRecord(id: number, newAmount: number) {
const row: relationalStore.ValuesBucket = {
amount: newAmount
}
const predicates = new relationalStore.RdbPredicates('records')
predicates.equalTo('id', id)
this.rdbStore.update(row, predicates).then(() => {
this.loadRecords()
})
}
可以作为一个练习:给每条记录加一个"编辑"按钮,点击后弹出编辑窗口,修改金额或备注后保存。
7. 表单处理与状态同步
7.1 表单重置的挑战
在 ArkUI 中,一个常见的问题是:清空了 @State 变量,但 TextInput 的显示文本没有清空。
这是因为 TextInput 内部维护了自己的文本缓冲区。当 @State 变量通过 onChange 更新时,TextInput 接受新的输入;但当 @State 被直接赋值为 '' 时,TextInput 并不一定会清除已有文本。
7.2 解决方案:条件重建
我们的解决方案是销毁并重建 TextInput:
@State showForm: boolean = true
// 添加成功后
this.formAmount = ''
this.formNote = ''
this.formDate = this.getToday()
this.showForm = false
setTimeout(() => {
this.showForm = true // 重建表单,所有 TextInput 回到初始状态
}, 50)
UI 部分:
if (this.showForm) {
Column() {
// 所有表单控件...
}
}
当 showForm 变为 false 时,ArkUI 销毁整个表单 Column 及其所有子组件。50ms 后 showForm 变回 true,组件被重新创建,TextInput 的初始值就是 placeholder,干净整洁。
为什么不直接用
.key()?
在 API 24 中,.key()仅在测试目录中可用,生产构建会报警告。条件重建是更稳妥的方式。
7.3 日期字段的预处理
为了让日期输入更友好,我们在 aboutToAppear 时预填当天日期:
aboutToAppear() {
this.formDate = this.getToday()
this.initDB()
}
同时界面上同时显示一个只读的 Text 和一个可编辑的 TextInput:
Text('📅 ' + this.formDate) // 显示当前值
TextInput({ placeholder: '修改日期 (YYYY-MM-DD)' }) // 供修改
这样用户既能看到日期值,又能在需要时修改。
7.4 表单校验
三关卡校验:
| 关卡 | 校验条件 | 错误提示 |
|---|---|---|
| 金额 | parseFloat > 0 |
请输入有效金额 |
| 日期 | length >= 8 |
请输入有效日期 |
| 数据库 | rdbStore != null |
数据库未就绪 |
校验点前置可以避免无意义的数据库操作,提升用户体验。
8. 组件化与 @Builder 复用
8.1 @Builder 装饰器
@Builder 是 ArkTS 中定义可复用 UI 片段的装饰器。它类似于 React 中的函数组件或 Vue 中的 slot。
本应用使用了两个 @Builder:
- CategoryChip:分类标签
- RecordRow:记录行
8.2 CategoryChip 组件
@Builder
CategoryChip(cat: string) {
Column() {
Text((this.catEmojis[cat] || '📋') + ' ' + cat)
.fontSize(13)
.fontWeight(this.formCategory === cat ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.formCategory === cat ? '#FFFFFF' : '#AABBCC')
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.formCategory === cat ? '#3498DB' : '#0D1A33')
.borderRadius(20)
.onClick(() => {
this.onCategoryTap(cat)
})
}
选中的标签高亮(蓝色背景 + 白色文字 + 加粗),未选中的为深色背景 + 浅色文字。这种"胶囊式"标签在移动端应用中非常常见。
8.3 RecordRow 组件
@Builder
RecordRow(record: RecordRow) {
Row() {
// 分类 emoji 图标
Text(this.catEmojis[record.category] || '📋')
.fontSize(28)
.width(44).height(44)
.textAlign(TextAlign.Center)
// 中间信息
Column() {
Row() {
Text(record.category).fontSize(15).fontColor('#CCDDEE')
Blank()
Text((record.type === 0 ? '-' : '+') + ' ¥' + this.formatAmount(record.amount))
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(record.type === 0 ? '#E74C3C' : '#2ECC71')
}.width('100%')
Row() {
Text(record.date).fontSize(12).fontColor('#667788')
if (record.note !== '') {
Text(' · ' + record.note).fontSize(12).fontColor('#667788')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
Blank()
}.width('100%').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
// 删除按钮
Button('×').width(32).height(32).fontSize(18)
.fontColor('#667788').backgroundColor('transparent')
.borderRadius(16)
.onClick(() => { this.confirmDelete(record.id) })
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 8 })
.backgroundColor('#1A2744')
.borderRadius(12)
.alignItems(VerticalAlign.Center)
}
8.4 为什么用 @Builder 而不是自定义 @Component?
| @Builder | @Component | |
|---|---|---|
| 复杂度 | 简单 UI 片段 | 复杂独立组件 |
| 参数传递 | 直接作为方法参数 | 通过 struct 属性 |
| 状态隔离 | 共享父组件状态 | 私有状态 |
| 复用范围 | 当前组件内 | 跨文件 |
| 代码量 | 少 | 多 |
对于这种单文件应用,@Builder 足够。如果未来将分类标签或记录行抽取为独立文件,才需要考虑 @Component。
9. 用户交互反馈
9.1 Toast 反馈系统
我们实现了一个轻量级的 Toast 系统,代替系统原生弹窗:
触发机制:
showToast(msg: string) {
this.toastMsg = msg
setTimeout(() => { this.toastMsg = '' }, 2000)
}
显示逻辑:
if (this.toastMsg !== '') {
Text(this.toastMsg)
.fontSize(15)
.fontColor('#FFFFFF')
.backgroundColor('#334466CC')
.borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ bottom: 20 })
.shadow({ radius: 8, color: '#00000044' })
.transition({ type: TransitionType.Insert, opacity: 0 })
}
当 toastMsg 为空时,if 条件不成立,Text 组件不在组件树中,不占空间。当设置为消息时,Text 出现并自动淡入(通过 transition)。
这种实现方式比 AlertDialog 更轻量,不打断用户操作。
9.2 确认删除弹窗
使用 AlertDialog.show() 实现原生弹窗:
AlertDialog.show({
title: '删除记录',
message: '确定要删除这条记录吗?',
autoCancel: true,
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '删除',
fontColor: '#E74C3C',
action: () => {
this.deleteRecord(id)
}
}
})
两个按钮风格不同:取消按钮默认样式,删除按钮用红色强调其破坏性。
9.3 视觉反馈
除了文字反馈,UI 中的颜色变化也提供了即时反馈:
- 余额正负切换颜色(绿 ↔ 红)
- 选中分类高亮(蓝底白字)
- 类型切换按钮颜色变化(支出红色、收入绿色)
- 金额前缀 +/- 与颜色一致
这些细节构成了完整的用户体验。
10. 完整代码解析
10.1 文件结构(536 行)
Index.ets (536 行)
├── import 声明 (Line 1)
├── 数据模型接口 (Lines 3-18)
│ ├── RecordRow
│ └── RdbStoreConfig
├── @Component struct Index (Lines 20-536)
│ ├── @State 变量 (Lines 22-38)
│ ├── 私有常量 (Lines 40-48)
│ │ ├── expenseCats / incomeCats
│ │ └── catEmojis
│ ├── 数据库引用 (Line 51)
│ ├── 生命周期 (Lines 54-57)
│ ├── 工具方法 (Lines 59-68)
│ ├── 数据库操作 (Lines 70-128)
│ │ ├── initDB
│ │ └── loadRecords
│ ├── 业务逻辑 (Lines 131-237)
│ │ ├── calcTotals
│ │ ├── addRecord
│ │ ├── deleteRecord
│ │ ├── confirmDelete
│ │ ├── onCategoryTap
│ │ ├── switchType
│ │ └── showToast
│ ├── build() UI (Lines 239-453)
│ │ ├── 顶部余额卡片
│ │ ├── 中间表单 (if showForm)
│ │ ├── 底部记录列表
│ │ └── Toast 反馈
│ └── @Builder 组件 (Lines 455-536)
│ ├── CategoryChip
│ └── RecordRow
10.2 关键代码段速览
数据库初始化 (15 行):
// getContext → getRdbStore → executeSql → loadRecords
查询 + 遍历 (28 行):
// RdbPredicates + orderByDesc + query + ResultSet.goToNextRow
添加记录 (40 行):
// 三关校验 → ValuesBucket → insert → Toast → 重置 → reload
删除记录 (14 行 + 确认弹窗 16 行):
// confirmDelete → AlertDialog → delete → reload
UI 构建 (215 行):
// Column + Row + Text + TextInput + Button + List + ForEach + @Builder
10.3 获取完整代码
完整代码在项目 entry/src/main/ets/pages/Index.ets 中。如果是从头开始:
- 用 DevEco Studio 创建新项目(Empty Ability 模板)
- 将
Index.ets的完整内容粘贴覆盖 - 确保
oh-package.json5中依赖正确(本应用无外部依赖) - 运行即可
11. 踩坑记录与最佳实践
11.1 ArkTS 中的类型声明
问题:relationalStore.getRdbStore() 的配置参数如果没有显式声明类型,在 API 24 中会报错。
解决:声明独立的 interface:
interface RdbStoreConfig {
name: string
securityLevel: number
}
然后再使用:
const config: RdbStoreConfig = {
name: 'accountbook.db',
securityLevel: relationalStore.SecurityLevel.S1
}
11.2 异步操作的错误处理
问题:getRdbStore、insert、query、delete 都是 Promise 异步操作。不 catch 的错误会导致静默失败。
解决:每个 .then() 后面跟 .catch():
this.rdbStore.insert('records', row).then(() => {
// 成功处理
}).catch((e: Error) => {
this.showToast('❌ 添加失败:' + e.message)
console.error('Insert error:', JSON.stringify(e))
})
并在外层用 try-catch 包围同步代码:
try {
// async operations
} catch (e) {
console.error('...', JSON.stringify(e))
}
11.3 TextInput 状态不同步
问题:清空 @State 后界面不清空。
解决:使用条件重建。
@State showForm: boolean = true
// 重置时
this.showForm = false
setTimeout(() => { this.showForm = true }, 50)
UI 用 if (this.showForm) 包裹表单。
11.4 innerText 与同步问题
问题:在 TextInput 的 onChange 回调中读取 this.formAmount 可能拿到旧值。
解决:onChange 的参数就是最新值,直接用:
TextInput().onChange((v: string) => {
this.formAmount = v // v 就是当前输入内容
})
11.5 AlertDialog.show() 已废弃
在 API 24 中,AlertDialog.show() 被标记为废弃(deprecated)。但它在 SDK 24 中仍然可用。替代方案是使用 AlertDialogParamWithMultipleButtons 或自定义弹窗组件(使用 @CustomDialog)。
不过对于大多数场景,AlertDialog.show() 仍然是最简单直接的方式。
11.6 数字格式化注意
formatAmount(val: number): string {
if (val === 0) return '0.00'
return val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
toFixed(2) 确保保留两位小数。但要注意:
- 浮点数精度问题:
0.1 + 0.2不等于0.3(IEEE 754 标准)。对于记账应用,建议用整数(分)来存储和计算,显示时再转为元。 - 正则中的
\B表示非单词边界(即数字之间),(?=(\d{3})+(?!\d))表示从右向左每三位一组的位置。
11.7 数据库性能考量
当前实现简单直接:每次 CRUD 操作后重新加载全部记录。当记录数较少时(<1000 条)这完全没有问题。如果记录量增大,可以考虑:
// 分页查询示例
const predicates = new relationalStore.RdbPredicates('records')
predicates.orderByDesc('createdAt')
predicates.limit(pageSize, offset) // 限制返回条数
12. 总结与扩展方向
12.1 学到了什么
通过这个"日常收支记账本"项目,我们完整实践了:
- ✅ 在 ArkTS 中声明数据模型(interface)
- ✅ 使用 relationalStore 进行本地数据持久化
- ✅ 数据库的建表、增、查、删操作
- ✅ 使用 @State 管理 UI 状态
- ✅ 使用 @Builder 复用 UI 片段
- ✅ ArkUI 布局编排(Column/Row/Scroll/List/ForEach)
- ✅ 表单校验与用户反馈(Toast + AlertDialog)
- ✅ 条件重建解决 TextInput 状态同步问题
12.2 扩展方向
这个应用虽然功能完整,但还有很多可以拓展的方向:
功能增强:
| 功能 | 技术路径 |
|---|---|
| 编辑记录 | rdbStore.update() |
| 月度筛选 | predicates.contains('date', '2025-01') |
| 数据统计图表 | Charts 组件或 Canvas 绘制饼图/柱状图 |
| 搜索功能 | predicates.like('note', '%关键词%') |
| 数据导出 | @ohos.file.fs 写入 CSV/JSON 文件 |
| 导出分享 | Share Kit 系统分享能力 |
体验优化:
- 左滑删除(List 的
swipeAction属性) - 下拉刷新(
@ohos.pullToRefresh或 Refresh 组件) - 数据备份到云端(Cloud DB Kit)
- 夜间模式 / 主题切换
- 启动页动画
架构升级:
- 按模块拆分文件(数据层 / UI 层 / 组件层)
- 引入 MVVM 模式
- 添加单元测试(
@ohos.test)
12.3 写在最后
记账本是一个"麻雀虽小五脏俱全"的典型 CRUD 应用。它涵盖了移动应用开发中最核心的环节——数据持久化、状态管理、UI 布局、用户交互——而这些恰好是初学者最容易卡住的地方。
通过这一个文件 536 行代码,我们走完了从需求到实现、从数据到 UI、从功能到体验的完整链路。希望这篇文章能帮助你在 HarmonyOS 开发的路上走得更稳、更远。
附录 A:API 参考
| 模块 | 类/接口 | 用途 |
|---|---|---|
@ohos.data.relationalStore |
getRdbStore() |
打开/创建数据库 |
RdbStore |
数据库操作实例 | |
RdbPredicates |
SQL 条件构建器 | |
ResultSet |
查询结果集 | |
ValuesBucket |
插入/更新的数据载体 | |
SecurityLevel |
安全等级常量 |
更多推荐



所有评论(0)