【寻迹校园 HarmonyOS NEXT 实战 16】Preferences 还是 RelationalStore:HarmonyOS 本地存储选型实战
这是"寻迹校园 HarmonyOS NEXT 实战"系列第 16 篇。本文不做抽象的 API 罗列,而是结合项目里的安全提醒、失物记录、认领交接和照片文件,说明 Preferences、RelationalStore 与应用沙箱文件系统各自应该保存什么,以及错误选型会给升级、查询和数据一致性带来什么问题。

上图为原创生成的技术插画,不是项目截图。它表达的核心很简单:偏好开关、结构化业务记录和二进制图片虽然都叫"本地数据",但生命周期、查询方式和失败影响完全不同,不应该被塞进同一种存储。
一、先看结论:不要按"数据大小"单独做决定
很多示例把存储选型简化成"少量数据用 Preferences,大量数据用数据库"。这个判断不够。
真正需要同时考虑的是:
- 数据有没有稳定主键和多字段结构;
- 是否需要筛选、排序、统计或关联;
- 是否存在状态机和增量迁移;
- 写入失败会不会破坏核心业务;
- 数据能否直接序列化成单个键值;
- 是否属于图片、音频等二进制内容;
- 后续是否需要跨版本演进。
在"寻迹校园"中,安全提醒只有一个布尔值,适合 Preferences;失物、认领、交接、举报都需要按 ID 查询并更新状态,适合 RelationalStore;Photo Picker 返回的图片需要复制到应用沙箱,由文件系统保存,数据库只保留 URI 引用。
二、项目里的 Preferences 只承担轻量偏好
当前 SettingsRepository.ets 只保存一个安全提醒开关:
ts
const STORE_NAME: string = 'xunji_settings';
const KEY_SAFE_NOTICE: string = 'safe_notice_enabled';
async loadSafeNotice(context: common.UIAbilityContext): Promise<boolean> {
const store = await this.ensureStore(context);
const value: preferences.ValueType = await store.get(KEY_SAFE_NOTICE, true);
return typeof value === 'boolean' ? value : true;
}
async saveSafeNotice(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
const store = await this.ensureStore(context);
await store.put(KEY_SAFE_NOTICE, enabled);
await store.flush();
}
这里有三个值得保留的工程细节。
第一,读取给出默认值 true。首次安装、键不存在或历史值异常时,页面仍能得到可解释状态。
第二,读取后再次做类型检查。Preferences 的 ValueType 不只包含布尔值,不能假设历史版本一定写入了正确类型。
第三,put() 后调用 flush()。内存中的变更与落盘完成不是同一个证明层级;设置页提示"保存成功"前,应等待持久化动作结束。
三、为什么不把失物记录序列化进 Preferences
把整个失物列表转成 JSON,再保存到一个键,看起来代码很短,但会迅速遇到问题:
- 修改一条记录也要读出并重写整组数据;
- 无法自然表达按
status、reportType、createdAt查询; - 认领、交接和举报之间的关联只能靠手工遍历;
- Schema 演进只能写一大段 JSON 兼容代码;
- 写入中断时,整组数据可能一起受影响;
- 数据量增长后,启动解析和全量复制成本持续上升。
失物记录不是"多个设置项",而是结构化业务实体。项目为 item_report 维护主键、公开字段、私密核验字段、状态、事件日期、图片 URI 与创建时间,这正是 RelationalStore 的职责范围。
四、状态机数据必须有明确的权威来源
项目中的报告状态包括:
| 状态 | 含义 | 是否参与首页公开筛选 |
|---|---|---|
DRAFT |
尚未正式发布 | 否 |
OPEN |
开放展示和匹配 | 是 |
CLAIMING |
认领处理中 | 是,但业务动作受限 |
RESOLVED |
已完成交接 | 视页面语义展示,不再作为新候选 |
WITHDRAWN |
发布者主动撤回 | 否 |
HIDDEN |
治理流程隐藏 | 否 |
这些状态不是界面文案,而是会影响编辑、删除、候选召回和后续交接的业务事实。若把它们分散成多个 Preferences 键,跨实体约束会变得不可追踪。
更合理的数据流是:页面发起动作,Service 校验状态,Repository 原子地更新权威记录,其他页面收到 dataRevision 失效信号后重新查询。刷新信号可以轻量,权威数据不能变成临时页面变量。
五、图片为什么既不放 Preferences,也不直接塞数据库正文
Photo Picker 返回的 URI 可能依赖临时授权。项目通过 ReportPhotoRepository 把图片复制到:
context.filesDir/report_photos
RelationalStore 保存的是 file://... URI 列表,而不是把完整图片编码成超长字符串。这样可以避免 Preferences 或记录字段被大块二进制撑大,也能在替换、删除记录时按引用清理文件。
但文件系统和数据库也会产生新的顺序问题:
- 先复制图片;
- 再写入业务记录;
- 数据库失败时清理刚复制的文件;
- 编辑成功后删除不再引用的旧图片;
- 删除记录后清理其受管图片。
这不是完整跨资源事务,因此 Service 必须提供补偿逻辑,不能让页面自己拼接文件操作。

