HarmonyOS 7 排队事件归约与闪控双窗联动:连载05(共08篇)【共创稿事节】
HarmonyOS 7 排队事件归约与闪控双窗联动:连载05(共08篇)【共创稿事节】
城市艺术夜展进入高峰时段后,一张入场凭证可能在短时间内连续收到多条进度消息:等待序号从31调整到29,再调整到28;某条较早的消息因为重试而延迟抵达。完整排队页、闪控球和闪控窗都需要显示最新结果,但三处界面的刷新节奏并不相同。把网络回调直接绑定到三个窗口,会让重复消息、视图创建时序和窗口关闭行为互相干扰。
前面的连载已经建立 QueueSnapshot、QueueEvent、EntryPhase、reduceQueue(),并实现了闪控球短文案与闪控窗内容投影。这次把更新入口收束到一个业务协调器:消息先按凭证版本归约,再通知主页面与轻量窗口。三个视图共享可信快照,窗口是否可见不会改变入场资格。

一、为什么单次收到消息不等于页面就应刷新
入场排队通常存在两个独立时钟:服务端给每张凭证分配的事件序列号,以及本地收到消息的时间。网络传输过程中,重试、断线重连和不同数据源的合并都会改变抵达顺序。真正决定消息新旧的是 ticketId 与 seq 的组合,而非本地接收时间,也不能简单比较排队位置的大小。
例如同一张凭证先后收到以下三条记录:seq=38, position=29、seq=40, position=28、seq=39, position=30。最后一条虽最晚送达,却是旧版本;如果直接赋值,用户可能看到序号倒退。到达 CALLED 之后,旧版 WAITING 消息更加需要被拒绝,否则界面会从“请前往入口”退回等待状态。
| 输入情况 | 协调器处理 | 对视图的影响 |
|---|---|---|
| 新序列号且迁移合法 | 接纳新的快照 | 触发一次统一通知 |
| 相同序列号重复到达 | 保留已有快照 | 无额外窗口刷新 |
| 旧版本消息补到 | 丢弃旧消息 | 三处视图保持一致 |
| 凭证 ID 与当前记录不匹配 | 拒绝关联 | 防止串票显示 |
| 已入场后收到等待消息 | 保持终止阶段 | 避免状态回退 |
这里仍复用第一篇的 reduceQueue() 规则,不在事件总线里再写一份状态迁移表。事件总线负责传递已接纳的数据;合法性判定只保留一个位置。这样,之后无论采用请求轮询、推送连接还是恢复时主动拉取快照,入口都能保持一致。
二、把订阅管理放到业务层
工程在原有 model/、logic/、pages/ 的基础上增加 logic/QueueStore.ets。该文件为每张凭证保存一份最新快照,使用稳定的订阅标识注册监听器。订阅时先返回当前值,解决“页面刚打开,下一条事件还没有到达”的空白问题。取消订阅返回一个函数,组件离开或窗口结束时可直接释放。
// logic/QueueStore.ets
import { QueueEvent, QueueSnapshot, initialSnapshot } from '../model/QueueModel';
import { reduceQueue } from './QueueReducer';
type QueueListener = (snapshot: QueueSnapshot) => void;
export class QueueStore {
private values: Map<string, QueueSnapshot> = new Map();
private listeners: Map<string, Map<number, QueueListener>> = new Map();
private nextId: number = 1;
constructor() {
const initial: QueueSnapshot = initialSnapshot();
this.values.set(initial.ticketId, initial);
}
snapshot(ticketId: string): QueueSnapshot | undefined {
return this.values.get(ticketId);
}
listen(ticketId: string, listener: QueueListener): () => void {
let group: Map<number, QueueListener> | undefined = this.listeners.get(ticketId);
if (!group) {
group = new Map<number, QueueListener>();
this.listeners.set(ticketId, group);
}
const id: number = this.nextId++;
group.set(id, listener);
const current: QueueSnapshot | undefined = this.values.get(ticketId);
if (current) listener(current);
return (): void => {
const active: Map<number, QueueListener> | undefined = this.listeners.get(ticketId);
active?.delete(id);
if (active && active.size === 0) this.listeners.delete(ticketId);
};
}
ingest(event: QueueEvent): boolean {
const oldValue: QueueSnapshot | undefined = this.values.get(event.ticketId);
if (!oldValue) return false;
const nextValue: QueueSnapshot = reduceQueue(oldValue, event);
if (nextValue === oldValue) return false;
this.values.set(event.ticketId, nextValue);
this.listeners.get(event.ticketId)?.forEach((listener: QueueListener) => {
listener(nextValue);
});
return true;
}
}
export const queueStore: QueueStore = new QueueStore();
这个仓储保留了一个重要性质:同一凭证的订阅者只收到经过归约的新对象。主页面与闪控窗读取的 seq 一致,用户不必再处理“窗口更新到了40,但主页面仍在38”的逻辑分歧。对于多凭证场景,应在凭证列表同步时显式加入已授权的初始快照;这里为了和此前的单凭证工程保持连续性,只初始化 entry-7264。
listen() 返回的释放函数与页面生命周期配对。业务仓储的存活范围通常长于单个页面,不能在页面退出时顺便清空整个仓储。订阅者只是观察者,取消观察不会取消排队,更不会导致票证失效。对于业务数据来自多个进程的情况,还需要在持久化与进程通信层建立一致的消息入口,而非依赖一个内存对象跨进程共享。
三、主页面只订阅一次,所有更新经由归约器
原本的完整页面可以保留活动卡片和凭证操作,这里只列出与更新联动有关的部分。打开页面时取当前快照,随后观察变化;离开页面时释放监听器。按钮产生的是新消息输入,不是直接修改某个显示字段。
// pages/QueueDetailPage.ets
import { QueueEvent, QueueSnapshot, EntryPhase,
initialSnapshot, phaseLabel } from '../model/QueueModel';
import { queueStore } from '../logic/QueueStore';
@Entry
@Component
struct QueueDetailPage {
@State snapshot: QueueSnapshot = initialSnapshot();
private dispose?: () => void;
aboutToAppear(): void {
this.dispose = queueStore.listen('entry-7264', (value: QueueSnapshot): void => {
this.snapshot = value;
});
}
aboutToDisappear(): void {
this.dispose?.();
this.dispose = undefined;
}
private readUpdatedRecord(): void {
const event: QueueEvent = {
ticketId: 'entry-7264', phase: EntryPhase.WAITING,
position: 28, waitMinutes: 12, seq: 40,
updatedAt: Date.now()
};
queueStore.ingest(event);
}
build() {
Column({ space: 16 }) {
Text('城市艺术夜展').fontSize(25).fontWeight(FontWeight.Bold)
Text(phaseLabel(this.snapshot.phase)).fontSize(21)
Text(`当前序号 ${this.snapshot.position}`).fontSize(34)
Text(`预计等待 ${this.snapshot.waitMinutes} 分钟`).fontSize(18)
Text(`消息版本 ${this.snapshot.seq}`).fontSize(13)
Button('读取更新记录').onClick((): void => this.readUpdatedRecord())
}
.width('100%')
.padding(24)
}
}
示例输入采用固定序列号,方便观察重复消息被拒绝的规则。接入真实队列服务后,应将接收到的消息映射成 QueueEvent 并送入 queueStore.ingest()。页面无需关心消息究竟来自轮询还是推送。网络错误也应该留在服务层处理,而不是让某次请求失败把已有的可信快照清空。

