茶器艺科智造HarmonyOS应用实战-63-送印状态只存在三个页面字段里,进程重启后待处理订单为何永远不再推进:持久化任务截止时间
茶器艺科智造HarmonyOS应用实战-63-送印状态只存在三个页面字段里,进程重启后待处理订单为何永远不再推进:持久化任务截止时间
订单数组已经写进 Preferences,因此进程重启后仍能看到 pending 订单;但负责推进它的订单 ID 和截止时间只保存在页面字段里。页面消失时计时句柄和字段一起消失,重新打开只能恢复“待处理”这个结果,却恢复不了“何时应该进入下一状态”的依据。

本文以 Gitee 仓库 aiding521/chaqi-app-gitee 的 master 分支、提交 f671fcd8de277973bd7518a3c462f68384575f1e 的静态源码为事实基线。本文没有修改项目源码,也没有执行 HAP 构建、模拟器或真机验证;建议代码属于演进方案,不能描述成已经落地的功能。
一、订单被保存了,推进订单的事实却没有保存
静态源码显示,订单数组会进入 Preferences,而当前待处理订单 ID 与触发时刻保存在页面内存字段中。当前代码可以概括为:
private pendingSlicePrintOrderId: string = '';
private pendingSlicePrintFireAtMs: number = 0;
这两个字段在当前进程里可以帮助 timer 找到订单并推进状态,但它们不是订单持久化模型的一部分。进程被回收后,Preferences 中的订单仍然是 pending,新的页面实例却不知道它原定何时推进,也无法仅凭数组顺序可靠重建那个决定。
静态源码不能证明系统一定会在某个时刻回收进程,也不能证明真实运行中已经出现了永久停滞;它能确认的是恢复信息不完整。只保存状态快照,不保存下一次状态转换的时间事实,恢复路径就没有足够输入作出确定决策。
二、业务截止时间可恢复,timerId 不可恢复
建议把 nextTransitionAtMs 放进订单模型并随订单一起持久化。它表达“这笔订单最早在何时进入下一阶段”,属于跨进程仍有意义的业务事实;timerId 只是在当前进程中提醒页面何时再检查一次,进程结束后没有任何可复用价值。

