在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

技术栈: HarmonyOS 5.0 (API 24) · ArkTS · ArkUI · relationalStore(关系型数据库)


📋 目录

  1. 项目概述
  2. 技术选型与架构设计
  3. 数据库层设计
  4. 状态管理设计
  5. UI 布局拆解
  6. CRUD 完整流程
  7. 表单处理与状态同步
  8. 组件化与 @Builder 复用
  9. 用户交互反馈
  10. 完整代码解析
  11. 踩坑记录与最佳实践
  12. 总结与扩展方向

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 刷新

这是一个典型的单向数据流模式:

  1. 用户点击"添加记录"
  2. addRecord() 被调用
  3. 数据写入数据库
  4. loadRecords() 从数据库重新查询
  5. calcTotals() 计算汇总值
  6. 所有 @State 变量更新
  7. ArkUI 自动重新渲染 UI

4.3 为什么表单字段不用双向绑定

你可能注意到了,代码中并没有使用 ArkUI 的双向绑定语法(如 TextInput({ text: $$this.formAmount })),而是统一使用单向绑定加 onChange

TextInput({ placeholder: '输入金额' })
  .onChange((v: string) => { this.formAmount = v })

原因有二:

  1. 更灵活:可以在 onChange 中添加过滤、格式化等逻辑。
  2. 更可控:双向绑定在 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 按列索引取值。
  • 务必关闭 ResultSetresultSet.close() 释放数据库资源,否则可能造成内存泄漏。
  • 列索引从 0 开始:对应查询时传入的列名数组顺序。

查询性能优化建议(当数据量增大时):

  • 使用 limitoffset 实现分页
  • 添加 WHERE 条件按月份筛选,而非全表查询
  • createdAt 字段建立索引

6.3 Delete — 删除记录

删除流程分为两步:

  1. 确认弹窗(防止误删)
  2. 执行删除
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:

  1. CategoryChip:分类标签
  2. 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 中。如果是从头开始:

  1. 用 DevEco Studio 创建新项目(Empty Ability 模板)
  2. Index.ets 的完整内容粘贴覆盖
  3. 确保 oh-package.json5 中依赖正确(本应用无外部依赖)
  4. 运行即可

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 异步操作的错误处理

问题getRdbStoreinsertquerydelete 都是 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 安全等级常量
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