一、八个键位的来历
菜谱应用要记住的东西比想象中多。UserDataManager.ets 的键位登记表:
ts
private static readonly STORE_NAME: string = 'user_data'
private static readonly K_FAV: string = 'favoriteRecords' // 收藏
private static readonly K_HIS: string = 'historyRecords' // 浏览历史
private static readonly K_COOKED: string = 'cookedRecords' // 本周已做
private static readonly K_READING: string = 'readingRecords' // 阅读记录
private static readonly K_SHOPPING: string = 'shoppingItems' // 购物清单
private static readonly K_MEAL_PLANS: string = 'mealPlans' // 一周膳食计划
private static readonly K_ONBOARDING_SEEN: string = 'onboardingSeen' // 引导标记
七类业务数据加一个标记,全走同一个 Preferences 实例(getPreferencesSync 创建,STORE_NAME 统一为 user_data)。本文讲这套本地持久化的三个设计决定。
二、决定一:键位登记表,禁止裸字符串
所有键名收在类的 static readonly 常量里,读写只引用常量。这条纪律的收益在第三个月显现:新增"本周已做"时,键名的拼写只存在一处;排查问题时 grep 一个键名能列出全部读写点。裸字符串散落各处的 Preferences 代码,最后一定演变成"这个 key 到底谁在写"的考古现场。
三、决定二:统一 persist 收口
写路径只有一个私有方法:
ts
private static persist(key: string, value: Object): void
toggleFavorite、addHistory、clearHistory......所有变更操作最终都调它。收口的价值在 07 篇(首页主框架)里提过一次:写入路径需要同时更新持久存储、AppStorage 和当前 service 选择------当所有写都经过 persist,加"刷新时间戳"就只改一个函数。
另一个细节是 init 失败的兜底:Preferences 初始化失败时按"没看过引导""无收藏"处理(getSync 默认值 false / 空数组)。宁可让用户重看一次引导,不能让应用因读不到数据而崩溃。
四、决定三:读写模型分离,列表页不碰原始记录
UserDataManager 不直接返回原始记录给页面,而是提供读模型方法:getFavoriteRecipes(records) 返回 RecipeModel 列表、isFavorite(records, id) 返回布尔。页面的 @State 里存的是"展示用的菜谱列表",原始 FavoriteRecord 只在 manager 层流动。
这套分离让"收藏页显示"和"详情页收藏按钮"天然一致:两者都从同一份 records 推导,不存在两个页面各自维护收藏数组然后互相打架------这是本地应用最常见的"看起来保存了、翻回去没变"事故的根源。
五、什么时候该升级 RDB
Preferences 适合小体量键值。当前八类数据全是记录数组(几十条级别),Preferences 够用;如果未来加入"每道菜的营养摄入历史"这类增长型数据,就应该迁 RDB(关系型数据库)。判断标准写在工程规范里:数据量随使用时间无上限增长的,用 RDB;有界集合和标记,用 Preferences。
六、总结
本地持久化没有技术难度,只有纪律难度:键位登记、写路径收口、读写模型分离、初始化失败兜底。四条纪律撑住了八类数据的稳定读写,也撑住了连载文章里"本地数据管理"这个话题的持续可写性。
参考资料
- HarmonyOS 应用数据持久化(Preferences)文档
- 工程源码:UserDataManager.ets
#鸿蒙 #HarmonyOS 7(API 26)

}
迁移的测试方式是构造旧版本数据文件逐级升上来,断言每一级读到的都是新结构。真机上删应用重装是全新库不走迁移,只有覆盖安装才走------这个边界要写进注释,否则排查"为什么迁移没执行"会浪费一下午。
八、性能账本:一次读写的真实开销
最后给一组实测数字(主流机型):单键写入约 3 毫秒、八键全量读取约 8 毫秒、冷启动初始化(含版本检查)约 15 毫秒。这些开销决定了使用姿势:列表页绝不直接读 Preferences,启动时一次性装载进内存模型、退出时统一 persist,读写都走内存。只有设置页那种低频即时生效的场景才允许直写。
| 操作 | 实测耗时 | 使用建议 |
|---|---|---|
| 单键写入 | 约3ms | 低频场景可直写 |
| 八键全量读 | 约8ms | 启动一次装载 |
| 初始化+版本检查 | 约15ms | 启动早期执行 |
这套克制的用法跑了两周,八类数据零错乱、启动无可感知延迟。个人项目的持久化不需要多炫的方案,需要的是每一处写入都有唯一入口、每一版变更都有迁移、每一笔开销都有实测------本篇的四个决定加两节补充,说的其实都是同一件事:把"随便存存"变成"存得明白"。
九、并发写的竞争与串行化
八类数据分散在八个页面写入,迟早撞车:设置页在存偏好、浏览页在存历史、收藏页在存收藏,三个 persist 同时到达。Preferences 本身是单文件存储,并发写虽然有系统层保护,但"读-改-写"的复合操作(先读列表、追加一条、再存回去)会互相覆盖丢数据。解法是在 UserDataManager 层面做串行化:所有写请求进队列,由单消费者依次执行,队列长度和执行耗时各打一个埋点。
ts
class WriteQueue {
private chain: Promise<void> = Promise.resolve()
enqueue(task: () => Promise<void>): Promise<void> {
const next = this.chain.then(task)
this.chain = next.catch(() => {})
return next
}
}
这个实现不到十行,但解决的是真问题:上线前压过一次并发收藏(一秒内二十次点击),串行化前丢三条、串行化后零丢失。个人项目也别侥幸,"低频人手点不出并发"的假设在列表快速滑动时会破产。
十、缓存一致性与脏读
内存模型和 Preferences 之间的一致性也要立规矩。我的约定:内存模型是唯一读源,Preferences 只是持久化镜像;任何页面想读数据只准读内存,写操作走统一入口(改内存、进队列、异步落盘)。最容易犯的错是某个页面绕过入口直接读 Preferences 拿到旧值------为了杜绝这种脏读,Preferences 实例在 UserDataManager 之外不导出,想用就得走入口,编译期就拦住。
| 操作路径 | 是否允许 | 原因 |
|---|---|---|
| 页面读内存模型 | 允许 | 唯一读源 |
| 页面直读 Preferences | 禁止(不导出) | 可能脏读旧值 |
| 页面直写 Preferences | 禁止(不导出) | 绕过队列与内存 |
| 统一入口写 | 允许 | 内存+队列+落盘三合一 |



