茶器艺科智造HarmonyOS应用实战-64-新送印会强制完成上一笔pending,为什么单槽调度容不下并发订单:把状态转换改成按订单管理
茶器艺科智造HarmonyOS应用实战-64-新送印会强制完成上一笔pending,为什么单槽调度容不下并发订单:把状态转换改成按订单管理
商城列表已经允许同时出现多笔订单,送印调度却仍只记一组 timer、订单 ID 和截止时间。第二笔送印到来时,代码先推进上一笔,再把这组字段整体覆盖;表面上是“继续处理”,实质上是用新订单改写旧订单的运行上下文。本文把问题收窄到一个核心:订单是多实例,调度状态也必须按订单区分。

本文以 Gitee 仓库 aiding521/chaqi-app-gitee 的 master 分支、提交 f671fcd8de277973bd7518a3c462f68384575f1e 的静态源码为事实基线。本文没有修改项目源码,也没有执行 HAP 构建、模拟器或真机验证;建议代码属于演进方案,不能描述成已经落地的功能。
一、从三个单槽字段还原串单路径
静态源码显示,新送印会先调用一次强制推进,再写入唯一的待处理订单 ID,并把新句柄放进唯一 timer 字段。当前代码可以概括为:
this.applySlicePrintKickScheduled(true);
this.pendingSlicePrintOrderId = orderId;
this.printerOrderTransitionTimerId = setTimeout(...);
把操作顺序换成 A、B 两笔订单就容易看清风险:A 创建延迟回调,B 在 A 到期前进入;B 先推动 A,又把 pendingSlicePrintOrderId 与 timer 句柄换成自己的。此后“当前待处理订单”只能指向 B,A 的原始等待过程已经无法从这些字段独立追踪。这里确认的是源码结构与可发生的覆盖路径,不等于证明线上已经出现串单;设备调度、后台节流以及实际打印结果仍需运行证据。
二、并发能力必须与业务队列一致
解决方向不取决于 timer 写法,而取决于产品到底允许什么。若多笔订单能够各自推进,就需要让每个订单拥有独立调度记录;若一台打印机只能串行处理,也不该用“强制完成上一笔”模拟串行,而应显式增加 queued,由一个调度器按顺序选出下一笔。两种方案可以共用订单状态模型,却不能混用同一个“当前订单”字段。