流程图里的恢复入口与正常 timer 回调应汇入同一个幂等推进函数。正常运行时,timer 到点后调用它;进程重启时,页面从 Preferences 读回订单并扫描到期项,再调用同一函数。这样恢复不是“补造一个旧 timer”,而是根据持久化事实重新判断现在该处于哪个状态。
三、把下一次转换时刻放回订单模型
export interface ShopOrder {
id: string; status: OrderStatus; progress: number;
name: string; material: string; date: string;
nextTransitionAtMs?: number;
}
nextTransitionAtMs 是可选字段,因为并非所有订单状态都必须等待下一次自动转换。创建 pending 订单时写入截止时间;进入 printing 后清除或替换它,避免已经完成的阶段在下次恢复时再次触发。这个字段应与状态变更一起保存,不能先写 pending、稍后再单独补截止时间,否则两次写入之间仍存在不完整快照。
这里保存的是绝对时刻,而不是“还剩多少次回调”。绝对时刻让恢复代码只需比较 nowMs,不需要猜测进程离开了多久。若产品后来定义暂停语义,则模型还需相应事实,但不能继续依赖页面内存默认值。
四、恢复时扫描订单,而不是寻找已经失效的旧句柄
this.orders = this.orders.map((o: ShopOrder): ShopOrder =>
o.status === 'pending' && (o.nextTransitionAtMs ?? Number.MAX_VALUE) <= nowMs
? { ...o, status: 'printing', progress: 20, nextTransitionAtMs: undefined }
: o);
这段映射只推进同时满足两个条件的订单:状态仍是 pending,并且持久化截止时间不晚于 nowMs。转换完成后把状态改为 printing、进度设为现有方案中的 20,并移除本阶段的截止时间。再次用同一个 nowMs 执行时,订单已经不是 pending,因此不会重复推进。
Number.MAX_VALUE 让旧数据里缺少截止时间的 pending 订单保持原状,而不是被误判为立即到期。这是一种保守兼容策略:不会自动修复旧订单,却也不会无依据地推进。产品若要给旧数据安排恢复规则,应单独定义迁移或人工重试入口,不能把缺字段默认为“现在到期”。
五、每次转换都要同时更新状态与下一截止时间
| 订单阶段 | 持久化字段 | 当前进程可以做什么 | 恢复时的决定 |
|---|---|---|---|
新建 pending | id、状态、进度、nextTransitionAtMs | 为最近截止时间安排 timer | 未到期则重排,到期则推进 |
| 到期待处理 | 同上,且截止时间不晚于现在 | 调用幂等推进函数 | 直接调用同一推进函数 |
已进入 printing | 新状态、进度,本阶段截止时间已清除 | 如有下一阶段,再安排新事实 | 不重复执行 pending 转换 |
| 页面离开 | 订单事实仍在 Preferences | 清理 timer 句柄 | 下次读回后重新扫描 |
| 旧数据缺截止时间 | pending 但字段缺失 | 不凭空推断到期 | 保守保留并走兼容策略 |
订单状态与 nextTransitionAtMs 必须作为一个一致的快照理解。只更新状态不更新截止时间,会留下过期触发器;只更新截止时间不保存状态,又会让恢复逻辑对错误阶段执行转换。页面 timer 只是对最近截止时间的运行时优化,不应成为唯一调度记录。
六、用订单快照和 nowMs 验证恢复决策
interface BoundaryCase<T> {
name: string;
input: T;
expected: string;
}
const cases: BoundaryCase<string>[] = [
{ name: 'empty', input: '', expected: 'reject' },
{ name: 'normal', input: 'valid', expected: 'accept' },
{ name: 'repeat', input: 'valid', expected: 'idempotent' }
];
通用用例骨架落到本文时,input 应是一组订单和确定的 nowMs,expected 则是转换后的订单数组。至少覆盖未到期、刚好到期、早已到期、非 pending、缺失截止时间和多笔同时到期。还要把转换结果再次输入函数,确认第二次执行不会重复改变已经进入 printing 的订单。
纯逻辑可以验证扫描和幂等转换,却不能证明 Preferences 写入一定成功,也不能代替进程被系统结束后的真实恢复实验。存储失败时是否回滚页面状态、序列化字段能否兼容旧版本、读取完成前是否错误安排 timer,仍需分别观察并保留运行证据。
七、生命周期只管理唤醒句柄,不删除订单事实
private disposeOwnedResources(): void {
// timer/listener只由创建它的组件清理
// 异步结果写状态前核对taskId或pageGeneration
// 可恢复任务先保存业务事实,再释放进程句柄
}
创建 timer 的页面负责在离开、重排或任务完成时清理它。清理句柄的目的,是阻止旧实例的回调继续写状态,并不是取消订单。订单 ID、当前状态和 nextTransitionAtMs 已经保存后,即使当前页面没有任何 timer,下一次恢复扫描仍有足够事实作出决定。
页面出现时应先完成订单读回,再以同一个 nowMs 扫描到期项,保存转换结果,最后只为仍未到期的最近任务建立新句柄。顺序相反会让空数组或旧内存状态先安排一轮错误调度。迟到回调也要携带订单身份或 generation,防止旧页面推进新页面刚刚改过的订单。
八、验收必须真正跨过一次进程边界
- 新建
pending订单时,订单与nextTransitionAtMs在同一次业务更新中保存。 - 未到期时重启进程,读回后仍保持
pending,并按剩余时间重新安排。 - 截止时间已过后重启进程,恢复扫描把对应订单推进一次。
- 同一份到期订单连续扫描两次,第二次不再重复转换或重复副作用。
- 多笔订单同时存在时,每笔都按自己的 ID 和截止时间判断,不依赖单个页面槽位。
- 旧数据缺少
nextTransitionAtMs时不被默认成到期,并提供可解释的兼容结果。 - 页面快速离开和返回后至多存在一个有效 timer,旧回调不写新实例。
- Preferences 写入、读回、进程重启和设备行为分别留证;没执行的步骤继续写“未执行”。
九、这些做法仍把进程内偶然状态当成业务事实
| 误修 | 为什么进程重启后仍失效 | 正确方向 |
|---|---|---|
| 只把 timerId 写进存储 | 新进程不能复用旧句柄 | 保存业务截止时间 |
| 启动后把所有 pending 立即推进 | 丢失未到期语义 | 比较每笔截止时间 |
| 只保存 pending 订单 ID | 仍不知道何时推进 | ID 与截止时间随订单保存 |
| 读回后固定再等一轮完整时长 | 忽略离线期间已经过时间 | 用绝对时刻计算 |
| 缺字段默认成当前时间 | 旧订单会无依据地变化 | 显式兼容或人工处理 |
恢复逻辑的目标不是让界面“看起来继续动”,而是让同一业务事实无论从 timer 回调还是从进程重启入口进入,都得到同一个可解释终态。任何依赖旧页面字段才能成立的方案,都没有真正跨过进程边界。
十、订单模型和恢复规则下沉,页面只保留调度句柄

