【寻迹校园 HarmonyOS NEXT 实战 13】RelationalStore 草稿恢复:按 LOST/FOUND 隔离两类发布草稿
【寻迹校园 HarmonyOS NEXT 实战 13】RelationalStore 草稿恢复:按 LOST/FOUND 隔离两类发布草稿
这是“寻迹校园 HarmonyOS NEXT 实战”系列第 13 篇。本文结合
ReportDraftRepository.ets与PublishFormPage.ets,介绍如何用report_type主键分别保存丢失和拾得草稿,并处理图片 URI、发布后清理、Context 缺失回退与失败边界。

上图为本文原创生成的架构插画,不是应用截图。蓝色 LOST 草稿与橙色 FOUND 草稿共享同一个 Repository 接口,却拥有独立数据行,用户切换发布类型时不会互相覆盖。
一、为什么一份“最后草稿”不够用
“寻迹校园”的发布入口先选择类型:我丢了物品,或我捡到物品。两种表单字段相似,但业务含义并不相同:
- LOST 记录描述丢失物,后续要匹配 FOUND 候选;
- FOUND 记录描述拾得物,还包含私密核验特征;
- 两类草稿可能在不同时间分别填写;
- 发布其中一类,不应该清空另一类;
- 页面标题、提示和后续状态机都依赖
ReportType。
如果只保存一个 last_draft,用户上午填写的丢失草稿可能被下午的拾得草稿覆盖。恢复时页面还要猜这份数据属于哪种类型,容易出现私密字段或文案错位。
项目直接把 report_type 设为草稿表主键:每种类型最多一份当前草稿,规则简单、可查询、可单独清理。
二、草稿表的最小数据契约
表结构包含表单恢复需要的字段:
CREATE TABLE IF NOT EXISTS report_draft (
report_type TEXT PRIMARY KEY,
title TEXT NOT NULL,
category TEXT NOT NULL,
area TEXT NOT NULL,
event_date TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
private_feature TEXT NOT NULL,
image_uris TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
)
report_type 既是业务维度,也是唯一键。updated_at 用于记录最后保存时间;当前版本没有多份历史草稿,因此不需要额外自增 ID。
数据库配置使用与其他本地业务表相同的 xunji_campus.db,安全级别为 S2,并启用加密。这里仍要保持准确表述:配置存在不等于所有设备环境都已完成安全验收,最终要以真实运行和数据检查为准。
三、Repository 接口按 ReportType 工作
页面不拼 SQL,只调用三个动作:
load(reportType: ReportType, context?: UIAbilityContext)
save(draft: ReportDraft, context?: UIAbilityContext)
clear(reportType: ReportType, context?: UIAbilityContext)
三者都显式携带类型,避免“加载当前草稿”这种模糊 API。调用方从路由获得 ReportType.LOST 或 ReportType.FOUND,整个生命周期都使用同一个稳定值。
显示文案可以变化,但持久化 key 不能因为“我丢了物品”改成“发布失物”而变化。这和路由 key、状态枚举的设计原则相同:稳定值与中文标签分离。
四、load 只查询同一类型的一行
存在 Context 时,Repository 为 TABLE_DRAFT 创建 RdbPredicates,用 equalTo('report_type', reportType) 限定类型,并通过 limitAs(1) 只读取一行。
读取到结果后再重建 ReportDraft,而不是把 ResultSet 直接交给页面。这样数据库列名、序列化格式和 ResultSet 生命周期都留在 Repository 内部。
ResultSet 使用 try/finally 关闭,避免异常或提前返回导致资源未释放。页面最终拿到的是普通业务模型,可以直接恢复标题、分类、区域、日期、描述、私密特征和图片 URI。
五、save 使用 ON_CONFLICT_REPLACE 保持每类一份
保存时把 ReportDraft 映射为 ValuesBucket,再以 ON_CONFLICT_REPLACE 写入 TABLE_DRAFT。
由于 report_type 是主键,同类型再次保存会替换原行;另一类型不受影响。这个模型特别适合“每类只恢复最近一份”的产品要求。
如果未来需要草稿列表、跨账号草稿或版本历史,就不能继续把类型当作唯一主键。届时应增加 draft_id、用户 ID、版本号和更新时间索引,并重新设计冲突与清理策略。
六、图片 URI 为什么使用明确分隔符
当前最多 3 张图片,Repository 用控制字符 \u001F 作为 PHOTO_SEPARATOR,并在序列化前执行 uris.slice(0, 3),把有限 URI 写入一个文本字段。
使用不常出现在 URI 中的分隔符,比逗号更不容易与查询参数冲突。反序列化时过滤空值并再次 slice(0, 3),继续守住数量契约。
这是当前小规模模型的务实选择。若图片需要独立状态、排序、上传进度、尺寸或哈希,应使用独立媒体表,而不是不断给一个分隔字符串增加语义。
七、PublishFormPage 如何决定是否恢复
新建表单出现时,页面按当前 reportType 调用 loadDraft()。恢复不是无条件覆盖当前输入,还要满足:
- 当前是“新建”而不是编辑已发布记录;
- Repository 确实返回对应类型草稿;
- 页面还没有进入提交或销毁状态;
- 恢复后用中性提示说明数据来自本地;
- 图片缺失时提供占位,不因单个资源失败清空文本。
编辑正式记录与恢复新建草稿是两条不同路径。混用会让旧草稿覆盖已发布数据,或者在保存编辑后误清理新建草稿。