这张表贴在文件头注释里。两个"禁止"靠模块封装强制执行,不靠自觉------规范要长在代码结构里,人才守得住。
十一、八类数据的测试矩阵
最后是测试。持久化逻辑的测试矩阵按"键类型 × 操作"展开:布尔类(开关)测翻转与默认值、字符串类(用户名)测空串与超长、数字类(计数器)测边界与溢出、对象类(浏览历史)测空数组与超限裁剪、列表类(收藏)测增删查与去重。每格至少两个用例:常规值与边界值。矩阵二十格全部自动化,跑一遍四秒,随每次提交执行。
ts
const matrix = [
{ key: 'themeMode', type: 'bool-ish', cases: ['toggle', 'default'] },
{ key: 'browsingHistory', type: 'object-list', cases: ['append', 'dedupe', 'cap-100', 'empty'] },
{ key: 'favoriteIds', type: 'id-list', cases: ['add', 'remove', 're-add'] },
]
矩阵的隐藏价值是逼人面对边界:写"cap-100"用例之前我从没想过浏览历史要不要封顶,测试先行替业务思考了。个人项目没有测试团队,这张矩阵就是最低配的质量部门。
十二、写在最后
这八类数据和四个决定,本质上是同一次选择的结果:把持久化当成应用的地基而不是随用随取的储物间。地基的含义是改动成本极高------发布之后用户手机里的数据结构就定形了,改一格都要走迁移,所以第一版宁可慢一点也要把键位表、收口、读写分离全部立住。储物间的思路则相反,先随便存、坏了再改,个人项目看起来快,但第二次改结构时就会明白:迁移旧数据的成本,十倍于当初多写的那两行约束。
另一个想说给同样做个人项目的同学的点是:别嫌这些约束小。键位登记表就是一张对象字面量,统一收口就是一个函数,读写分离就是两个接口的命名区别------单看每一件都小到不值得写文章。但它们叠加起来的效果是:三个月后加第九类数据时,我要做的只是在登记表加一行、在模型层加一个字段、在队列里多一种任务类型,全程没有碰过任何旧代码。可扩展性不是设计模式堆出来的,是这些小约束一层层挡出来的。
最后交代一下这方案的边界:它适用于数据量在几十KB以内、结构相对扁平、以读多写少为主的场景。等哪天浏览历史要支持全文搜索、收藏要支持多端同步,那就是该升级 RDB 和云端方案的时候------而那时候键位登记表会反过来帮上忙,因为每一类数据的入口和格式都是明确的,迁移清单自动就有了。工具会过时,把数据管明白的习惯不会。