一、要做什么:一个不丢账的记账小工具
三句话讲完:一个 ArkTS 单机记账工具,记一笔(金额、类目、备注)、按月看流水、删错账;不登录、不联网,数据只活在本机。功能一分钟说完,但“账不能丢”这个约束会让每个看起来很小的决策都变重——没有云端兜底,持久化选错了、状态刷新漏了,丢的就是用户真金白银的记录。所以本文重心不在界面怎么摆(布局看上一篇《ArkUI 布局与导航》),而在三条决策链:本地持久化怎么选、状态管理用 V1 还是 V2、列表怎么在千条流水下不卡。
环境默认就绪(《鸿蒙环境搭建与工具链》),DevEco Studio 能真机或模拟器跑起应用。开工前把验收指标定死,每条带”怎么测”:
| 指标 | 目标 | 怎么测 |
|---|---|---|
| 数据持久 | 杀进程重开一条不丢 | 录 3 笔 → 从任务切换器划掉应用 → 重开核对 |
| 离线可用 | 无网状态功能完整 | 开飞行模式走完一轮记、查、删 |
| 输入校验 | 非法金额不落库 | 金额输 abc / 0 / -5 点记一笔:应弹提示且列表无新行 |
| 千条流畅 | 千条流水滚动无感知卡顿(定性目标) | 灌 1000 条后用 release 包真机滚列表,DevEco Profiler 看帧率 |
| 汇总正确 | 分类合计 = 流水总和 | 对照页面上的分类汇总与手工加总 |
二、整体架构:三层就够,别上全家桶
分层一条线:
UI(@Entry 页面 + RecordRow 列表项)
└─ 视图模型(页面内的 @Local 状态 + 数据源 RecordSource) // 谁持有"正在显示什么"
└─ 仓库层(LedgerStore,全应用唯一摸数据库的类) // 查询、事务、游标全关在这层
└─ 持久层(RelationalStore 存流水 + preferences 存偏好)
纪律只有一条:UI 不直接碰数据库连接,仓库层不 import 任何页面组件。这条线画清楚后,换状态方案、换存储方案,各层互不连坐。
克制建议也放这里:不要第一个项目就上分布式数据同步、全局 AppStorage 满天飞、自研 ORM。单机记账没有多端一致性需求,AppStorage 滥用等于把全局变量合法化;等真有跨设备诉求再上分布式 KV 不迟。瓶颈从来不在框架能力上限,在你对问题本身的理解。
三、决策链一:持久化,RelationalStore 还是 preferences
鸿蒙本地持久化两件套:RelationalStore(关系型数据库,底层 SQLite)与 preferences(用户首选项,键值对)。不是二选一,是各管一摊,先把边界摆开:
| 维度 | preferences 首选项 | RelationalStore 关系库 |
|---|---|---|
| 数据模型 | Key-Value,值是 number/string/boolean 及数组 | 表、行、列,完整 SQL |
| 内存行为 | 全量加载进内存,数据越多占用越大 | 按查询取用,游标逐行读 |
| 查询能力 | 只能按 key 取,没有条件查询 | WHERE / 索引 / GROUP BY / 事务 |
| 量级建议 | 配置类小数据,社区共识别过万条 | 结构化业务数据,万级起步无压力 |
| 典型内容 | 深色模式、默认类目、上次选中月份 | 账单流水、聊天记录、订单 |
记账的主数据是流水:要按月过滤、按类目汇总、按时间倒序——这三件事 SQL 一句话的事,键值库要全量拉进内存自己筛。流水进 RelationalStore,偏好进 preferences,这个选型其实没有悬念。反模式点名:把流水存 preferences。它每条都是独立 key、无事务、全量驻内存,千条之后每次启动的加载和每次写入的 flush 都是成本,而且”记两笔字段更新到一半被杀”在无事务的存储里就是脏数据。
仓库层完整实现(可运行,注意看注释里的”为什么”):
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export class LedgerRecord {
id: number = 0;
amount: number = 0;
category: string = '';
note: string = '';
createdAt: number = 0;
}
export class LedgerStore {
private rdb?: relationalStore.RdbStore;
async init(context: common.UIAbilityContext): Promise<void> {
this.rdb = await relationalStore.getRdbStore(context, {
name: 'ledger.db',
securityLevel: relationalStore.SecurityLevel.S1 // 本机私有数据,最低敏级即可
});
await this.rdb.executeSql(`CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount REAL NOT NULL,
category TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL)`);
await this.rdb.executeSql(
'CREATE INDEX IF NOT EXISTS idx_created ON records(created_at)'); // 按月查全表扫描变索引定位
}
async add(amount: number, category: string, note: string): Promise<void> {
await this.rdb!.insert('records', {
amount: amount, category: category, note: note, created_at: Date.now()
});
}
async deleteById(id: number): Promise<void> {
const p = new relationalStore.RdbPredicates('records').equalTo('id', id);
await this.rdb!.delete(p);
}
async listAll(): Promise<LedgerRecord[]> {
const p = new relationalStore.RdbPredicates('records')
.orderByDesc('created_at'); // 排序交给 SQL,别拉回来再 sort
const rs = await this.rdb!.query(p, ['id', 'amount', 'category', 'note', 'created_at']);
const list: LedgerRecord[] = [];
while (rs.goToNextRow()) {
const r = new LedgerRecord();
r.id = rs.getLong(rs.getColumnIndex('id'));
r.amount = rs.getDouble(rs.getColumnIndex('amount'));
r.category = rs.getString(rs.getColumnIndex('category'));
r.note = rs.getString(rs.getColumnIndex('note'));
r.createdAt = rs.getLong(rs.getColumnIndex('created_at'));
list.push(r);
}
rs.close(); // ResultSet 不关,游标句柄一直占着,列表页反复刷新就会累积
return list;
}
async summaryByCategory(): Promise<Map<string, number>> {
// 汇总这种事是 SQL 的主场:GROUP BY 在库里做完,只把几行结果带回内存
const rs = await this.rdb!.querySql(
'SELECT category, SUM(amount) AS total FROM records GROUP BY category');
const map = new Map<string, number>();
while (rs.goToNextRow()) {
map.set(rs.getString(rs.getColumnIndex('category')),
rs.getDouble(rs.getColumnIndex('total')));
}
rs.close();
return map;
}
async seedForTest(n: number): Promise<void> {
// 压测灌数据:事务包起来。逐条自动提交一千次,慢一个数量级还容易中途失败
this.rdb!.beginTransaction();
try {
for (let i = 0; i < n; i++) {
await this.add(10 + i % 90, i % 3 === 0 ? '餐饮' : (i % 3 === 1 ? '交通' : '购物'), '压测数据');
}
this.rdb!.commit();
} catch (e) {
this.rdb!.rollback();
throw e;
}
}
}
preferences 这摊也给了,存”默认类目”这类真偏好:
import { preferences } from '@kit.ArkData';
const pf = await preferences.getPreferences(context, { name: 'ledger_prefs' });
await pf.put('defaultCategory', '餐饮');
await pf.flush(); // flush 之前数据只在内存,杀进程就丢——首选项最经典的坑
const saved = await pf.get('defaultCategory', '餐饮') as string;
四、决策链二:状态管理,V1 还是 V2
ArkTS 状态管理有两代并存,这是鸿蒙当前最需要”如实标注取舍”的选型。两边现状:
| 维度 | V1(@State/@Prop/@Link/@Provide) | V2(@Local/@Param/@Event/@ObservedV2/@Trace) |
|---|---|---|
| 可用性 | 早期即有,全部版本可写 | API 12 起提供并稳定 |
| 更新粒度 | 组件级,深层数组元素、嵌套属性变更常观察不到 | 属性级依赖追踪,@Trace 标到哪刷到哪 |
| 教程生态 | 存量博客、第三方示例大多还是 V1 | 官方主推但社区资料仍少 |
| 混用规则 | 同一组件内两代装饰器不能混用;组件树层面 V1/V2 组件可以互相嵌套 | |
本项目的取舍:新工程、compileSdk 直接以当前 Release 的 API 12 为目标,整页统一 V2 写法(@Entry 搭 @ComponentV2 完全成立)。代价也如实说:网上搜来的示例九成是 @State 写法,你要会做”翻译”——@State 对应 @Local、父子传值从赋值变成 @Param + @Event。翻译规则就这两条,成本可控。
另一个取舍在细粒度上:V2 的正路是模型类挂 @ObservedV2 + @Trace 做属性级刷新,但那要求每处改数据都走模型方法。记账场景每次操作后都整表重读(refresh 全量替换数组引用),简单直接,千条量级毫无压力——我们选引用替换,放弃属性级追踪,这是有意识的简化而不是不会用。页面完整实现:
@Entry
@ComponentV2
struct IndexPage {
@Local total: number = 0; // @Local:V2 里替代 @State 的组件内部状态
@Local input: string = '';
@Local category: string = '餐饮';
@Local summary: string = '';
store: LedgerStore = new LedgerStore();
source: RecordSource = new RecordSource(); // LazyForEach 的数据源,上一篇讲过
aboutToAppear(): void {
// 建库完成后再拉数据,顺序不能反:先查询会把"表还不存在"炸在你脸上
this.store.init(getContext(this) as common.UIAbilityContext)
.then(() => this.refresh());
}
refresh(): Promise<void> {
return this.store.listAll().then(list => {
this.source.reload(list); // 数据源通知 LazyForEach 重载,页面随之重建
return this.store.summaryByCategory();
}).then(map => {
let t = 0;
let s = '';
map.forEach((v: number, k: string) => { t += v; s += `${k} ¥${v.toFixed(2)} `; });
this.total = t; // 汇总也来自数据库,不来自前端累加,两边永远一致
this.summary = s;
});
}
build() {
Column({ space: 12 }) {
Text(`合计 ¥${this.total.toFixed(2)}`).fontSize(18)
Text(this.summary).fontSize(13).fontColor('#666')
Row({ space: 8 }) {
TextInput({ placeholder: '金额' })
.type(InputType.NUMBER_DECIMAL)
.onChange(v => this.input = v)
.layoutWeight(1)
Select([{ value: '餐饮' }, { value: '交通' }, { value: '购物' }])
.selected(0)
.onSelect((_idx: number, value: string) => this.category = value)
}
Button('记一笔')
.width('100%')
.onClick(() => {
const amount = Number(this.input);
if (Number.isNaN(amount) || amount <= 0) {
// 校验挡在写入之前,库里就不会有脏数据;V2 用 UIContext 拿提示能力
this.getUIContext().getPromptAction().showToast({ message: '金额不合法' });
return;
}
this.store.add(amount, this.category, '').then(() => this.refresh());
})
List({ space: 8 }) {
LazyForEach(this.source, (item: LedgerRecord) => {
RecordRow({
record: item,
onRemove: (id: number) => {
this.store.deleteById(id).then(() => this.refresh());
}
})
}, (item: LedgerRecord) => item.id.toString())
}
.cachedCount(6)
.layoutWeight(1) // 吃掉剩余高度,列表区域有界才能触发懒加载
.divider({ strokeWidth: 1, color: '#EEEEEE' })
}
.padding(16)
.height('100%')
}
}
@ComponentV2
struct RecordRow {
@Param record: LedgerRecord = new LedgerRecord(); // @Param:父传子的只读入参
@Event onRemove: (id: number) => void = () => {}; // @Event:子组件的回调出口
build() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.record.category).fontSize(14)
Text(this.record.note).fontSize(12).fontColor('#999')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(`¥${this.record.amount.toFixed(2)}`).fontSize(14)
Button('删')
.controlSize(ControlSize.NORMAL)
.onClick(() => this.onRemove(this.record.id))
}
}
}
反模式点名两处。其一:在 @ComponentV2 里写 @State、@Link——两代装饰器同组件混用直接编译不过,正确动作是整组件切换,嵌套时父 V2 子 V1(或反过来)可以,但要清楚数据只在引用变化时流动。其二:把查询写进 build 或.aboutToAppear 之外的重建路径——build 每次刷新都会跑,查询塞进去等于每个动画帧打一次数据库,正确位置是事件回调末尾统一走 refresh()。
五、决策链三:列表性能,千条是个分水岭
LazyForEach + cachedCount + 键值用业务主键,这套在上一篇已拆透,这里只讲本项目的取舍。千条流水:LazyForEach 足够,不需要组件复用。复用(V1 的 @Reusable、自带复用的 Repeat 组件)解决的是”列表项组件很重、反复创建销毁开销大”,记账的行组件就两个文本一个按钮,复用带来的收益低于引入的复杂度。先量化再优化:Profiler 里滚动帧率没问题,就不要为想象中的瓶颈写代码。真到十万条流水的量级,该做的是分页查询(LIMIT/OFFSET 或游标按月翻页),那也是数据库的活,不是列表组件的活。
六、验证:照表逐条过,别凭感觉
| 验收项 | 操作 | 预期 |
|---|---|---|
| 持久 | 录 3 笔 → 划掉进程 → 重开 | 3 笔全在,合计一致 |
| 校验 | 金额分别输 abc、0、-5 后记一笔 | toast 提示,列表与合计无变化 |
| 删除 | 删 1 笔 | 列表移除,合计立即减去该笔 |
| 千条压测 | 调 seedForTest(1000) → release 包真机滚动 | 无白块、无感知卡顿(定性) |
| 汇总正确 | 对照 summaryByCategory 与手工加总 | 两类数字一致 |
两个测试口径必须说死:一是性能一律用 release 包,debug 包的校验和日志会拖垮帧率,测出来的”卡”不是应用的卡;二是持久化测试必须杀进程,开发期的热重载保留页面状态,测的是内存不是磁盘。
七、总结与边界
本项目教会了三件事:持久化按数据性质分家(结构化流水进 RelationalStore,偏好进 preferences,判断标准是要不要查询与事务);状态管理新工程统一 V2,会做 V1 示例的翻译即可,粒度取舍跟着数据量走;列表性能先 LazyForEach 后量化,复用与分页都是”测出来需要”才上。边界也如实:这个工具不做多端同步与图表(那是下一个量级的事),千条以上要分页。下一篇《鸿蒙 ArkTS 学习路线》会把系列六篇串成一条线,走完这条线的下一个动作是脱离教程做个变体——读书清单、打卡器,决策链自己走一遍才算你的。API 细节随用随查《ArkTS 参考字典》。
版本核实(2025-01):HarmonyOS 当前商用版本为 HarmonyOS NEXT 5.0(API 12),relationalStore 与 preferences 自 API 9 起提供、状态管理 V2 装饰器(@ComponentV2/@Local/@Param/@Event/@ObservedV2/@Trace)自 API 12 起稳定;文中”V1/V2 同组件不可混用、组件树可嵌套”以华为官方状态管理文档为准。