ArkTS + HarmonyOS 巡点台:后台恢复与本地数据一致性诊断(连载05/06)【共创稿事节】
巡检员完成了两项检查,刚要走出配电房就切到了地图应用。重新进入“巡点台”时,页面还保留着“已选两项”的样子,但这并不证明两条记录都已经落盘。若此时把内存中的勾选状态直接恢复成“提交成功”,现场人员可能以为工作已经记录,实际上前一次写入还停留在队列里。这个细节把离线工单的两个概念分开了:界面离开前显示了什么,以及持久化层最后确认了什么。
前四篇沿用同一个 Ticket { id, title, done },稳定 ID 从 T001 开始;patrol_records Preferences 文件中的 tickets_v1 保存快照,pending_ops_v1 保存确定性 setDone、setTitle 操作日志。第四篇的 BatchCommitter 已经将进程内提交串行化,但尚未执行的排队操作并不会自动变成可靠日志。本篇不添加远端接口、不偷偷替换成数据库,而是补一个只读数据诊断页,给页面切后台、返回前台、进程重新创建这三类场景提供可核验的依据。

一、别把生命周期事件当作存储完成通知
一个容易误用的方案是:用户离开页面时,在 onPageHide() 里再把整个列表写一遍,认为这样就能兜住所有数据。这里至少有三处不成立。其一,切换应用后进程可能继续运行,页面不可见并不等于存储写入失败;其二,进程可能在系统资源回收时被终止,不能依靠“退出之前一定会执行”的回调承担唯一持久化路径;其三,第四篇已经有串行队列,如果生命周期回调又绕开队列写旧数组,恰好会重新引入“旧快照盖掉新变更”的竞争。
因此本篇的硬规则是:每次用户操作由 BatchCommitter 进入原来的 LocalJournal.commit(),并等待 flush() 的结果;页面隐藏时不另启独立写盘。 onPageHide() 只撤去无关动画、记录页面可见性;onPageShow() 或页面再次进入时更新可视信息。真正跨进程的恢复仍从 WorkOrderPage.boot() 开始,先调用 LocalJournal.recover(),再构建 TicketModel 数组。生命周期事件不是 flush() 的回调,更不是跨两个 Preferences 键的事务承诺。
例如,用户勾选了 T001 和 T002,点击批量完成,队列刚处理完 T001 就离开应用。正常情况下,T001 可能已经被确认写盘,T002 可能尚未开始。下次进入时应该检查持久化快照和日志,再决定提示“已完成几项”;不能因为上次按钮收到了点击事件就显示两项全部成功。为帮助定位这种差异,我们把诊断页的读取范围限制为三个事实:工单快照、尚待恢复的操作日志、数据是否满足当前协议。
二、先区分「不存在」「空数组」「损坏」
如果开发者写 JSON.parse(pref.getSync('tickets_v1', '[]') as string),结果恰好为 [],容易误以为“现在没有任何工单”。但真实情况还可能是首次安装、数据键被意外删除,或为了避免异常给损坏的 JSON 填了一个回退默认值。前者允许按第一篇的初始化规则注入种子数据,后两者不能随便覆盖。诊断页使用 hasSync() 明确判断键是否存在;存在时必须原样解析并逐项校验,而不是把读取错误解释成空列表。
// entry/src/main/ets/common/PatrolInspector.ets
import { preferences } from '@kit.ArkData';
import { Ticket, PatrolOp } from '../model/PatrolTypes';
export interface PatrolReport {
level: '正常' | '待恢复' | '阻断';
ticketCount: number;
completedCount: number;
pendingCount: number;
message: string;
}
export class PatrolInspector {
private pref: preferences.Preferences;
constructor(pref: preferences.Preferences) { this.pref = pref; }
inspect(): PatrolReport {
try {
if (!this.pref.hasSync('tickets_v1')) {
throw new Error('工单快照键不存在');
}
const raw: string = this.pref.getSync('tickets_v1', '') as string;
const tickets: Ticket[] = JSON.parse(raw) as Ticket[];
if (!Array.isArray(tickets)) { throw new Error('快照不是数组'); }
const ids: Set<string> = new Set<string>();
tickets.forEach((item: Ticket) => {
if (!item || typeof item.id !== 'string' || item.id.length === 0 ||
typeof item.title !== 'string' || typeof item.done !== 'boolean' ||
ids.has(item.id)) { throw new Error('工单字段或ID异常'); }
ids.add(item.id);
});
const pendingRaw: string = this.pref.getSync('pending_ops_v1', '[]') as string;
const pending: PatrolOp[] = JSON.parse(pendingRaw) as PatrolOp[];
if (!Array.isArray(pending)) { throw new Error('待恢复日志不是数组'); }
pending.forEach((op: PatrolOp) => {
if (!op || typeof op.opId !== 'string' ||
typeof op.ticketId !== 'string' ||
(op.kind !== 'setDone' && op.kind !== 'setTitle') ||
(op.kind === 'setDone' && typeof op.done !== 'boolean') ||
(op.kind === 'setTitle' && typeof op.title !== 'string')) {
throw new Error('日志字段无效');
}
});
return {
level: pending.length > 0 ? '待恢复' : '正常',
ticketCount: tickets.length,
completedCount: tickets.filter((x: Ticket) => x.done).length,
pendingCount: pending.length,
message: pending.length > 0 ? '存在待确认操作,请执行日志恢复' : '快照可读取'
};
} catch (e) {
return { level: '阻断', ticketCount: 0, completedCount: 0,
pendingCount: 0, message: '数据校验失败,请勿继续写入' };
}
}
}
这段代码的职责是只读诊断,不会自行调用 putSync()、flush() 或移除任何键。pending_ops_v1 的数量只代表数组条目数,不代表这些操作都有效或都尚未写入快照。代码对 opId、ticketId、动作类型以及动作目标进行了最小结构检查;实际工程应将第三篇 LocalJournal.readPending() 中的校验抽成公共 validatePendingOps(),避免同一协议被两处代码分别维护。诊断只说明“有几条日志符合结构”,并没有真正运行 recover(),更不能认为日志存在就意味着所有动作已被写入快照。即使校验失败,返回值中的计数 0 也必须配合红色的“阻断”状态理解,不能作为“工单数为零”继续进入正常提交界面。