流程图中的关键分叉应发生在调度前:先判断该订单是否已有任务,再依据并发或串行规则决定“独立启动、进入队列、拒绝重复”中的一种。页面负责发起动作和展示订单;订单状态转换负责判断合法迁移;timer Map 或扫描器只负责在正确时刻提出迁移请求。把这三层拆开后,B 的到来不再隐式改变 A 的终态。
三、让 timer 与 orderId 一一对应
如果选择每笔订单独立推进,最小变化不是再增加第二组字段,而是把运行句柄按 orderId 收进 Map。现有建议代码如下:
private transitionTimers: Map<string, number> = new Map();
private scheduleOrderTransition(orderId: string, delayMs: number): void {
const id = setTimeout(() => this.updateShopOrderById(orderId, 'printing', 20), delayMs) as number;
this.transitionTimers.set(orderId, id);
}
这段模型至少恢复了可寻址性:创建时用订单 ID 写入,回调时仍携带同一个订单 ID,取消时也能定位同一条记录。真正接入时还需要在回调执行后移除对应 Map 项,并规定同一订单重复调用 scheduleOrderTransition 时是覆盖、忽略还是先取消旧句柄。这个决定必须显式,不能依赖 Map 的最后一次赋值,因为 Map 更新并不会自动清除旧 timer。
Map 只保存当前进程资源,不能替代订单事实。订单状态、进度和可恢复的截止时间仍属于业务数据;timerId 离开进程就没有意义。把两者分开,才能既处理页面离开,也为后续进程恢复留下空间。
四、取消路径也要覆盖整个订单集合
单槽实现通常只会清理“最后一个句柄”。改成按订单持有后,统一清理就必须遍历集合,现有建议代码保留如下:
private cancelAllOrderTransitions(): void {
this.transitionTimers.forEach((id: number) => clearTimeout(id));
this.transitionTimers.clear();
}
这个方法适合组件销毁时释放其拥有的全部进程句柄;单笔取消则应根据 orderId 找到一个句柄,clearTimeout 后删除该键。两条路径不能互相替代:用户取消 A 时不应连带清掉 B,而页面整体销毁时也不能只清理当前选中的订单。
接回现有组件时,改动范围应集中在送印入口、订单状态更新、单笔取消和页面清理四个点。杯型参数、切片算法、商城筛选与档案页面并不参与这次身份覆盖,不应借机重构。这样评审时可以沿着一个 orderId 从创建一直追到终态。
五、把并发规则写成订单级转换表
| 当前情况 | 新动作 | A 订单结果 | B 订单结果 |
|---|---|---|---|
| 无活动订单 | 发起 A | A 建立自己的调度记录 | 不存在 |
| A 为 pending | 再次发起 A | 保持原任务或明确重排,不能生成两个匿名回调 | 不存在 |
| A 为 pending | 发起 B(并行策略) | A 继续保持 pending | B 建立独立记录 |
| A 为 pending | 发起 B(串行策略) | A 不被强制完成 | B 进入 queued |
| A 被取消 | 取消 A | 仅 A 的句柄被清理 | B 不受影响 |
| 页面离开 | 清理运行资源 | 保存的业务事实不因清句柄而伪造完成 | B 同样按自身状态处理 |
这张表刻意不把“新订单到来”写成旧订单的完成条件。completed 应来自对应订单自己的完成事件或明确的恢复判断,而不是另一笔业务的副作用。若产品采用串行打印,还要补充 queued 到 printing 的选取规则;若采用独立模拟,则需要保证每个回调只更新其携带的 orderId。
六、测试重点是订单之间不互相污染
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 具体化为订单动作序列,例如 start(A)、start(B)、cancel(A),输出则记录每笔订单的状态与活动句柄数。最关键的断言不是“最后显示 printing”,而是 A 的每次变化都由 A 的事件触发,B 同理。
至少需要覆盖:同一订单重复送印、两笔订单紧邻送印、只取消第一笔、先完成第二笔、旧回调迟到以及页面离开后重新进入。纯逻辑可以验证迁移函数和 Map 管理约定,却不能替代定时器在设备前后台切换时的实际行为,因此源码级用例与设备级证据要分开记录。
七、区分页面寿命与订单寿命
private disposeOwnedResources(): void {
// timer/listener只由创建它的组件清理
// 异步结果写状态前核对taskId或pageGeneration
// 可恢复任务先保存业务事实,再释放进程句柄
}
这段通用清理约束在本篇尤其重要:timer 属于创建它的页面或调度对象,订单却可能需要跨页面存在。aboutToDisappear 可以调用统一清理释放所有 timer,但不能因此把所有 pending 订单改成 completed;否则资源释放又变成了一次业务状态伪造。
如果页面重新出现,恢复逻辑应读取每笔订单保存的状态或截止时间,再逐笔决定立即补算、重新排程或等待用户处理。回到前台仅能补偿当前进程内的节流,不能证明进程被杀后的任务已经执行。运行句柄清零与业务任务终止是两件事,日志也应分别表达。
八、验收不能只看最后一张卡片
- 搜索送印路径,确认唯一
pendingSlicePrintOrderId和唯一 transition timer 是否已被完整替代,而不是新旧机制并存。 - 先发 A、紧接着发 B,分别记录 A/B 的状态序列、回调携带的 orderId 和活动句柄数量。
- A pending 时取消 A,确认 B 的句柄与状态均未变化。
- 重复点击 A,确认约定的幂等或重排策略生效,没有遗留不可寻址的旧回调。
- 页面离开前保留两笔未完成订单,确认所有进程句柄被释放,但订单没有被批量伪造为完成。
- 返回页面或重启进程后,逐笔核对恢复决策,缺失字段不能自动走向危险终态。
- 构建、模拟器、真机和打印服务读回分别记录;未执行项继续写“未执行”。
九、四种看似解决并发的误修
| 误修 | 问题 | 正确方向 |
|---|---|---|
| 再加一个备用 timer 字段 | 只能从一笔扩成两笔,容量仍写死 | 用 orderId 寻址集合,或建立显式队列 |
| B 来时先完成 A | 把新请求误当作 A 的完成证据 | A/B 各自由自身事件迁移 |
| 只把 timer 放进 Map | 同一订单重复调度仍可能遗留旧句柄 | 规定覆盖、拒绝或先取消策略 |
| 离开页面就把 pending 全改完成 | 混淆资源清理与业务终态 | 释放句柄,保留可恢复事实 |
| 只观察 B 最后成功 | A 的错误终态被界面最新结果遮住 | 记录并断言每笔订单完整序列 |
并发排障应先问“回调属于哪笔订单”,再问“谁拥有这个句柄”,最后问“什么事件有资格写终态”。延长等待时间或调整提示文案都不会回答这三个问题。
十、让模型、调度与页面各守边界

