Preferences 存设置、filesDir 存历史:非敏感数据持久化分层

一个 App 要落盘的数据,在我们这里分三类,对应三种存储,决策依据是搞砸了的成本:
|
数据 |
存储 |
搞砸的成本 |
|---|---|---|
|
个性化设置(训练目标、自定义规则等 4 个字段) |
Preferences |
低:用户重填一次 |
|
训练历史(最多 50 条,含转写正文) |
filesDir 文件 |
中:用户失去复盘记录 |
|
DeepSeek API Key |
永不进这两者(内存 + Asset Store,B15 讲) |
高:安全事故 |
先立一条总红线再分头讲:Preferences 和 filesDir 里永远不允许出现 Key、令牌、音频。这条线不是靠 Code Review 自觉,是靠 port 接口的形状保证的——往下看。
1. Preferences:小 KV 设置的正确打开方式
Preferences 是鸿蒙的轻量键值存储,适合"几个字段、读频繁、写不频繁"的配置类数据。我们的个性化设置正好是这个形状:训练目标、自定义规则、风格参考、自定义词,四个字符串字段。
实现层是一个 port(SpeakLabPreferencesSettingsPort),几个值得抄的细节:
Store 名和 Key 名都是常量, schema 版本随数据写:
const p = await preferences.getPreferences(this.context, SPEAKLAB_SETTINGS_STORE_NAME);
// save 时:
await p.put(SPEAKLAB_PREF_KEY_SCHEMA_VERSION, SPEAKLAB_SETTINGS_SCHEMA_VERSION);
await p.put(SPEAKLAB_PREF_KEY_TRAINING_GOAL, fields.trainingGoal);
// …
await p.flush(); // 写完必须 flush,否则只留在内存缓存
两个坑位提醒:一是 put 之后必须 flush() 才真正落盘,忘了 flush 的设置"存了个寂寞";二是数据里带 schema 版本号,将来字段结构变了可以按版本迁移,而不是猜。
读失败回退默认值,而不是抛给用户:
async load(): Promise<SpeakLabPersonalizationFields> {
try {
// …逐字段 get,缺省 ''
return new SpeakLabPersonalizationFields(goal, rules, style, words);
} catch (_e) {
return SpeakLabPersonalizationFields.empty(); // 损坏/首装 = 空设置
}
}
设置损坏的正确姿势是当作"没设置过",不是弹个错误框。这符合前面的成本核算:丢设置是低成本事件。
写入侧的限制定义在纯类型层:每字段最长 2000 个 UTF-16 code unit,自定义词最多 64 个、每词最长 16。限制不进 UI、不进 port,而是放在 SpeakLabSettingsTypes 的纯校验函数里——UI、port、门禁三方引用同一份限制,改限制只改一处。
2. filesDir:结构化历史的索引+分条布局
训练历史是另一种形状:记录较大(一条含完整转写正文,软上限 20 万 UTF-16)、条数有上限(50)、需要列表页快速加载、详情页按需加载。塞 Preferences 既撑不住也查不动,上文件系统。
目录布局是索引 + 分条:
{filesDir}/speaklab_history/
├── index.json # 轻量索引:id 列表 + 每条摘要(标题/时长/时间)
└── records/
├── h-1753246800000-1.json # 完整记录正文
└── h-1753246900000-2.json
这个布局直接服务两个 UI 场景:历史列表页只读 index.json(几十条摘要,毫秒级),点进详情才读对应的 records/{id}.json。50 条上限写死在类型层(SPEAKLAB_HISTORY_MAX_RECORDS = 50),新记录进、最老记录出,索引和正文一起淘汰。
文件层的几个硬细节:
原子写:临时文件 + rename。索引写坏一半就是整个历史列表消失,所以写文件必须先写临时文件再 rename——rename 在同一文件系统内是原子的,读到的一半状态不存在:
Atomic write: temp file then rename.
id 消毒防路径逃逸。记录 id 要拼进文件路径,先过滤掉路径分隔符类字符:
private recordPath(id: string): string {
// Sanitize id so path cannot escape records dir (ids are h-{ms}-{seq}).
const safe = id.replace(/[^a-zA-Z0-9._-]/g, '_');
return `${this.recordsRoot()}/${safe}.json`;
}
哪怕 id 来源"理论上可信"(自己生成的 h-{毫秒}-{序号}),拼路径前一律消毒——这是把安全习惯写成肌肉记忆,成本一行代码。
API 行为坑:accessSync 返回 boolean 不抛异常。文件头注释用 CRITICAL 标记了这个坑:
CRITICAL: fileIo.accessSync returns boolean (true/false), it does NOT throw on missing path.
判断文件存不存在,写 try { accessSync(path); return true } catch { return false } 是错的——不存在时它安静地返回 false,你的 try/catch 永远走不到 catch。这类"返回值语义 vs 异常语义"的 API 差异,每个平台都有几个,发现了就写进注释防下一个人。
读失败同样显式降级:索引读取失败返回空索引(并记日志 history_index_load_failed),不是 crash。历史是增强功能,它的损坏不该拖垮 App。
3. 红线怎么落地:让接口说不出秘密
回到总红线:Key 永不进 Preferences/filesDir。看两个 port 的接口形状:
// 设置 port 的输入只有这四个非敏感字段——想存 Key,编译期就无路可走
save(fields: SpeakLabPersonalizationFields): Promise<void>
// 历史 port 文件头注释:Never stores Key / audio.
SpeakLabPersonalizationFields 类型里根本没有能塞秘密的字段;历史记录的类型同理。敏感凭据走完全独立的通道(内存控制器 + Asset Store/沙箱 vault,B15 专篇),两个通道的代码没有交集。这就是"用类型系统表达安全边界"——红线不依赖"大家记得别存",而是想存也没有 API。
配套的清除语义也按通道对齐:设置页的"清除"清 Preferences 四字段;隐私页的"清除历史"删整个 speaklab_history 目录;凭据的"显式清除"双清内存 + Asset/vault。每个通道对自己的数据负全责,互不代管。
4. 选型速查
|
数据特征 |
推荐存储 |
我们的例子 |
|---|---|---|
|
小 KV、配置类、读写简单 |
Preferences |
个性化设置四字段 |
|
较大结构化记录、需列表/详情分离 |
filesDir 索引+分条 |
训练历史 50 条 |
|
大型二进制/媒体 |
filesDir 裸文件 |
(本项目不存音频,红线) |
|
关系查询、复杂检索 |
关系型数据库 |
(本项目用不到,不引入) |
|
秘密/凭据 |
Asset Store / 仅内存 |
DeepSeek Key(B15) |
原则收尾:先按"搞砸的成本"分层,再按"数据的形状"选型,最后让接口形状替你守红线。
5. 小结
-
Preferences:store/key 名常量化、写后必 flush、数据带 schema 版本、读失败回退默认值、限制集中在纯类型层。
-
filesDir:索引+分条匹配列表/详情双场景;原子写 temp+rename;id 消毒防路径逃逸;
accessSync返回 boolean 不抛异常。 -
敏感数据不进这两者,靠类型形状保证,不靠自觉;清除语义按通道对齐。
更多推荐



所有评论(0)