HarmonyOS ArkUI:异步排程回执乱序合并与撤销(连载07)【共创稿事节】
排程页面已经有两条分开的交互状态:橙色表示业务选中的B02焊接,青色表示键盘停留的C03质检。上一节按十分钟步长移动候选时间,确认时才会检查工位占用。接上异步保存接口以后,麻烦反而出现在用户按下确认之后:连续两次调整C03,第二次保存的回执可能先于第一次到达。页面如果只写this.c03 = receipt.slot,晚到的旧回执会把新时间覆盖,时间轴看起来像自己退回了一格。
这个问题与网络快慢没有直接对应关系。请求发出的顺序、服务端实际接受的顺序、页面收到结果的顺序是三条不同的时间线。工序的时间仍用当天零点起的分钟数,机加A01保持08:00—09:20,焊接B02保持09:20—12:00,质检C03原计划12:00—14:10,包装D04保持14:10—18:00。示例的排程修订从41开始;用户连续发出两个候选:请求序号1为12:10—14:20,请求序号2为12:20—14:30。假定服务端按顺序采纳,返回修订42和43;网络返回阶段却是修订43先到、修订42后到。此时界面应停在修订43。

先为回执补齐判定身份
页面原先仅在收到结果时检查id==='C03',并没有区分某一笔编辑意图。只看工序ID也无法判断两个C03回执谁更新。因此我把一次编辑所需的信息确定为工序ID、客户端递增序号、服务端修订号、候选起止分钟及结果类别。客户端序号只在当前排程编辑会话内递增,不能代替持久化的服务端修订;服务端修订也不负责识别当前用户正在等待哪一笔操作。两项比较都需要存在。
更容易被忽略的是回执不是命令。接收器拿到回执后应该做判断和状态合并,不应再触发同样的提交动作,否则容易形成重复写入。上一节的工位占用校核仍放在提交前和服务端确认处两层;异步合并只负责已经被接受的排程视图,不会绕开H-200与H-202各自的工位约束。对于“拒绝占用”结果,应保留正式时间,单独反馈冲突原因,而不是让错误回执覆盖工序对象。
下面把核心收敛函数独立出来。acceptedSeq和serverRevision同时向前推进,较旧的一方都不具备覆盖资格。工序名称、工位和状态依旧从原来的稳定模型取得,收敛器只保存当前C03的时间数据;这避免每次回执都重新构造整张包含四个工序的数组。
export interface SlotTime {
id: string;
begin: number;
end: number;
}
export interface PlanReceipt {
clientSeq: number;
serverRevision: number;
accepted: boolean;
slot: SlotTime;
reason: string;
}
export class ReceiptReconciler {
private acceptedSeq: number = 0;
private revision: number = 41;
private slot: SlotTime = { id: 'C03', begin: 720, end: 850 };
private active: boolean = true;
apply(receipt: PlanReceipt): boolean {
if (!this.active || !receipt.accepted || receipt.slot.id !== 'C03') {
return false;
}
if (receipt.clientSeq <= this.acceptedSeq ||
receipt.serverRevision <= this.revision ||
receipt.slot.begin >= receipt.slot.end) {
return false;
}
this.acceptedSeq = receipt.clientSeq;
this.revision = receipt.serverRevision;
this.slot = { id: receipt.slot.id, begin: receipt.slot.begin,
end: receipt.slot.end };
return true;
}
current(): SlotTime { return { ...this.slot }; }
version(): number { return this.revision; }
dispose(): void { this.active = false; }
}
对照输入很直观:先传入clientSeq=2、serverRevision=43、begin=740、end=870,收敛器接受;再传入序号1、修订42、730—860,直接返回false。740与870分别对应12:20、14:30,730与860对应12:10、14:20。这里的时间数据是规则输入,不依赖任何图形宽度,所以即使窗口从820vp切回窄布局,合并后的C03也不会改变。accepted=false要走错误消息通道,拒绝回执不会进入这个函数的确认分支。
撤销必须是新修改,而不是覆盖历史
收到较新的结果后,用户点击撤销,最诱人的写法是把本地C03直接设回12:10—14:20,再把服务器修订从43减回42。这个写法有两个问题:修订号是服务端的顺序标识,客户端不能倒退;另一窗口也可能已经基于43继续编辑。如果只改屏幕,刷新之后仍会看到服务端的12:20—14:30。
撤销应转成一条新的排程修改。点击“撤销最近一步”时,在明确的历史里找到上一条已接受的候选,构造新命令:目标C03、目标730—860、预期服务端修订43。服务端通过并发修订检查后可以生成44;若修订已经变化,则要求用户重新读取最新计划,再决定是否继续撤销。撤销之前仍保持正在展示的740—870,不让按钮点击表现得像写入已经成功。这样还能把撤销操作纳入同一个回执处理流程。
export interface PlanChange {
clientSeq: number;
expectedRevision: number;
slot: SlotTime;
purpose: string;
}
export function makeUndoChange(
lastAcceptedSeq: number,
confirmedRevision: number,
previous: SlotTime
): PlanChange {
if (confirmedRevision < 1 || previous.id !== 'C03' ||
previous.begin < 480 || previous.end > 1080) {
throw new Error('撤销目标缺少有效的修订上下文');
}
return {
clientSeq: lastAcceptedSeq + 1,
expectedRevision: confirmedRevision,
slot: { ...previous },
purpose: 'undo'
};
}
// 当前已采纳序号2、服务端修订43;撤销目标是上一笔730—860
const undo: PlanChange = makeUndoChange(2, 43,
{ id: 'C03', begin: 730, end: 860 });
这段代码只负责生成符合约束的命令,真正的持久化动作交给既有排程仓储接口。仓储应把expectedRevision当作并发条件,而不是一个用于显示的字段。若返回修订冲突,页面把“可撤销”改成“需重新获取”,但不要清空用户原先的候选;重试也应产生新序号,不能沿用上一条已产生结果的请求。对于连续快速点击,按钮可以暂时禁止重复提交,同样要靠服务端命令幂等键避免跨页面重复写入。
页面只订阅确认状态,不追着每次回执重绘
布局回流的关键在于保存的是业务ID。B02仍是详情面板的业务选中项,C03仍是滚动锚点;回执更新只修改C03的确认时间和修订号。如果每个回执都触发列表排序、容器断点更新和重新定位,用户会看到列表在原本不变的四个工序之间跳动。此处采取一次合并、一次视图发布的路径:成功接受修订43后更新确认时间,随后旧修订42只留下诊断计数,不触发可观察状态变动。
ArkUI里状态变量本身的赋值能触发相应UI更新。自定义组件生命期退出时还可能有异步Promise继续完成,接收器因而需要明确的关闭标记。关闭后可以释放回执订阅或仓储观察回调;对于网络层可主动取消的任务还应在仓储里执行取消操作。仅把页面文字清空并不能阻止过期回调重新写入。示例页面展示最小连接方式,列表组件可以继续沿用前六篇的工序卡,不必把完整排程布局重复写一遍。

