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。正确预期是仓储只接纳合法新状态,所有窗口读取同一份结果,不因用户切换应用而回退序号。

通过这一层集中归约,应用把“可信的队列状态”和“可变化的窗口外观”彻底拆开。后续增加消息渠道或优化窗口样式,不再要求重写票务状态机;窗口销毁、重新进入前台以及订阅释放,也就有了清晰的管理边界。

相关接口可查阅闪控球开发指导和闪控窗开发指导。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