【寻迹校园 HarmonyOS NEXT 实战 16】Preferences 还是 RelationalStore:HarmonyOS 本地存储选型实战

【寻迹校园 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,再保存到一个键,看起来代码很短,但会迅速遇到问题:

  • 修改一条记录也要读出并重写整组数据;
  • 无法自然表达按 statusreportTypecreatedAt 查询;
  • 认领、交接和举报之间的关联只能靠手工遍历;
  • 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 或记录字段被大块二进制撑大,也能在替换、删除记录时按引用清理文件。

但文件系统和数据库也会产生新的顺序问题:

  1. 先复制图片;
  2. 再写入业务记录;
  3. 数据库失败时清理刚复制的文件;
  4. 编辑成功后删除不再引用的旧图片;
  5. 删除记录后清理其受管图片。

这不是完整跨资源事务,因此 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、默认策略和错误映射。页面只处理 loadingsaving、当前开关值和用户可见错误,不需要认识 Store 名称与 Key。

未来如果安全提醒从单一开关升级为按场景控制,Service 可以组合规则;Repository 仍只负责读写。

七、默认值不是随便写一个常量

安全提醒默认开启,是产品安全策略的一部分。默认值至少要在三个位置保持一致:

  • Repository 首次读取的默认值;
  • Service 没有 Context 时的保守回退;
  • 页面初始化期间展示的本地状态。

如果这三处分别使用 truefalse 和空值,用户会看到开关闪动,或者在持久化尚未读取时错误关闭提醒。

更成熟的做法是把默认值集中为领域常量,并区分"尚未加载"和"已加载为 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 双数据源》;下一篇:《编辑、撤回、删除与结案的一致性设计》。

相关推荐
2501_919749033 小时前
华为鸿蒙免费听歌APP—小羊免费听歌
华为·harmonyos·鸿蒙
OH_TPC4 小时前
HarmonyOS APP开发---"祝福圈"节日贺卡App,需要用到这个库
harmonyos
m0_749690237 小时前
【寻迹校园 HarmonyOS NEXT 实战 31】举报去重怎么做:同一记录的重复投诉不应制造多条处理中案件
harmonyos·arkts·relationalstore·内容治理·举报去重
贾伟康8 小时前
【中国方言题库|11】HarmonyOS ArkTS 学习统计实战:计算地区学习进度与收藏数量
harmonyos·arkts·arkui·数据统计·多设备适配
用户1269550879148 小时前
RK3588 + OpenHarmony 6.1 RKNN2 NPU 验证指南
harmonyos
m0_749690239 小时前
【寻迹校园 HarmonyOS NEXT 实战 35】先写全页面 Design Spec 再写 ArkUI:一个比赛项目的设计稿门禁实践
harmonyos·响应式设计·设计规范·arkui·ui设计
Magic-ZYJ9 小时前
HarmonyOS 日记类 App 的日期设计:本地自然日、月历与夏令时边界
华为·harmonyos·arkts·arkui·问题排查·移动端开发·独立开发者
贾伟康9 小时前
【中国方言题库|12】HarmonyOS ArkTS 题库列表组件实战:减少多地区页面重复并保证点击反馈
harmonyos·arkts·arkui·组件化·多设备适配