一、最高频的写操作,最不能丢的数据
菜谱应用里有一类数据的特点是"小、碎、频":收藏一条菜谱、记下看过的菜、标记本周做过------单条不到 100 字节,但每次交互都可能触发写盘,而且是用户最不能接受丢失的数据(收藏丢了会直接投诉)。1.0.4 起,这类数据由一个 UserDataManager 统一管理,本文复盘它的三层流转设计和四个关键取舍。
二、三层架构:UI 订阅、运行时聚合、磁盘兜底

最底层是 Preferences(user_data 存储),上面是 AppStorage 作为运行时单一事实源,UI 组件全部通过 @StorageLink 订阅。任何页面改收藏,走的是同一条路:操作函数返回新数组 → 写 AppStorage → 同步序列化进 Preferences。没有页面直接碰 Preferences,也没有页面自己维护副本------副本一旦出现,同步 bug 只是时间问题。
启动时的 init 用 getPreferencesSync 一次性把五类记录读进 AppStorage:同步加载保证首帧就有数据,这是"启动可容忍几十毫秒"换来的简单性。
三、取舍一:记录数组 + 单键 JSON,而不是关系库
每类行为数据是一个带时间戳的记录数组(recipeId + createdAt),整体 JSON.stringify 后存进一个 Preferences 键。为什么不上 RDB:数据量小(收藏几十条封顶)、结构稳定、查询就是内存里的 find/filter。关系库的正确引入时机是"需要条件查询和分页"------目前一条都不需要。诚实说这个方案的天花板:JSON 大字符串意味着每次收藏都全量重写整个数组,几百条无感,几千条会有明显的写放大,到那个量级再迁 RDB,接口不用变(persist 是唯一出口)。
四、取舍二:不可变更新是硬约束
toggleFavorite 的写法值得单独说:
ts
static toggleFavorite(records: FavoriteRecord[], recipeId: number): FavoriteRecord[] {
const index = records.findIndex(r => r.recipeId === recipeId)
if (index >= 0) {
const newRecords = [...records]
newRecords.splice(index, 1)
return newRecords
}
return [{ recipeId, createdAt: dateStr }, ...records]
}
取消收藏时先复制再 splice,最后返回的是新数组------不是改完原数组返回它。这是 ArkTS 状态管理 V1 的硬约束:@StorageLink 靠引用变化感知更新,原地 mutate 一个数组,UI 就不动了。所有七个操作函数都遵守这个约定,这一致性比任何单个函数的实现都重要。
五、取舍三:MRU 语义压缩在一个函数里
浏览历史的 addHistory 干了三件事:filter 掉同 id 旧记录、把新记录插到最前、带上时间戳。一次调用完成"去重+置顶+计时",页面侧只需要 records = addHistory(records, id) 一行。历史天然是最近优先(MRU)结构,不需要排序逻辑------插入位置就是排序。清空历史同样只有几行:置空数组 + persist。
六、取舍四:同步 API 的边界
putSync 加 flush 的写路径在这个量级完全够用:单条记录百字节级,全量序列化也就几 KB,磁盘操作毫秒级完成。但同步意味着在 UI 线程做 IO,这个设计成立的前提是数据量小。我已经在 persist 里留了唯一的出口------将来换异步 API 或 RDB,改这一层,七个操作函数和所有页面无感。
七、结语
用户行为数据的设计要点不在存储选型,在流转纪律:单一事实源、不可变更新、写路径收敛到一个函数。做到这三条,数据小的时候跑得轻快,数据大了换存储也不伤筋动骨。
参考资料
- Preferences 文档(@kit.ArkData)
- 工程源码:UserDataManager.ets、FavoritePage.ets
#鸿蒙 #HarmonyOS 7(API 26)