【寻迹校园 HarmonyOS NEXT 实战 15】Repository 双数据源:RelationalStore 与内存回退如何共用接口
【寻迹校园 HarmonyOS NEXT 实战 15】Repository 双数据源:RelationalStore 与内存回退如何共用接口
这是“寻迹校园 HarmonyOS NEXT 实战”系列第 15 篇。本文结合
ReportRepository.ets,拆解有UIAbilityContext时使用 RelationalStore、无 Context 时使用静态内存数据的双数据源策略,以及统一接口、种子数据、对象克隆和证据边界。

上图为本文原创生成的架构插画,不是应用截图。同一组 list/find/insert/update/delete 业务接口,可以路由到真实本地数据库或内存回退;调用方不需要在页面里写两套业务逻辑。
一、为什么项目会同时存在两种数据源
“寻迹校园”的正式运行需要 RelationalStore:记录要在应用重启后保留,需要查询、排序、状态更新和增量迁移。
但部分纯业务测试、无平台执行环境或初始化前路径拿不到 UIAbilityContext。如果 Repository 完全依赖 Context,Service 很难在这些环境验证匹配、状态机和错误映射。
项目因此采用两条路径:
- 有 Context:打开加密 S2 RelationalStore,读写
item_report; - 无 Context:使用静态
fallbackReports,返回确定性种子数据和内存变更。
这是一种可测试性设计,不是把内存数组当成正式数据库替代品。
二、统一接口让页面与 Service 不感知数据源
Repository 提供的主要动作保持一致:
list(context?)
findById(reportId, context?)
insert(report, privateFeature, context?)
update(report, privateFeature, context?)
deleteById(reportId, context?)
updateStatus(reportId, status, context?)
Service 负责业务规则,只选择是否传入 Context,不需要维护 listFromDb() 与 listFromMemory() 两套返回模型。页面更不应该直接判断数据库是否可用。
统一的不只是方法名,还包括:
- 返回
ItemReport业务模型; - ID、状态和 ReportType 的语义;
- 排序与查找结果;
- 写入后再次读取的可观察行为;
- 不向调用方暴露
ResultSet或内部数组引用。
三、有 Context 时才进入 RelationalStore
ensureStore() 使用真实 UIAbilityContext 获取 RdbStore,创建表并执行缺列检查。list() 则通过谓词按创建时间倒序:
const predicates = new relationalStore.RdbPredicates(TABLE_REPORT);
predicates.orderByDesc('created_at');
const resultSet = await (await this.ensureStore(context)).query(predicates);
数据库路径负责:
- S2 与加密配置;
- 表结构与增量迁移;
- ValuesBucket 映射;
- 条件查询、排序和状态更新;
- 应用重启后的持久化;
- ResultSet 生命周期。
这些能力都依赖真实平台环境,不能由内存测试替代。
四、无 Context 时返回内存回退
没有 Context 时,list() 直接从静态数据创建返回数组:
return ReportRepository.fallbackReports.map(
(item: StoredReport) => this.cloneReport(item.report)
);
回退数据包括几条固定校园失物记录,能够让首页、匹配规则和认领流程在无数据库环境下获得确定输入。
固定种子比随机数据更适合回归:同一个用例每次看到相同 ID、类型、类别、地点和日期,失败更容易复现。若测试需要修改,结束后应重建进程或显式重置,避免静态数组在不同用例间泄漏状态。
五、为什么读取结果必须 clone
ItemReport 是可变对象。如果 Repository 直接把内部对象引用返回给页面,调用方执行 report.status = ReportStatus.RESOLVED 就可能绕过正式写入动作。
就可能绕过 updateStatus(),直接修改内存权威数据。数据库路径中,修改查询结果也不会自动更新数据库,两种数据源语义随即分裂。
项目用 cloneReport() 重建对象:
return new ItemReport(
report.id, report.reportType, report.title, report.category,
report.area, report.timeLabel, report.description, report.status,
report.createdAt, report.isUserCreated, report.eventDate, report.imageUris
);
这样,读取结果是快照;任何正式变更仍要经过 Repository 方法。克隆隔离让内存回退更接近数据库“读出—修改—显式写回”的行为。
六、insert 为什么同时更新 fallback 与数据库
当前 insert() 先更新静态回退,再在有 Context 时写数据库:
ReportRepository.fallbackReports = [stored].concat(
ReportRepository.fallbackReports.filter(
(item: StoredReport) => item.report.id !== report.id));
if (context) {
await store.insert(TABLE_REPORT, bucket,
relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
}
它保证当前进程后续无 Context 查询仍能看到新记录,但也带来需要关注的顺序风险:如果数据库写入失败,fallback 已经发生变化。
Service 不能因此把内存结果当成持久化成功。生产路径应以数据库写入结果为准;如果要求两者严格一致,可以调整顺序或在失败时补偿回退状态。文章记录真实实现与风险,不把它描述成跨数据源事务。
七、数据库读取异常时为什么会退到 fallback
list() 和 findById() 在数据库查询异常时返回内存数据,能让演示页面继续展示基础内容。这属于可用性降级,但也容易产生误判:
- 页面有数据,不代表 RelationalStore 正常;
- 新写入记录可能只存在某一数据源;
- 用户重启后可能看到不同结果;
- 错误被完全吞掉时,维护者难以定位根因。
更完善的实现应让 Service 或诊断层知道当前数据来源,例如内部记录受控状态或返回可观察错误分类;用户页面可以显示“当前使用本地示例数据”,而不是静默伪装成真实持久化结果。
八、初始化时如何把种子写入空数据库
initialize(context) 先统计表内数量。只有 COUNT(*) 为 0 时,才遍历 fallbackReports,把每条 StoredReport 映射为 ValuesBucket 并以 ON_CONFLICT_REPLACE 写入数据库。
这避免每次启动重复插入。稳定 ID 与 ON_CONFLICT_REPLACE 也降低重复风险。
但“表为空”不等于“首次安装”。用户主动删除所有记录后再次启动,种子可能重新出现。若产品不希望这样,应使用独立初始化标记或数据库版本状态,而不是仅依赖行数。

上图明确区分两类证据:内存回退可以验证接口和业务语义;数据库 Schema、加密、事务、设备 I/O 与冷启动持久化,必须走真实 RelationalStore 路径。
九、私密字段为什么不直接放进 ItemReport
Repository 内部使用 StoredReport 同时保存公开 ItemReport 与 privateFeature。普通 list() 只返回公开模型,私密特征通过单独的 findPrivateFeature() 在认领审核场景读取。
这个接口分离降低了公开列表、匹配页或 Agent 输入误取私密信息的概率。无论数据来自内存还是数据库,访问边界都应保持一致。
不过“单独方法”不是完整鉴权。未来接入账号和后端后,还需要在服务端验证身份、记录访问审计,并限制私密字段出现在日志、缓存和错误上报中。
十、两种数据源的行为必须对齐
双数据源最容易出现的 bug 不是编译错误,而是语义不一致:
| 行为 | RelationalStore | 内存回退 | 对齐要求 |
|---|---|---|---|
| list 排序 | created_at 倒序 |
当前数组顺序 | 种子和写入顺序应一致 |
| findById | 谓词匹配 | 数组查找 | ID 归一化相同 |
| insert | 冲突替换 | 过滤旧项后头插 | 同 ID 最终只有一条 |
| update | 条件更新 | 替换对应索引 | 不存在时策略明确 |
| delete | 谓词删除 | 过滤数组 | 再查询都不可见 |
| status | 更新单列 | 修改内部状态 | 返回克隆,禁止旁路修改 |
为每个 Repository 接口建立一组契约测试,并分别运行内存实现与真实数据库实现,才能发现行为漂移。
十一、内存回退测试能证明什么
它适合验证:
- Service 调用 Repository 的顺序;
- ID、状态和类型转换;
- 匹配与筛选规则;
- 错误结果是否阻止后续动作;
- 读取对象是否克隆隔离;
- 写入、更新、删除的业务语义;
- 固定种子下的确定性输出。
这些测试速度快、无需设备,可以作为日常回归基础。
十二、内存回退测试不能证明什么
它不能证明:
CREATE TABLE和ALTER TABLE在目标系统有效;ValuesBucket列名与类型完全正确;- ResultSet 游标和关闭逻辑没有问题;
- S2、加密与实际文件权限满足要求;
- SQL 条件、索引和排序性能;
- 磁盘不足、数据库损坏和并发写入行为;
- 进程重启后的数据仍存在;
- 图片文件 URI 在真实设备可读取。
因此报告中应写“无 Context 契约测试 passed”,而不是“数据库持久化 passed”。证明层级必须和实际执行环境一致。
十三、失败回滚与数据权威
只要正式应用有 Context,RelationalStore 就应该是权威数据源。fallback 可以支撑无平台测试和受控降级,但不应在数据库写入失败后向用户返回成功。
一次写操作至少要明确:
- 业务校验是否通过;
- 图片物化是否完成;
- 数据库写入是否完成;
- fallback 是否需要同步或回滚;
- 失败后页面保留什么;
- 哪个结果会触发
dataRevision。
只有权威写入成功才发跨页面刷新信号。否则其他页面可能重查到回退数据,看似更新,重启后却消失。
十四、什么时候应该拆成两个实现类
当前项目把两条分支放在同一个 Repository 中,代码量有限、共享映射逻辑较多,改动成本较小。
当出现以下情况时,建议提取明确接口与两个实现:
- fallback 规则持续增加;
- 数据库与内存排序、分页差异扩大;
- 需要依赖注入和自动契约测试;
- 引入远端 Repository、缓存或离线同步;
- 页面需要明确显示当前数据来源;
- 异常策略不能再用简单回退表达。
拆分后仍应共享 ItemReport、错误模型和方法契约,Service 通过构造或工厂选择实现,而不是在每个业务方法里写 if (context)。
十五、验证矩阵与维护建议
建议把验证分为三层:
1. 无 Context 契约层
运行固定种子下的 list/find/insert/update/delete/status 用例,检查克隆隔离和 Service 结果。
2. 有 Context 数据层
在真机或模拟器上验证首次初始化、旧库迁移、条件查询、重启持久化、异常路径与图片引用。
3. 页面业务层
完成发布—首页出现—编辑—撤回—删除链路,确认所有页面都从权威数据重新读取,而不是手工补状态。
三层结果分别记录 passed、failed、not run。任何一层通过都不能自动替代另外两层。
十六、本文小结
ReportRepository 用同一套业务接口承接 RelationalStore 与内存回退:有 Context 时读写真实本地数据库,无 Context 时使用固定种子与克隆对象支撑契约测试。
双数据源的关键不是“随时有数据可显示”,而是保持方法语义一致、明确权威来源,并诚实区分证明边界。内存测试可以证明 Service 行为,却不能证明 Schema、加密、事务和真实设备 I/O。
系列导航:第 15 篇 / 共 50 篇。上一篇:《RelationalStore 安全增量迁移》;下一篇:《把 Repository 错误映射成 OperationResult》。
更多推荐


所有评论(0)