四、闪控球的更新需要串行化
业务层接纳新事件的频率可能高于系统窗口实际完成更新的速度。连续为版本38、39、40分别发起并行窗口调用,会产生完成顺序不确定的问题。更合适的办法是保留等待投递的最新快照,保证同一时间只有一个更新请求在途。该策略只合并显示刷新,不修改业务仓储里的任何数据。
闪控球的实际更新接口是 FloatingBallController.updateFloatingBall(),新参数沿用启动时的 NORMAL 模板。STATIC 模板的更新能力不同,更新过程中也不能切换模板。下面的桥接器接收已经创建、已经启动并满足权限要求的控制器;它只负责展示刷新,窗口创建和权限判断仍由上一阶段的网关完成。
// logic/QueueBallRelay.ets
import { floatingBall } from '@kit.ArkUI';
import { QueueSnapshot } from '../model/QueueModel';
import { toQueueBallText } from './QueueBallProjection';
export class QueueBallRelay {
private pending?: QueueSnapshot;
private busy: boolean = false;
private delivered: number = 0;
private controller: floatingBall.FloatingBallController;
constructor(controller: floatingBall.FloatingBallController) {
this.controller = controller;
}
offer(snapshot: QueueSnapshot): void {
if (snapshot.seq <= this.delivered) return;
if (!this.pending || snapshot.seq > this.pending.seq) {
this.pending = snapshot;
}
if (!this.busy) this.drain();
}
private async drain(): Promise<void> {
this.busy = true;
try {
while (this.pending) {
const current: QueueSnapshot = this.pending;
this.pending = undefined;
if (current.seq <= this.delivered) continue;
const view = toQueueBallText(current);
await this.controller.updateFloatingBall({
template: floatingBall.FloatingBallTemplate.NORMAL,
title: view.title,
content: view.content
});
this.delivered = current.seq;
}
} catch (error) {
console.error(`闪控球状态更新失败:${String(error)}`);
this.pending = undefined;
} finally {
this.busy = false;
}
}
}
失败后的处理不是在循环里无上限重试。系统窗口可能已经进入收起、停止或异常状态;下一次恢复应先核对窗口回调及当前 QueueSnapshot,再提交完整的最新投影。闪控窗内容页可以继续复用连载04的 QueueFloatPanel 和 LocalStorage,但应该从同一个 QueueStore.listen() 接收快照,再调用 toFloatPanel() 更新已有存储,避免窗口层额外建立数据源。
五、处理订阅顺序和显示版本
建议为主页面、闪控球投影和闪控窗内容分别维护独立的订阅释放函数。某个窗口停止后移除对应订阅,其余观察者仍可接收变化。如果窗口重新创建,先从 snapshot(ticketId) 取得最新版本,后注册变化监听;而 listen() 注册时会立即发送当前快照,因此即使启动间隔里来了新消息,也能取得仓储保存的最近值。
还要区分“展示投递成功”和“业务记录已经接纳”。ingest() 返回 true 表示通过归约并更新仓储,不等同于系统窗口已经完成刷新;窗口接口返回成功也无法替代业务凭证的服务端确认。可以将日志分成 queue.accept、window.offer 和 window.present 三类,分别排查消息是否接纳、是否提交更新、是否进入窗口可展示状态。
窗口内容存在隐私要求:闪控球标题只使用阶段、队列序号和估算等待时间;完整凭证与核验码留在主应用里。窗口状态变化不应导致票务信息被重复发送。对于最终阶段,可在业务投影中输出“已入场”“凭证已失效”等明确文字,是否结束窗口则交给下一篇的生命周期控制器决定。

六、调试时优先确认事件被哪一层舍弃
排查“窗口没有更新”时,应从源头按顺序查看:消息携带的 ticketId 是否属于当前凭证;seq 是否大于仓储值;reduceQueue() 是否允许阶段迁移;主页面订阅是否仍在;窗口是否完成创建、启动以及状态监听。窗口临时隐藏时,业务仓储仍可以更新,只是展示适配器需要等待合适的窗口状态。
还应覆盖三个容易遗漏的输入:同序列号事件重复投递、版本40之后收到版本39、已经进入 CALLED 阶段后收到旧的 WAITING。正确预期是仓储只接纳合法新状态,所有窗口读取同一份结果,不因用户切换应用而回退序号。
通过这一层集中归约,应用把“可信的队列状态”和“可变化的窗口外观”彻底拆开。后续增加消息渠道或优化窗口样式,不再要求重写票务状态机;窗口销毁、重新进入前台以及订阅释放,也就有了清晰的管理边界。
更多推荐


所有评论(0)