按本文静态基线,entry 是产品入口,libraryhsp 承担页面和交互,libraryhar 放置模型、算法、路由与通用服务。订单状态与合法迁移可以保持为纯模型;timerId 依赖当前进程和页面生命周期,适合由 HSP/entry 中明确的所有者持有。若以后抽成独立调度服务,也应继续区分可持久化字段与运行句柄。
entry / libraryhsp -> 页面、组件、生命周期
libraryhar -> 纯类型、纯函数、可持久化模型
tests -> 边界、幂等、恢复序列
runtime evidence -> 日志、设备行为、服务读回
这份分层图不能自动决定并行还是串行,但能防止订单模型反向依赖页面 timer。测试层应输入一串带 orderId 的动作,验证每笔订单状态;运行证据层再核对设备上的回调顺序与服务结果。
十一、本文证据边界
baseline: f671fcd8de277973bd7518a3c462f68384575f1e
scope: source-level proposal
build: not run
simulator/device: not run
network/account: not run
result: static boundary identified; implementation pending
本文能确认单槽字段、强制推进调用和按订单管理的风险边界,建议代码只展示最小收口方向。它没有证明 Map 方案已接入,也没有证明真实打印机支持并行;构建、模拟器、真机、账号、网络和性能结果均未执行。后续若产品规则明确只能串行,应把 queued 方案纳入设计,而不是把本文示例直接当成已验证实现。
十二、用 A/B 订单复现覆盖与隔离
复现时不要用“连续点几次,最后成功”作为步骤。先固定两笔身份明确的订单 A、B,再分别记录发起、排程、回调、取消和恢复事件。这样即使界面只显示最新 Toast,也能从轨迹判断旧订单是否被新订单强制改写。
| 场景 | 操作序列 | 必须观察的证据 | 不足以作为结论的现象 |
|---|---|---|---|
| 单笔基线 | 发 A→等待 A 到期 | A 的完整状态序列与句柄回收 | A 卡片最终显示完成 |
| 并行隔离 | 发 A→A 未完成时发 B | A/B 各有身份对应的迁移记录 | 最后一条 Toast 正常 |
| 局部取消 | 发 A、B→只取消 A | A 句柄删除,B 继续保持原计划 | 两张卡片仍在列表中 |
| 页面离开 | 发 A、B→立即返回 | 所有 owned timer 清零,业务状态未被伪造 | 页面没有崩溃 |
| 进程恢复 | 保存两笔事实→杀进程→重开 | 每笔截止时间与恢复决策可对应 | 仅做前后台切换 |
日志不要只打印 success 或 failed。至少带上 orderId、调度时间、回调时间、旧状态、新状态和终态来源;只有涉及页面级异步时才需要 pageGeneration。涉及账号或材料时只记录必要状态码,不记录令牌、完整输入或敏感内容。
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 应能够关联到具体 orderId,不能只依赖当前页面 generation。评审还要追问:重复回调是否幂等、B 先结束是否会推动 A、取消 A 是否影响 B、旧版本订单缺少截止时间时采取什么保守策略。四个答案都应能在状态转换或测试中找到,而不是藏在某个按钮回调里。
真实验收应保留 A/B 两笔订单的截图、带身份的日志片段和可重放步骤。编辑器预览只能说明页面能展示,本地模拟状态也不是打印服务回执。本文基线没有执行这些运行步骤,因此结果栏仍保持“待验证”。
补充审查要求:示例里的状态名、订单字段和延迟值都要与变更时仓库核对;若源码、SDK 版本或“并行/串行”产品规则变化,应重新建立基线,不沿用本文快照。没有执行的构建、设备、网络和账号步骤继续标记为未执行。
更多推荐

所有评论(0)