上图把数据落点和调用方向放在一张图里:设置开关进入 Preferences,业务实体进入 RelationalStore,图片内容进入文件系统;页面只调用 Service,不直接持有任何一种存储实现。
六、设置页为什么还需要 Service 层
只有一个布尔值,也可以直接在 SettingsPage 里调用 Preferences,但项目仍保留 SettingsService:
ts
async loadSafeNotice(): Promise<boolean> {
if (!this.context) return true;
return this.repository.loadSafeNotice(this.context);
}
async saveSafeNotice(enabled: boolean): Promise<boolean> {
if (!this.context) return false;
try {
await this.repository.saveSafeNotice(this.context, enabled);
return true;
} catch (error) {
return false;
}
}
这一层的价值不是"代码显得完整",而是隔离 Context、默认策略和错误映射。页面只处理 loading、saving、当前开关值和用户可见错误,不需要认识 Store 名称与 Key。
未来如果安全提醒从单一开关升级为按场景控制,Service 可以组合规则;Repository 仍只负责读写。
七、默认值不是随便写一个常量
安全提醒默认开启,是产品安全策略的一部分。默认值至少要在三个位置保持一致:
- Repository 首次读取的默认值;
- Service 没有 Context 时的保守回退;
- 页面初始化期间展示的本地状态。
如果这三处分别使用 true、false 和空值,用户会看到开关闪动,或者在持久化尚未读取时错误关闭提醒。
更成熟的做法是把默认值集中为领域常量,并区分"尚未加载"和"已加载为 true"。当前页面通过 loading 控制,在加载期间展示进度组件,避免用户在未知状态下重复操作。
八、flush 失败时不能假装已经保存
设置页切换开关后,先暂存原值,再等待保存结果。如果持久化失败,应恢复原值并显示明确提示。
这种行为看似保守,却能避免一个常见错觉:界面上的 Toggle 已经改变,用户以为下次启动仍会保持,实际上磁盘写入失败。
对于报告、认领等核心数据,失败处理要求更高。页面不能只根据内存对象变化提示成功,必须以 Repository 的权威写入完成为准。Preferences 和 RelationalStore API 不同,但"成功提示要晚于持久化成功"的原则相同。
九、选型决策表
| 场景 | 推荐方案 | 关键理由 |
|---|---|---|
| 安全提醒、主题偏好、少量开关 | Preferences | 键值读取简单,有明确默认值 |
| 失物记录、草稿、认领、交接、举报 | RelationalStore | 有主键、查询、状态更新和迁移需求 |
| 用户选择的图片、未来的音频附件 | 应用沙箱文件 | 二进制内容适合文件 I/O,数据库保存引用 |
| 页面临时步骤、输入焦点、加载状态 | 页面短生命周期状态 | 不需要跨启动持久化 |
| 跨设备同步、多人协作、真实审核 | 远端服务与本地缓存 | 需要身份、鉴权、冲突解决和审计 |
选型不应从"我熟悉哪个 API"开始,而应从数据契约开始。
十、迁移策略也因存储类型不同
Preferences 的迁移通常围绕 Key:增加新 Key、读取旧 Key、转换类型、写入新值并删除废弃 Key。
RelationalStore 的迁移围绕 Schema:检查列、执行 ALTER TABLE、回填旧数据、维护索引和版本幂等性。
文件系统迁移则要处理目录、扩展名、孤儿文件和 URI 失效。
三种存储混在一起时,升级顺序必须明确。例如先升级数据库字段,再扫描文件引用,最后清理孤儿文件;任何一步都要可重复执行。当前项目规模较小,没有必要为一个开关引入复杂配置数据库,但也不能因此把所有数据都降级成键值。
十一、隐私与安全边界
本地保存不等于天然安全。项目仍需要遵守这些边界:
- Preferences 不写入账号密码、证书口令或长期 Token;
- 公开
ItemReport与私密核验特征分开读取; - 图片目录只保存用户主动选择并用于发布的内容;
- 日志不打印私密特征和完整本机路径;
- 删除业务记录时同步考虑受管图片;
- 未来接入云端后,权限校验必须放在服务端,不能依赖本地 UI 隐藏。
当前项目是单机比赛演示版,Preferences、RelationalStore 和沙箱文件只能解决本设备数据管理,不能证明真实账号隔离、多设备同步或运营审计已经实现。
十二、验证应该分三层
第一层是静态检查:Store 名称、Key、默认值、flush()、Repository 接口和页面状态是否一致。
第二层是构建与自动化:确认 ArkTS 类型、模块依赖和不依赖 Context 的业务测试没有回归。
第三层是模拟器或真机验证:切换安全提醒后重启应用,确认值仍保持;创建、编辑和删除记录后重启,确认 RelationalStore 与文件目录一致。
可执行的项目命令以当前脚本为准:
powershell
powershell -ExecutionPolicy Bypass -File .\scripts\test-local.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\build-hap.ps1
本地 Node 测试不具备 UIAbilityContext,因此不能证明 Preferences 真正落盘,也不能证明 RelationalStore 的 Schema、加密和设备 I/O。只有在模拟器或真机完成重启复测,才能把对应项标记为 passed。
十三、什么时候需要升级设计
当设置项增加到需要分组、搜索、版本迁移或策略组合时,可以为设置建立明确模型;当业务数据需要远端同步时,应引入本地/远端 Repository 与冲突策略;当图片数量和体积增长时,应增加配额、压缩、引用计数和孤儿清理任务。
升级的前提是需求出现,而不是为了让目录看起来更复杂。当前 xunji_settings 只有安全提醒开关,Preferences 已经足够;报告状态机具有结构化查询需求,RelationalStore 才是合适选择。
十四、把存储选型变成可审查的工程决策
存储方案不能只写在代码里,还应留下可复核的决策记录。每新增一种数据,先说明数据所有者、生命周期、查询方式、失败影响、迁移方式与清理责任,再决定进入 Preferences、RelationalStore 还是文件系统。这样评审者看到的不只是 API 调用,而是一条从业务语义到技术落点的完整推导。
对"寻迹校园"而言,安全提醒由设置域负责,报告和认领由业务 Repository 负责,图片由文件 Repository 负责。三类数据可以在同一页面出现,但不能因此共用同一种存储。页面只是把动作交给 Service,Service 再协调各数据源,并把失败映射成用户能够理解的状态。
十五、失败场景、补偿顺序与回滚边界
真实故障通常发生在跨资源写入之间。例如图片已经复制成功,但报告写库失败;或者数据库删除成功,文件清理却失败。工程上要先保护权威业务记录,再处理可补偿资源,并把孤儿文件扫描作为后续维护能力,而不是让页面静默吞掉异常。
- 新增失败:删除本轮新复制且尚未被任何记录引用的图片;
- 编辑失败:保留旧记录和旧图片,清理本轮新增文件;
- 删除失败:不提前删除图片,避免仍存在的记录变成断图;
- 文件清理失败:记录可观测事件,允许后台或下次启动重试;
- 迁移失败:保留旧版本字段或备份,禁止只完成一半就提升 Schema 版本。
这些补偿并不等于真正的跨资源事务。若未来数据进入云端,还需要幂等请求、服务端事务、冲突版本和重试上限,不能把本地演示中的顺序控制直接描述成分布式一致性。
十六、验收证据应覆盖哪些层级
验收时应把静态代码、自动化、构建、设备落盘和冷启动恢复分开记录。看到 flush() 只能证明调用意图;Node 测试通过只能证明普通规则;只有在真实 UIAbilityContext 下保存并重启仍保持,才能证明 Preferences 或 RelationalStore 的设备持久化路径。
一份可追踪记录至少包含执行命令、设备或模拟器型号、测试数据、预期结果、实际结果、失败日志与未验证项。这样后续更换 SDK、调整 Schema 或扩展跨校数据时,可以判断回归来自业务规则、存储实现还是运行环境。
十七、本文小结
HarmonyOS 本地存储选型的关键,不是简单比较 API,而是识别数据语义:偏好是键值,业务记录是结构化实体,图片是文件资源。
"寻迹校园"用 Preferences 保存安全提醒,用 RelationalStore 保存报告、草稿与状态机数据,用应用沙箱保存图片,并通过 Page → Service → Repository 保持边界。这样的拆分让默认值、失败恢复、迁移和验证都更容易解释,也避免把页面上的一次切换误当成完整的持久化成功。
系列导航:第 16 篇 / 共 50 篇。上一篇:《Repository 双数据源》;下一篇:《编辑、撤回、删除与结案的一致性设计》。