上图展示 report_type 主键下的读写关系:LOST 与 FOUND 各占一行;成功发布 LOST 后只删除 LOST,FOUND 草稿仍然保留。
八、发布成功后为什么只清同类型草稿
清理实现同样使用类型谓词:equalTo('report_type', reportType) 后删除匹配行,不执行无条件全表删除。
清理时机必须放在正式记录写入成功之后。如果图片物化或 Report Repository 写入失败,草稿仍应保留,用户才能重试。
正确顺序是:
- 完整校验当前
ReportDraft; - 物化需要长期保存的图片;
- 写入正式 Report;
- 确认成功后清当前
reportType草稿; - 通知页面数据失效并进入成功页。
任何失败都不应清另一类型草稿,也不应跳转成功页。
九、无 Context 时的内存回退如何保持接口一致
Repository 维护静态 fallbackDrafts。没有 Context 时,load 从内存查同类型,save 替换同类型项,clear 过滤同类型项。
返回前还会 cloneDraft(),避免页面修改对象后直接污染 Repository 内部数组。克隆隔离使无 Context 测试更接近真实存储语义:读取结果是一个快照,不是数据库内部行的可变引用。
但必须强调,内存回退不能证明:
- RelationalStore 表创建和迁移成功;
- S2 与加密配置在目标设备生效;
- 应用重启后草稿仍存在;
- ResultSet、SQL 和文件 I/O 正确;
- 磁盘失败时的错误映射完整。
它只证明 Service 与 Repository 的接口契约能在无平台环境下运行。
十、数据库异常时回退的收益与风险
当前 load() 在数据库异常时可能返回内存中的同类型草稿。这能让演示路径保持可用,却也带来证据边界:用户看到草稿,不一定说明数据库读取成功。
生产环境要根据产品目标决定是否允许这种回退。如果允许,应记录受控错误分类,并避免把回退数据标成“已从本地数据库恢复”;如果不允许,应显示可重试错误,同时保留页面当前输入。
最危险的做法是静默吞掉所有异常并声称持久化成功。保存动作失败时尤其不能只写入内存后返回成功,否则用户重启应用会发现草稿消失。
十一、schemaReady 只优化当前实例,不替代迁移判断
Repository 使用 schemaReady 防止同一实例重复执行建表和列检查:
if (!this.schemaReady) {
await this.store.executeSql(CREATE_DRAFT_TABLE_SQL);
await this.ensureColumn(this.store, 'event_date', "TEXT NOT NULL DEFAULT ''");
await this.ensureColumn(this.store, 'image_uris', "TEXT NOT NULL DEFAULT ''");
this.schemaReady = true;
}
这个布尔值只在当前进程内有效。应用重新启动后仍会进入初始化,因此迁移语句本身必须幂等:先查询列是否存在,缺失时才执行 ALTER TABLE。
下一篇会专门拆解这条迁移链,以及为什么“直接 ALTER,报错就 catch”不是理想的长期方案。
十二、草稿恢复的验收矩阵
建议覆盖以下组合:
| 场景 | 预期结果 |
|---|---|
| 只保存 LOST | LOST 恢复,FOUND 为空 |
| LOST 与 FOUND 都保存 | 两类分别恢复,不互相覆盖 |
| 发布 LOST 成功 | LOST 清除,FOUND 保留 |
| 发布 LOST 失败 | LOST 与 FOUND 都不被清理 |
| 编辑正式记录 | 不加载新建草稿 |
| 草稿含 0/1/3 张图 | 顺序稳定,数量不越界 |
| 无 Context | 使用克隆的内存回退 |
| 冷启动 | 真实 RelationalStore 草稿恢复 |
还要检查大字体、小屏、返回路径和页面销毁后的异步回写。恢复提示不能遮挡字段错误,图片失败不能让文字草稿消失。
十三、隐私与日志边界
草稿可能包含私密核验特征与本地图片引用,因此:
- 日志不能打印完整
ReportDraft; - 公开列表与 Agent 输入不能读取
private_feature; - 错误提示不能暴露数据库路径或 SQL;
- 删除草稿时要按引用关系处理受管图片;
- 卸载或清理数据时遵循应用存储生命周期;
- 将来接入云同步前必须重新取得用户知情并定义保留策略。
“数据库加密”不能替代访问边界。字段是否被页面、日志和外部能力读取,仍由 Service 与 Repository 的接口设计决定。
十四、当前验证边界与后续建议
通过代码和无 Context 路径,可以复核按类型替换、克隆隔离、最多 3 张以及清同类型的契约。真实设备还需验证:首次建表、旧库增量迁移、冷启动恢复、图片可读性、异常断电和低磁盘场景。
验收记录应明确写成 passed、failed、not run。尤其不能用内存回退测试通过,替代“RelationalStore 冷启动恢复已通过”。
后续若支持多个草稿,应先升级数据模型和 UI 信息架构,再修改 Repository;不要简单把数组 JSON 塞进同一行。
十五、本文小结
ReportDraftRepository 用 report_type 主键为 LOST 与 FOUND 各保存一份草稿。load/save/clear 都显式携带类型,成功发布后只清同类草稿;图片 URI 以有限数量序列化,页面不直接操作数据库。
无 Context 内存回退保持接口可测试,但不证明真实持久化。下一篇将继续进入 Schema 迁移:如何先查询 PRAGMA table_info,只在缺列时安全补上 event_date。
系列导航:第 13 篇 / 共 50 篇。上一篇:《把临时 URI 复制到应用沙箱》;下一篇:《RelationalStore 安全增量迁移》。
更多推荐


所有评论(0)