HarmonyOS 本地存储实战:用一个记账本案例吃透 RDB 与 KVStore
-
- 一、为什么我建议你用"记账本"来学本地存储
- 二、先看最终案例:一个双存储职责拆分的轻账本
- [三、RDB 负责结构化数据:把账单明细稳稳接住](#三、RDB 负责结构化数据:把账单明细稳稳接住)
-
- [1. 建表逻辑](#1. 建表逻辑)
- [2. 插入和查询,不只是"会用 API",而是要能喂给页面](#2. 插入和查询,不只是“会用 API”,而是要能喂给页面)
- [3. 删除动作为什么也值得单独封装](#3. 删除动作为什么也值得单独封装)
- [四、KVStore 负责轻量偏好:别让昵称和预算也去"占表"](#四、KVStore 负责轻量偏好:别让昵称和预算也去“占表”)
-
- [1. 初始化 KVStore](#1. 初始化 KVStore)
- [2. 轻量存储的读写方式](#2. 轻量存储的读写方式)
- 五、真正的关键不是存进去,而是页面怎么和存储联动
-
- [1. RDB 页面初始化:先拉偏好,再拉账单](#1. RDB 页面初始化:先拉偏好,再拉账单)
- [2. 新增账单后怎么刷新列表](#2. 新增账单后怎么刷新列表)
- [3. 为什么筛选逻辑放页面层更合理](#3. 为什么筛选逻辑放页面层更合理)
- [六、最后补几条实战经验:这部分往往比 API 本身更值钱](#六、最后补几条实战经验:这部分往往比 API 本身更值钱)
-
- [1. 什么时候选 RDB,什么时候选 KVStore](#1. 什么时候选 RDB,什么时候选 KVStore)
- [2. 金额字段在正式项目里更建议用"分"](#2. 金额字段在正式项目里更建议用“分”)
- [3. ResultSet 一定要及时释放](#3. ResultSet 一定要及时释放)
- [4. 本地存储不是"存进去"就结束,设计的是数据生命周期](#4. 本地存储不是“存进去”就结束,设计的是数据生命周期)
- 写在最后
做鸿蒙应用时,本地存储几乎绕不过去。
你可能会遇到这些非常真实的场景:
- 用户刚打开 App,就想看到上次留下来的数据,而不是一片空白。
- 页面上有些数据结构很稳定,比如订单、账单、待办列表,这类数据显然适合"表结构"。
- 页面上还有一类数据很轻,比如用户昵称、开关状态、搜索历史、预算值,这时候如果还强行建表,成本就显得有点高。
所以问题来了:
鸿蒙里到底什么时候该用关系型数据库,什么时候该用键值型存储?
这篇文章我不打算只讲 API,而是基于官方示例写一个"轻账本"应用,然后带你从 0 到 1 把 HarmonyOS 本地存储真正串起来。

这篇文章你会拿到 3 个东西:
- 一个可讲、可跑、可扩展的本地存储案例。
- 一套 RDB + KVStore 的职责拆分思路。
- 一些比 API 本身更重要的实战经验。
一、为什么我建议你用"记账本"来学本地存储
很多本地存储 Demo 最大的问题,不是 API 写错了,而是场景太弱。
比如最常见的示例就是:
- 往数据库里插一个用户名
- 再查出来
- 再删掉
当然,这样能说明 API 能用,但它很难让人真正建立"设计意识"。
而记账本这个场景刚好很适合教学:
- 账单明细是典型的结构化数据,字段稳定,适合放到 RDB。
- 用户昵称、月预算、提示语这类轻量配置,不需要建表,适合放到 KVStore。
- 页面上又天然有"新增、删除、查询、统计、筛选、回显"这几类高频动作,非常适合串起完整的数据流。
所以我们这次的设计很明确:
RDB负责账单明细KVStore负责偏好设置- 页面层只关心"怎么展示"和"什么时候刷新"
这个拆法的价值在于:你写的不是一个 Demo,而是一套以后真的能落地到业务里的存储思路。
二、先看最终案例:一个双存储职责拆分的轻账本

这个项目里,我把核心代码拆成了下面几块:
text
entry/src/main/ets
├─ constant
│ └─ Constants.ets
├─ models
│ ├─ AppPreference.ets
│ └─ BillRecord.ets
├─ pages
│ ├─ MainPage.ets
│ ├─ KvStorePage.ets
│ └─ RdbStorePage.ets
└─ store
├─ LedgerRdbStore.ets
└─ PreferenceKvStore.ets
这里有一个非常关键的思路:不要把数据库调用散落到页面各个角落。
如果你直接在页面里写:
- 建表
- 插入
- 查询
- 删除
- KV 初始化
- 偏好写入
页面会很快膨胀成一坨。后续你要写博客、讲课、扩展功能,都会非常吃力。
所以我把两类存储分别封装成两个 store:
LedgerRdbStore.ets:专门负责账单表PreferenceKvStore.ets:专门负责偏好数据
页面层做的事情就简单很多:
- 账本页
RdbStorePage:负责展示账单、统计金额、打开录入弹窗、触发刷新 - 偏好页
KvStorePage:负责展示昵称/预算/提示语,并支持编辑
这套分层其实已经很接近真实项目了。
三、RDB 负责结构化数据:把账单明细稳稳接住
先说为什么账单一定更适合放 RDB。
因为账单天然有明确字段:
idtitleamounttypecategorybillDatenote
这种数据你后面大概率还会做:
- 按日期排序
- 按类型筛选
- 按标题搜索
- 做月度汇总
- 做统计报表
这时候用关系型数据库比 KVStore 更顺手。
1. 建表逻辑
下面这段代码来自我们项目里的真实实现,负责初始化本地数据库并创建账单表:
ts
import { common } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { BillRecord } from '../models/BillRecord';
const LEDGER_TABLE_NAME: string = 'ledger_bills';
const LEDGER_COLUMNS: string[] = ['id', 'title', 'amount', 'type', 'category', 'billDate', 'note'];
class LedgerRdbStore {
private rdbStore: relationalStore.RdbStore | null = null;
init(context: common.UIAbilityContext, onReady: () => void): void {
if (this.rdbStore !== null) {
onReady();
return;
}
const storeConfig: relationalStore.StoreConfig = {
name: 'ledger_book.db',
securityLevel: relationalStore.SecurityLevel.S1
};
relationalStore.getRdbStore(context, storeConfig, (err, store) => {
if (err) {
console.error(`Failed to get RdbStore. Code:${err.code}, message:${err.message}`);
return;
}
this.rdbStore = store;
try {
this.rdbStore.executeSql(
`CREATE TABLE IF NOT EXISTS ${LEDGER_TABLE_NAME} (` +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'title TEXT NOT NULL, ' +
'amount REAL NOT NULL, ' +
'type TEXT NOT NULL, ' +
'category TEXT NOT NULL, ' +
'billDate TEXT NOT NULL, ' +
'note TEXT)'
);
onReady();
} catch (error) {
const businessError = error as BusinessError;
console.error(`Failed to create table. Code:${businessError.code}, message:${businessError.message}`);
}
});
}
}
这里有 3 个点值得你记住:
CREATE TABLE IF NOT EXISTS很重要,这能避免应用重复启动时反复建表报错。- 我把
rdbStore缓存在类里,避免页面每次打开都重新初始化。 - 表结构设计时尽量把"业务最稳定的字段"先定下来,后续扩表会更从容。
2. 插入和查询,不只是"会用 API",而是要能喂给页面

插入账单的逻辑如下:
ts
insertBill(title: string, amount: number, type: string, category: string, billDate: string, note: string,
onComplete: (success: boolean) => void): void {
if (this.rdbStore === null) {
onComplete(false);
return;
}
const value: relationalStore.ValuesBucket = {
title: title,
amount: amount,
type: type,
category: category,
billDate: billDate,
note: note
};
try {
this.rdbStore.insert(LEDGER_TABLE_NAME, value);
onComplete(true);
} catch (error) {
const businessError = error as BusinessError;
console.error(`Failed to insert bill. Code:${businessError.code}, message:${businessError.message}`);
onComplete(false);
}
}
查询全部账单的逻辑如下:
ts
queryAll(onResult: (bills: BillRecord[]) => void): void {
if (this.rdbStore === null) {
onResult([]);
return;
}
const predicates = new relationalStore.RdbPredicates(LEDGER_TABLE_NAME);
this.rdbStore.query(predicates, LEDGER_COLUMNS, (err: BusinessError, resultSet) => {
if (err) {
console.error(`Failed to query bills. Code:${err.code}, message:${err.message}`);
onResult([]);
return;
}
const bills: BillRecord[] = [];
while (resultSet.goToNextRow()) {
bills.push(new BillRecord(
resultSet.getLong(resultSet.getColumnIndex('id')),
resultSet.getString(resultSet.getColumnIndex('title')),
resultSet.getDouble(resultSet.getColumnIndex('amount')),
resultSet.getString(resultSet.getColumnIndex('type')),
resultSet.getString(resultSet.getColumnIndex('category')),
resultSet.getString(resultSet.getColumnIndex('billDate')),
resultSet.getString(resultSet.getColumnIndex('note'))
));
}
resultSet.close();
bills.sort((left: BillRecord, right: BillRecord) => {
if (left.billDate === right.billDate) {
return right.id - left.id;
}
return left.billDate < right.billDate ? 1 : -1;
});
onResult(bills);
});
}
这段查询代码里,我比较看重两个细节:
resultSet.close()一定别忘,很多初学者会在这一步留下隐患。- 查询结果不要原样塞给页面,最好在 store 层就做一轮排序和模型转换。
换句话说,页面层应该拿到"可以直接渲染的数据",而不是"数据库原始结果集"。
3. 删除动作为什么也值得单独封装
真实业务里,删除通常会带条件、确认弹窗、列表刷新,甚至埋点。
所以我没有把删除逻辑直接塞在列表组件里,而是单独放到 store 里:
ts
deleteBill(id: number, onComplete: (success: boolean) => void): void {
if (this.rdbStore === null) {
onComplete(false);
return;
}
const predicates = new relationalStore.RdbPredicates(LEDGER_TABLE_NAME);
predicates.equalTo('id', id.toString());
try {
this.rdbStore.delete(predicates);
onComplete(true);
} catch (error) {
const businessError = error as BusinessError;
console.error(`Failed to delete bill. Code:${businessError.code}, message:${businessError.message}`);
onComplete(false);
}
}
这件事看起来不大,但它会直接影响你代码后期的可维护性。
四、KVStore 负责轻量偏好:别让昵称和预算也去"占表"

接下来我们看另外一类数据:
- 账本昵称
- 月预算
- 提醒语
这类数据的特点是:
- 字段少
- 关系弱
- 更新频率低
- 不需要复杂查询
这时候用 KVStore 就非常合适。
1. 初始化 KVStore
下面是项目中的真实代码:
ts
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { distributedKVStore } from '@kit.ArkData';
import { AppPreference } from '../models/AppPreference';
const BUNDLE_NAME: string = 'com.example.localdatabase';
const STORE_ID: string = 'ledger_preference_store';
const KEY_NICKNAME: string = 'pref_nickname';
const KEY_BUDGET: string = 'pref_monthly_budget';
const KEY_MOTTO: string = 'pref_motto';
class PreferenceKvStore {
private kvStore: distributedKVStore.SingleKVStore | undefined = undefined;
private kvManager: distributedKVStore.KVManager | undefined = undefined;
init(context: common.UIAbilityContext, onReady: () => void): void {
if (this.kvStore !== undefined) {
onReady();
return;
}
const kvManagerConfig: distributedKVStore.KVManagerConfig = {
context: context,
bundleName: BUNDLE_NAME
};
try {
this.kvManager = distributedKVStore.createKVManager(kvManagerConfig);
const options: distributedKVStore.Options = {
createIfMissing: true,
encrypt: false,
backup: true,
autoSync: false,
kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
securityLevel: distributedKVStore.SecurityLevel.S1
};
this.kvManager.getKVStore<distributedKVStore.SingleKVStore>(STORE_ID, options, (err, store) => {
if (err) {
console.error(`Failed to get KVStore. Code:${err.code}, message:${err.message}`);
return;
}
this.kvStore = store;
onReady();
});
} catch (error) {
const businessError = error as BusinessError;
console.error(`Failed to create KVManager. Code:${businessError.code}, message:${businessError.message}`);
}
}
}
这里我特别想提醒一个真实踩坑点:
bundleName 一定要和工程实际包名一致。
我们这次改项目的时候,就遇到过一个典型问题:原示例里遗留了旧的 bundleName,导致 KVStore 初始化失败。这个错误不算复杂,但很隐蔽,实际项目里非常常见。
2. 轻量存储的读写方式
偏好读取逻辑如下:
ts
loadPreferences(onResult: (preference: AppPreference) => void): void {
if (this.kvStore === undefined) {
onResult(this.defaultPreference());
return;
}
const query = new distributedKVStore.Query();
this.kvStore.getResultSet(query, (err, resultSet) => {
if (err) {
console.error(`Failed to query preferences. Code:${err.code}, message:${err.message}`);
onResult(this.defaultPreference());
return;
}
let nickname: string = 'XXX';
let monthlyBudget: number = 5000;
let motto: string = 'XXX';
if (resultSet !== null) {
resultSet.moveToFirst();
while (!resultSet.isAfterLast()) {
const entry = resultSet.getEntry();
if (entry.key === KEY_NICKNAME) {
nickname = entry.value.value.toString();
} else if (entry.key === KEY_BUDGET) {
monthlyBudget = Number(entry.value.value.toString());
} else if (entry.key === KEY_MOTTO) {
motto = entry.value.value.toString();
}
resultSet.moveToNext();
}
}
onResult(new AppPreference(nickname, monthlyBudget, motto));
});
}
偏好保存逻辑如下:
ts
savePreferences(nickname: string, monthlyBudget: number, motto: string, onComplete: (success: boolean) => void): void {
this.putValue(KEY_NICKNAME, nickname, (nicknameSaved: boolean) => {
if (!nicknameSaved) {
onComplete(false);
return;
}
this.putValue(KEY_BUDGET, monthlyBudget.toFixed(0), (budgetSaved: boolean) => {
if (!budgetSaved) {
onComplete(false);
return;
}
this.putValue(KEY_MOTTO, motto, (mottoSaved: boolean) => {
onComplete(mottoSaved);
});
});
});
}
你会发现,KVStore 的心智负担明显更低:
- 不需要建表
- 不需要字段映射
- 不需要关系设计
但它也有边界:
- 不适合复杂查询
- 不适合多维统计
- 不适合强结构数据
所以别纠结"哪个更先进",关键是看数据形态。
五、真正的关键不是存进去,而是页面怎么和存储联动
很多教程讲本地存储,到"插入成功"就结束了。
但真实业务里,用户不关心你有没有 insert 成功,他只关心:
- 为什么我刚记的一笔钱没刷新出来?
- 为什么我改了预算,首页进度条没变化?
- 为什么我删了账单,统计金额还是老数据?
所以这一步特别重要:存储层变更后,页面状态怎么联动刷新。
1. RDB 页面初始化:先拉偏好,再拉账单

账本页在 aboutToAppear 里同时初始化两类数据:
ts
aboutToAppear(): void {
if (this.formDate === '') {
this.formDate = this.todayString();
}
preferenceKvStore.init(this.context, () => {
this.loadPreference();
});
ledgerRdbStore.init(this.context, () => {
this.reloadBills();
});
window.getLastWindow(this.context).then((currentWindow) => {
currentWindow.on('avoidAreaChange', (data) => {
if (data.type === window.AvoidAreaType.TYPE_KEYBOARD) {
this.keyHeight = this.getUIContext().px2vp(data.area.bottomRect.height);
}
});
});
}
这里其实已经体现出一个完整的数据流:
- 偏好页改预算
- KVStore 持久化
- 账本页重新打开
- 重新读取偏好
- 统计区直接用新的预算值计算进度
2. 新增账单后怎么刷新列表

新增账单不是只调一个 insertBill 就完了,后面还要做 UI 刷新:
ts
private saveBill(): void {
const title = this.formTitle.trim();
const amount = Number(this.formAmount.trim());
const billDate = this.formDate.trim();
if (title === '') {
this.validationMessage = 'XXX';
return;
}
if (isNaN(amount) || amount <= 0) {
this.validationMessage = 'XXX';
return;
}
if (billDate === '') {
this.validationMessage = 'XXX';
return;
}
ledgerRdbStore.insertBill(title, amount, this.formType, this.formCategory, billDate, this.formNote.trim(),
(success: boolean) => {
if (!success) {
this.validationMessage = 'XXX';
return;
}
this.isShowSheet = false;
this.reloadBills();
});
}
而 reloadBills() 的职责就是重新查库并同步页面状态:
ts
private reloadBills(): void {
ledgerRdbStore.queryAll((records: BillRecord[]) => {
this.bills = records;
this.applyFilters();
});
}
这就是我很推崇的一种做法:
- store 只管存取
- page 只管状态同步和界面表现
这样你后面要加"编辑账单""按月分组""按分类统计",都不会太痛苦。
3. 为什么筛选逻辑放页面层更合理
我们这里的筛选是:
- 全部
- 支出
- 收入
- 搜索标题或分类
这类逻辑目前我放在页面层做:
ts
private applyFilters(): void {
let result: BillRecord[] = this.bills;
if (this.activeType !== 'all') {
result = result.filter((item: BillRecord) => item.type === this.activeType);
}
if (this.keyword !== '') {
result = result.filter((item: BillRecord) => {
return item.title.indexOf(this.keyword) > -1 || item.category.indexOf(this.keyword) > -1;
});
}
this.visibleBills = result;
}
为什么不直接下沉到数据库?
因为这个 Demo 的目标是教学,当前数据量也不大。先把结构讲清楚,比一上来就把 SQL 条件拼得很复杂更重要。
等你后面数据量大了,再把筛选条件下沉到 RdbPredicates,也是很自然的一步。
六、最后补几条实战经验:这部分往往比 API 本身更值钱
文章写到这里,RDB 和 KVStore 已经都串起来了。接下来我补几条真实开发里非常有价值的经验。
1. 什么时候选 RDB,什么时候选 KVStore
你可以用一个很简单的判断法:
适合 RDB 的数据:
- 有明确字段结构
- 后续需要筛选、排序、统计
- 记录数量可能持续增长
适合 KVStore 的数据:
- 数据量小
- 更像配置项
- 不依赖复杂查询
一句话记忆就是:
能抽成"表"的,优先考虑 RDB;更像"配置"的,优先考虑 KVStore。
2. 金额字段在正式项目里更建议用"分"
我们这个教学案例里,金额用了 REAL,是为了讲解更直观。
但如果你在正式业务里做支付、账务、对账,建议把金额存成整数分,比如:
3850表示38.50
这样可以规避浮点数精度问题。
也就是说,教学案例里追求易懂,生产项目里追求稳定,这两件事不冲突。
3. ResultSet 一定要及时释放
这一点再强调一次。
很多同学在查完数据库后,能成功渲染页面,就以为结束了。但长期来看,ResultSet 不及时关闭是很容易留下资源问题的。
所以这句:
ts
resultSet.close();
不要嫌它啰嗦。
4. 本地存储不是"存进去"就结束,设计的是数据生命周期
一个成熟的本地存储设计,至少要想清楚下面几件事:
- 应用首次启动时默认值是什么
- 页面重新进入时要不要重查
- 删除后如何同步统计值
- 异常场景怎么兜底
- 后面如果升级表结构怎么办
你会发现,真正拉开差距的从来不是 API 名字记没记住,而是你有没有把数据生命周期想清楚。
写在最后
如果你是刚接触鸿蒙本地存储,我真心建议你不要从"用户名增删改查"停下来,而是尽快进入一个更像业务的案例。
这次我们用一个轻账本,把两类本地存储的职责拆得很清楚:
RDB管结构化账单KVStore管轻量偏好- 页面层负责把存储变化转成用户能看见的状态变化
当你把这一套跑顺之后,再去做类似场景的应用,思路都会很顺。