@Entry
@Component
struct ReceiptPage {
private merger: ReceiptReconciler = new ReceiptReconciler();
@State revision: number = 41;
@State c03Time: string = '12:00—14:10';
@State status: string = '等待排程确认';
@State selectedId: string = 'B02';
@State anchorId: string = 'C03';
private onReceipt(receipt: PlanReceipt): void {
if (!this.merger.apply(receipt)) { return; }
const slot = this.merger.current();
this.revision = this.merger.version();
this.c03Time = this.formatTime(slot.begin) + '—' +
this.formatTime(slot.end);
this.status = '已采用新回执';
}
private formatTime(value: number): string {
const h = Math.floor(value / 60).toString().padStart(2, '0');
const m = (value % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
aboutToDisappear(): void { this.merger.dispose(); }
build() {
Column({ space: 12 }) {
Text('生产工序排程看板').fontSize(22).fontWeight(FontWeight.Bold)
Text(`选中 ${this.selectedId} · 锚点 ${this.anchorId}`)
Text(`C03 ${this.c03Time} · 修订 ${this.revision}`)
Text(this.status).fontColor('#087D70')
Button('撤销最近一步').onClick(() => {
this.status = '等待撤销回执';
})
}.padding(18).width('100%')
}
}
onReceipt应由仓储订阅回调进入,而撤销按钮点击事件需要与上一段的makeUndoChange和仓储提交函数相连。这样分层后,读者能看清页面负责显示、模型负责收敛、仓储负责持久化三个职责。按钮设置等待状态不是已完成撤销;收到修订44后才切换确认数据。异常要分清通信中断、服务端版本冲突和工位占用拒绝,三者的恢复路径不同:通信问题可以检查状态后重试,版本冲突需拉取最新快照,工位冲突则要求重新选择时段。
用明确的输入序列检查回归
这条链路至少需要六类业务检查。其一,43先于42到达,C03维持12:20—14:30;其二,42先于43到达,界面最终仍收敛到43;其三,重复收到43,状态变量不重复更新;其四,拒绝回执不改确认值;其五,页面退出后再到的回执被关闭标记拦住;其六,撤销与其他窗口修改发生修订冲突时,不覆盖别人的提交。还应加入服务端修订增加但客户端序号更旧的异常组合,并记录为何被拒绝,避免将输入协议错误误认为正常乱序。
再多考虑一次页面与仓储的交界。如果两扇窗口同时展示C03,某个窗口离开页面时不应把另一扇窗口共享的仓储也销毁。订阅句柄应当属于各自的页面实例,只有监听解绑跟随组件退出;全局排程快照则按业务作用域保存。如果窗口甲刚刚接收修订43,而窗口乙仍停在修订41,窗口乙再次提交时必须携带自己看到的预期修订,等待仓储明确返回冲突,再提示刷新。这样才能避免看似正常的本地覆盖,实际上掩盖跨窗口修订差异。
另外,回执处理过程中应保存被忽略的序号和服务端修订以供问题定位,但日志只记录工序ID与时间,不要把用户输入的备注全文带入输出。诊断计数用于观察旧消息被丢弃的频度,不直接作为页面状态;否则为了记录一次“已忽略”,又产生一次不必要的视图更新。
在性能上,节省的不是回执对象那几行内存,而是重复构建时间轴和反复触发滚动定位的工作量。高频响应到来时,将数据收敛在普通业务类中,最终只对确实改变的@State字段赋值;必要时为排程仓储做事件合并,而不是在每个异步完成分支里直接刷页面。窗口宽度调整和滚动锚点属于布局状态,不能由回执重置。

华为开发者文档可以重点对照ArkUI状态管理与更新机制、https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch装饰器的回调时机及ContainerReader容器断点布局。这里坚持由业务修订决定最终排程、由ArkUI状态决定界面刷新,正是避免异步回执把多设备排程布局拖回旧状态的关键。
更多推荐



所有评论(0)