putSync 返回了,进程一死数据就没了
一个夜间模式开关,用户打开后立刻看到界面变深色,putSync 返回正常,hilog 干干净净。第二天用户重开应用,开关又回到了浅色。你加日志,发现 getSync 读出来的就是默认值,好像前一天的 putSync 从来没有发生过。你查 getPreferences 的 name,没变;查 context,没换;查应用有没有被卸载重装,也没有。问题不在读取路径,在于你把 putSync 的“同步”两个字理解成了“同步落盘”。它同步的只是内存缓存,持久化文件那边,还没有人动笔。
putSync 写的是缓存,flush 才是落盘
先把 Preferences 的内存模型钉死。getPreferences 返回的 Preferences 实例是一个内存缓存,putSync 把键值写进这个缓存,仅此而已。持久化文件在沙箱里,putSync 不会主动去碰它。真正把缓存刷进文件的,是 flush() 或 flushSync()。
系统本身有一个内部定时器,大约每 10 秒会自动触发一次 flush。如果你的应用在 putSync 之后存活时间足够长,定时器恰好把数据写进了文件,重启之后就能读到;如果进程在定时器触发之前被杀掉,内存里的那次写入就随着进程一起消失了。表现出来就是“有时候能读到,有时候读不到”,偶现、难复现,因为它的成功与否取决于进程活了多久,而不是你的代码有没有“写对”。
typescript
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
async function saveNightMode(ctx: common.UIAbilityContext, enabled: boolean): Promise<void> {
const prefs = await preferences.getPreferences(ctx, { name: 'settings' });
await prefs.put('nightMode', enabled);
// 必须等待 flush 完成,不能假设 put 之后数据已经在磁盘上
await prefs.flush();
}
await prefs.flush() 这一行不是可选的收尾。flush 是异步接口,调用它只是发起了刷盘动作,持久化是在异步流程中完成的。如果你调用 flush() 之后不 await 就继续往下走,甚至紧接着就退出应用,数据仍然可能停留在内存里没有写进文件。API 14 起提供了 flushSync(),同步刷盘,调用返回即已写入文件;如果最低兼容版本允许,putSync 后面直接跟 flushSync 是最省心的写法。
高频写入加防抖,防抖的末尾必须等 flush 真正完成
知道了要 flush,下一个自然的优化是“不要每次 put 都刷盘,攒一批再刷”。这个方向没错,循环里每一次 put 都 flush,IO 开销确实吃不消。但攒批的写法如果只在定时器里调一次 flush() 而不等待它完成,问题并没有解决,只是把“每写必丢”变成了“批量丢”。
更隐蔽的是同 key 的高频覆盖。putSync 只写内存,短时间连续覆盖同一个 key,前一次的值还在缓存里没来得及刷盘,就被后一次覆盖了。Preferences 本身没有事务,它不保证中间状态的可追溯性。如果你需要的是“每一次写入都可恢复”,Preferences 从一开始就不是合适的载体。
一个务实的攒批结构是这样的:内存里维护一个 changeMap,put 只更新这个映射;一个短延迟的防抖定时器触发真正的批量 putSync,然后await flush(),并且给这个异步流程一个可追踪的 Promise;页面退出或 onBackground 里调一次 forceFlushNow,确保在生命周期终点之前,最后一次刷盘被执行到。
typescript
class PrefBatchWriter {
private prefs: preferences.Preferences | null = null;
private pending: Record<string, preferences.ValueType> = {};
private timer: number | null = null;
async init(ctx: common.UIAbilityContext, name: string): Promise<void> {
this.prefs = await preferences.getPreferences(ctx, { name });
}
put(key: string, value: preferences.ValueType): void {
this.pending[key] = value;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = setTimeout(() => { void this.flushNow(); }, 300);
}
async flushNow(): Promise<void> {
if (!this.prefs || Object.keys(this.pending).length === 0) return;
if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; }
for (const key of Object.keys(this.pending)) {
this.prefs.putSync(key, this.pending[key]);
}
this.pending = {};
await this.prefs.flush(); // 等待落盘完成,不把 Promise 丢在空中
}
}
flushNow 返回的是 Promise,调用方必须 await 它,或者至少把它的完成状态追踪起来。页面 aboutToDisappear 里如果调了 flushNow 但不等待,进程紧接着被回收,效果和没调 flush 是一样的。
覆盖安装不一定会丢,但“读的是另一份数据”经常伪装成丢
还有一种现象会让排查方向从 flush 上被带偏:用户反馈“升级之后设置全没了”,你检查 flush,代码里明明写了。打开沙箱目录,旧数据可能还在,只是新版本读的是一个不同的 Preferences 文件。
getPreferences 的 Options 里,name 决定了持久化文件名;dataGroupId 和 context.area 参与决定沙箱路径;context 的类型如果从 UIAbilityContext 换成了 ApplicationContext,或者模块从 HAP 换到了 HSP,即使 name 相同,解析出的文件路径也可能不同。name 改了,读的就是一个新文件,旧数据还在沙箱里,但新代码找不到它。表现是“数据丢了”,根因是“换了地址”。
排查这类问题时,不要只看 putSync 和 flush 有没有写。用 Preferences 调试工具直接查看沙箱里实际存在哪些 Preferences 文件,对比升级前后 name、context.area、dataGroupId 有没有变化。如果确实换了文件名或路径,正确的迁移方式是读旧、写新、flush 成功之后才标记迁移完成,而不是直接移动或覆盖文件。官方明确说,变更存储路径时不能直接搬文件,必须通过 Preferences 的读写接口完成迁移。
DevEco 的 Keep Application Data 没勾,Run 一次就是卸载重装
本地调试时遇到的“上次存的数据这次没了”,还有一个和代码完全无关的原因。DevEco Studio 默认的 Run 行为可能执行卸载重装,而不是覆盖安装。Preferences 的持久化文件在 data/app/el2/100/base/(bundleName)/haps/entry/preferences 下,覆盖安装不会动这个目录,但卸载重装会。调试配置里 Keep Application Data 没有勾选,你每次 Run 都在清空沙箱,然后把锅甩给 flush。
确认这件事只需要一个对照:勾上 Keep Application Data,重新 Run 一次,如果数据不丢了,问题从来不在代码里。
Preferences 是配置容器,不是事务存储
把上面几条串起来,背后的判断其实很简单:Preferences 适合存的是低频、小体积、丢失一两条也不会造成业务事故的配置数据。值类型只支持 number、string、boolean 及其数组,key 最长 1024 字节,string 类型的 value 上限 16MB,官方明确建议总数据量不超过 50MB。它是给偏好设置准备的,不是给业务数据准备的。
需要事务、需要高频写入不丢更新、需要跨版本迁移有可靠保证的场景,正确的载体是 RDB,而不是在 Preferences 外面套一层防抖然后祈祷 flush 在进程死之前跑完。你用 Preferences 存一个 nightMode 开关,丢了最多让用户再点一次;用它存用户的草稿或关键业务状态,丢一次就是一次线上事故。选型边界比 flush 写没写更根本。
排查顺序:先看 flush 有没有等,再看文件路径有没有变,最后看 Run 有没有清沙箱
下次 putSync 之后重启读不到的时候,按这个顺序走,比反复改 put 的调用位置快。
第一步,检查 put 或 putSync 之后有没有 flush() 或 flushSync(),以及异步 flush 有没有被 await。只 put 不 flush,数据就只在内存里;flush 不 await,退出太快时刷盘还没完成。
第二步,如果 flush 写了但现象仍然偶现,用调试工具打开沙箱里的 Preferences 文件,确认数据到底有没有写进去。写进去了但读不到,检查 name、context.area、dataGroupId、模块归属有没有在升级中发生变化。
第三步,如果只在本地调试时出现,检查 DevEco 的 Keep Application Data 有没有勾选。Run 可能执行了卸载重装,沙箱被清空和代码无关。
第四步,如果业务是高频写入并且要求不丢更新,停下来重新评估选型。Preferences 没有事务,高频覆盖同 key 时中间值本来就不保证保留。这类数据应该迁到 RDB,而不是在 Preferences 外面加更复杂的防抖。
putSync 的“同步”同步的是内存,不是磁盘。系统的 10 秒自动 flush 是一个兜底,不是一个承诺。flush() 调用了不代表写完了,await 它才是;攒批防抖的最后一跳如果不等 flush 完成,等于把丢失窗口从每一次写入挪到了进程退出的那一刻。下一次夜间模式重启回默认值的时候,先别怀疑 getPreferences 读错了——打开代码看一眼,putSync 后面那一行 flush,到底有没有人在等它。

更多推荐

所有评论(0)