鸿蒙提供了三套本地数据方案:Preferences(键值)、RDB(关系型)、KV Store(分布式键值)。
三套的名字很直白,但真到项目里,最常见的错是用 Preferences 装本该进 RDB 的东西。开始没事,数据涨到几百条以后,性能问题会以一种很难定位的方式出现。
这篇是一次迁移记录:一个用 Preferences 存了 400 多条数据的应用,怎么迁到 RDB,以及为什么这一步不能省。
一、问题的显现方式
症状很怪:
- 应用启动慢,但 Profiler 显示主线程没占满
- 列表滚动时偶发卡顿,抓不到固定的卡顿点
- 偶尔出现「数据丢失」,重启后又好了
三个症状看起来不相关,查到最后是同一个原因:Preferences 在每次 put 之后都会触发全量写盘。
Preferences 的机制是把整个键值库读进内存,改动后序列化整体写回。它适合存少量配置(用户偏好、开关、token),不适合存列表数据。
我这个应用把「消息已读状态」存在 Preferences 里,key 是 read_${msgId},400 多条消息就是 400 多个键。
每次标记已读,整个库序列化写一次。库越大,单次写越慢。这就是「偶发卡顿」的来源——不是每次都卡,取决于当时的库大小和写盘时机。
「数据丢失」是另一个原因:写盘是异步的,应用被杀时未落盘的改动会丢。Preferences 会尝试 flush,但不保证。
二、先判断该用哪个:三条分界线
迁之前先把选择标准立清楚,不然迁完还是错的。
分界线一:数据量。
几十条以内、结构简单的键值 → Preferences。
上百条、有查询需求的结构化数据 → RDB。
需要跨设备同步 → KV Store。
分界线二:有没有查询需求。
只要「按 key 取一个值」→ Preferences 够用。
需要「按条件筛选、排序、分页、聚合」→ RDB。用 Preferences 做这些,等于自己在内存里写一个数据库。
分界线三:写入频率。
一次写入、长期读取(配置项)→ Preferences。
高频写入(状态变更、日志、消息)→ RDB。RDB 是行级写入,改一行写一行,不像 Preferences 全量序列化。
三条一过,结论很清楚:消息已读状态属于「数据量大 + 有查询需求 + 高频写入」,三项全中,必须 RDB。
反过来,主题色、语言、登录 token 这类,继续留在 Preferences 是对的。
三、迁移的四步
第一步:建表和初始化
RDB 的第一步是拿到 RdbStore。鸿蒙的写法是 getRdbStore 配一个 StoreConfig:
import { relationalStore } from '@kit.ArkData'
const CONFIG: relationalStore.StoreConfig = {
name: 'app.db',
securityLevel: relationalStore.SecurityLevel.S2
}
const store = await relationalStore.getRdbStore(context, CONFIG)
await store.execute(`
CREATE TABLE IF NOT EXISTS message_read (
msg_id TEXT PRIMARY KEY,
read_at INTEGER NOT NULL,
session_id TEXT NOT NULL
)
`)
await store.execute(
'CREATE INDEX IF NOT EXISTS idx_session ON message_read(session_id)'
)
两个细节值得说:
securityLevel 必须显式给。 S1 到 S4,S1 是最低(设备解锁后可读)。给错了要么开不了库,要么数据保护级别不够。这个参数没有默认值,漏了直接编译不过——这点比运行时才报错好。
索引要建。 我这个应用最常用的查询是「某个会话下未读几条」,session_id 上有索引和没索引,400 条数据时差 10 倍,4000 条时差 100 倍。
第二步:迁移数据
从 Preferences 读出来,批量写进 RDB。
const prefs = await preferences.getPreferences(context, 'read_state')
const all = await prefs.getAll() // 拿到全部键值
const rows: relationalStore.ValuesBucket[] = []
for (const [key, val] of Object.entries(all)) {
if (!key.startsWith('read_')) continue
rows.push({
msg_id: key.slice(5),
read_at: val as number,
session_id: '' // 旧数据没有这个字段,先留空
})
}
// 批量插入必须包事务
await store.beginTransaction()
try {
for (const row of rows) {
await store.insert('message_read', row,
relationalStore.ConflictResolution.ON_CONFLICT_REPLACE)
}
await store.commit()
} catch (e) {
await store.rollBack()
throw e
}
事务这一步不能省。 400 条数据逐条 insert 不开事务,每条都是一次独立的写盘,实测 8.2 秒。包在事务里,0.4 秒。
这个差别在真机上是「迁移时应用假死 8 秒」和「一闪而过」的区别。
ON_CONFLICT_REPLACE 是为了幂等——迁移中途被杀,重跑不会撞主键。
第三步:改成按需查询
迁完最关键的改动不是存储,是读取方式变了。
原来:应用启动时把 400 多个 key 全部读进内存,判断某条消息是否已读靠内存查找。
现在:不预加载,用到时查。
单条查询在 400 条数据下是毫秒级,但列表要显示 20 条消息时,逐条查就是 20 次。更好的做法是一次查一批:
async function readSet(msgIds: string[]): Promise<Set<string>> {
const predicates = new relationalStore.RdbPredicates('message_read')
predicates.in('msg_id', msgIds)
const rs = await store.query(predicates, ['msg_id'])
const set = new Set<string>()
while (rs.goToNextRow()) {
set.add(rs.getString(rs.getColumnIndex('msg_id')))
}
rs.close()
return set
}
in 查询一次搞定,20 条和 1 条的开销差不多。
rs.close() 必须调。 ResultSet 不 close 会占着游标,查多了会报「too many open cursors」。这个错在测试时不容易发现,压力一大就出来。
第四步:清理旧数据
迁完把 Preferences 里那 400 多个键删掉。
const keys = Object.keys(await prefs.getAll())
for (const k of keys) {
if (k.startsWith('read_')) await prefs.delete(k)
}
await prefs.flush()
别整库 clear()。 同一个 Preferences 库里还有别的配置项(主题、语言),清库会一起清掉。
四、迁移后的数据
| 指标 | 迁移前(Preferences) | 迁移后(RDB) |
|---|---|---|
| 应用启动耗时 | 1240 ms | 680 ms |
| 标记已读单次耗时 | 40~120 ms | 2 ms |
| 列表滚动卡顿 | 偶发 | 无 |
| 数据丢失 | 出现过 3 次 | 0 |
启动耗时降了 560 毫秒,主要是省掉了「把 400 多个 key 全读进内存」这一步。
五、三个容易踩的坑
坑一:store 要写成单例。
getRdbStore 本身有开销(打开文件、校验版本),每次调用都重新 get 等于每次重开库。第一次拿到之后存起来复用。
坑二:RDB 的异步不等于不占主线程。
await store.query() 是异步,但结果的组装和反序列化在主线程上。一次查 5000 行,组装那一步能卡 200 毫秒。大结果集要分页。
坑三:securityLevel 改了要迁移。
从 S1 改成 S2,旧库打不开,会静默创建一个新空库——数据看起来「全丢了」。改安全级别之前要先导出数据。
六、自学选型最容易错的地方
三处。
第一处:不确定「该用哪个」。 三套方案的文档各自都很清楚,但没人告诉你分界线在哪。判断方法就是前面那三条:数据量、有没有查询、写入频率。
第二处:忘了事务。 批量写入不开事务,测试时数据量小(20 条)看不出来,上线后数据量大就假死。这个坑的可怕之处是它在开发环境不显现。
第三处:ResultSet 不 close。 报错信息是「too many open cursors」,但它不告诉你是哪个查询泄的。排查方法是在每个 query 后面加日志计数。
这三处的共同点:文档都写了,但不写在「入门第一页」。要踩到才知道去哪找。
顺带说一句,这类「三套方案怎么选」的判断,用誉天教育打比方,它的鸿蒙课上讲法是先给三条分界,再让学员拿自己的项目对一遍。这个顺序比先讲 API 有用——因为选错了,API 背得再熟也是白干。
最后
Preferences 和 RDB 不是「简单版」和「高级版」的关系,是两种不同的存储模型。
Preferences 是整个库当一条记录,改一个键要重写全库。RDB 是按行改,改一行写一行。
数据涨起来之后,前者的问题会以「偶发卡顿 + 莫名数据丢失」的形式出现,这两个症状都不会指向存储方案选错了。
所以我的建议是:动手前先数一下会有多少条数据。超过 200 条,直接上 RDB。