这张界面图展示的是启动恢复前从维护入口读取到的示例快照:四项工单中两项已完成,另有一条符合结构的待恢复操作。若正常启动路径已经成功完成 recover(),日志会被清理,此时页面重新检查应显示“待恢复日志 0 条”。因此不要把这张“待确认”页面理解为正常恢复之后仍应保留的最终状态。
三、把诊断结果接入页面,但别制造第二个写入者
原页面成功 recover() 后,应当再刷新诊断信息;如果 recover() 抛错,则停留在受限的故障入口,不能开放“批量完成”“编辑标题”。这样做不影响 TicketModel 与 @ObjectLink 的列表渲染关系,也不会让新的诊断组件抢走 BatchCommitter 的持久化所有权。诊断页不持有一份可编辑的 Ticket[],只展示摘要;用户看见了四条工单、其中两条完成、仍有一条待确认,也不意味着可以绕过原日志协议点击“立即覆盖”。
// entry/src/main/ets/pages/PatrolHealthPage.ets
import { preferences } from '@kit.ArkData';
import { PatrolInspector, PatrolReport } from '../common/PatrolInspector';
@Entry
@Component
struct PatrolHealthPage {
@State private report: PatrolReport = {
level: '正常', ticketCount: 0, completedCount: 0,
pendingCount: 0, message: '正在检查本地记录'
};
private refresh(): void {
const pref: preferences.Preferences = preferences.getPreferencesSync(
getContext(this), { name: 'patrol_records' });
this.report = new PatrolInspector(pref).inspect();
}
aboutToAppear(): void { this.refresh(); }
build() {
Column({ space: 16 }) {
Text('巡点台 · 数据诊断').fontSize(24).fontWeight(FontWeight.Bold)
Text(this.report.level).fontSize(20)
Text(`本地工单 ${this.report.ticketCount} 项`)
Text(`已完成 ${this.report.completedCount} 项`)
Text(`待确认日志 ${this.report.pendingCount} 条`)
Text(this.report.message).fontSize(15)
Button('重新检查').onClick(() => this.refresh())
}.padding(24).width('100%')
}
}
页面使用 getPreferencesSync() 读取当前实例的缓存值,并不强制重新从物理文件加载。对于同进程页面跳转后的诊断,这是符合预期的;如果开发者要做严格冷启动故障复现,就应终止并重新启动应用进程,而非在同一缓存实例上按十次“重新检查”。本篇把“刷新诊断”和“重做提交”分成两个按钮路径:前者只检查当前状态,后者必须回到现有恢复、串行写入流程。避免让一次看似无害的检查意外清理掉重要的待处理日志。
四、用可重放的三段场景验证提示是否诚实