按本文已有模块关系,entry 负责产品入口,libraryhsp 负责页面与交互,libraryhar 负责模型、算法、路由和通用服务。扩展后的 ShopOrder、按 nowMs 扫描到期项的纯逻辑,以及旧数据兼容规则适合放在 HAR;Preferences 的具体读写编排、ArkUI 状态和 timerId 的创建清理留在 HSP/entry。
边界明确后,页面不再需要用一个 pendingSlicePrintOrderId 猜测全局待处理任务。它从订单集合计算最近截止时间来优化唤醒,真正的状态转换仍按订单 ID 执行。模型负责回答“应该发生什么”,页面负责回答“何时再来检查以及怎么展示”。
entry / libraryhsp -> 页面、组件、生命周期
libraryhar -> 纯类型、纯函数、可持久化模型
tests -> 边界、幂等、恢复序列
runtime evidence -> 日志、设备行为、服务读回
十一、证据边界:这里只确认恢复信息缺失
baseline: f671fcd8de277973bd7518a3c462f68384575f1e
scope: source-level proposal
build: not run
simulator/device: not run
network/account: not run
result: static boundary identified; implementation pending
本文基线能确认订单数组进入 Preferences,也能确认 pendingSlicePrintOrderId 和 pendingSlicePrintFireAtMs 是内存字段,因此可以指出持久化快照缺少推进依据。它不能证明某次系统回收已经发生,也没有执行真实存储读回或进程恢复。建议代码只是把业务截止时间并入模型的演进方向。
十二、用“保存后杀进程”复现恢复链
只做页面前后台切换不足以证明跨进程恢复,因为原来的字段仍可能留在内存。关键场景是在订单仍为 pending 时确认数据已经保存,然后结束进程,再分别在截止时间前后重新启动。恢复日志应说明每笔订单为什么保持、为什么推进,以及转换后的快照是否再次写回。
| 场景 | 操作序列 | 必须观察的证据 | 不能作为结论的替代物 |
|---|---|---|---|
| 未到期恢复 | 创建订单→保存→结束进程→提前重开 | 保持 pending、按截止时间重排 | 普通前后台切换 |
| 已到期恢复 | 创建订单→保存→结束进程→到期后重开 | 对应订单幂等进入 printing | 手动刷新界面 |
| 重复恢复 | 读回并推进→再次触发扫描 | 不重复转换、不重复副作用 | 最终状态看起来正确 |
| 多订单 | 建立不同截止时间的订单→重启 | 按各自 ID 和时刻分别处理 | 单槽字段最后指向某一笔 |
| 旧版快照 | 读入缺少截止时间的 pending | 保守兼容并留下原因 | 默认立即完成 |
日志至少记录 orderId、恢复批次或页面 generation、读回状态、nextTransitionAtMs、本次 nowMs、采取的决定以及保存结果。不要只写“恢复成功”,因为保持 pending 与推进 printing 都可能是正确的恢复结果,必须结合时间事实解释。
interface OperationTrace {
taskId: string;
generation: number;
startedAtMs: number;
finishedAtMs?: number;
terminalSource?: 'ui' | 'service' | 'restore' | 'cancel';
}
function canApplyResult(trace: OperationTrace, currentGeneration: number): boolean {
return trace.generation === currentGeneration &&
trace.finishedAtMs === undefined;
}
在本文中,跟踪结构里的 taskId 可以使用订单 ID 或一次恢复动作的身份。timer 回调和启动恢复都要在写状态前确认当前 generation,并保证同一订单终态只应用一次。存储写入失败时不能只更新页面数组,否则下一次进程恢复仍会读到旧快照。
最终评审应顺着一笔订单检查四个问题:创建时是否连同截止时间保存,到期后是否原子地清除本阶段截止时间,重复扫描是否幂等,旧版本缺字段是否采取安全兼容。编辑器里看到订单变为 printing 不能替代 Preferences 读回,也不能替代真正的进程重启。本文没有执行这些运行步骤,因此结果仍保持“未执行”。
补充审查要求:订单状态集合、进度值、存储结构和时间语义都要与后续源码及产品规则重新核对;若 SDK 或仓库版本变化,应重新建立基线,不沿用本文快照。对没有执行的构建、设备、存储和进程恢复步骤继续标记为未执行。
更多推荐

所有评论(0)