建议按三个窗口进行验证。窗口 A:队列尚未取出下一条。 首条操作完成后切后台,此时还有排队但未写入日志的动作;下次进程重建后只允许恢复已持久化的部分,不能把遗失的内存队列说成已提交。窗口 B:日志写入已经 flush(),快照写入尚未完成。 启动时第三篇的 recover() 应把确定目标值重放到快照,再清理日志。窗口 C:快照写入完成但日志清理前退出。 即使重复重放,setDone(true) 的结果也不会被“切换”回未完成。三种窗口都需要检查两份键值,不应只观察提示条颜色。
另一个必须覆盖的路径是损坏数据:手工把开发环境里的 tickets_v1 改为无法解析的 JSON,或制造重复工单 ID,诊断应显示“阻断”,原始数据必须保持不变。对于 pending_ops_v1,如果里面的 kind 不属于既定动作,LocalJournal.recover() 应拒绝执行,而不是简单过滤错误条目继续写盘。这是“容错”和“静默丢数据”的边界。不要在正式产品里提供一键清空所有记录的红色按钮去掩盖存储问题;更稳健的方案是用户确认后导出备份,再交给专用恢复流程分析。

这些测试应在开发环境通过可控的异常注入和进程重启执行,并核对 tickets_v1、pending_ops_v1 的实际内容;本文给出的是预期行为与复现路径,不是设备跑出的日志或速度统计。一个已完成的操作是否被系统确认,要看 flush() 和恢复结果;一个还在进程队列里的动作,则没有跨进程持久化承诺。产品文案必须将“排队中”“已确认写盘”“需要恢复”分开,而不是全部写成“保存成功”。
诊断结果的呈现也需要留意访问权限。普通巡检员只需要知道“能否继续提交”,不必看到完整异常 JSON;维护人员可以在受限的排障页面查看具体字段失败原因,并在用户允许的情况下执行脱敏备份。把原始工单标题放进系统日志,可能会连带泄露机房名称、楼层或者设备标识,因此诊断图例中只展示数量和状态,不展示原始存储内容。即使未来引入埋点,也应优先记录恢复分支、操作类型和匿名错误分类,不记录未经处理的业务数据。
还有一个容易落空的要求是“恢复完成后 UI 立即准确”。recover() 返回快照只是数据层成功了,页面仍需将返回结果转换成新的 TicketModel 数组,再通知行组件刷新;切后台前保留的 selectedIds 应当重新与快照的稳定 ID 交集校验,不应直接把旧选择当作新页面的业务确认。尤其当列表经过用户新增和删除后,某个已不存在的 ID 若仍在选择数组里,下一次批量操作可能被误计为成功项。可以在 boot() 成功分支执行一次 selectedIds = selectedIds.filter(id => rows.some(item => item.id === id)),这一操作只修正界面选择,不写盘、不改变 done。
目前这套方法仍适合单进程、轻量数据的离线工具,不能提供数据库式跨键事务或多个进程同时修改的保证。它的价值是把危险的隐含假设变成可检查的三个状态:快照有效、日志待处理、数据阻断。下一篇可以在此基础上完成真正的导出与恢复策略,并总结从 Preferences 迁移到事务存储时哪些边界必须保留。
接口和版本说明:示例使用 Stage 模型中 API 10 起可用的 getPreferencesSync、getSync、putSync 及 flush,轻量 KV 数据不需要单独的外部文件访问权限;onPageShow/onPageHide 属于 @Entry 路由页面的生命周期,而非磁盘同步事件。新增页面需要加入工程的 main_pages.json 并从维护入口导航,旧版工单及日志仍由既有类管理。相关资料:华为开发者 Preferences API、路由页面生命周期、ForEach 稳定键。
更多推荐


所有评论(0)