HarmonyOS 7 活动入场排队状态机与轻任务模型:连载01(共08篇)【共创稿事节】

大型展览、城市音乐节和预约制体验活动往往会把“已经预约”和“能够入场”分成两个环节。用户可能提前数小时领取了凭证,却只希望在临近入场时了解当前序号、等待时间以及是否已经被叫号。如果把这些字段直接散落到多个页面中,窗口尺寸改变、页面重新打开或进度消息先后到达时,很容易出现一处显示“排队中”,另一处却显示“已入场”的情况。

这类应用适合先建立独立的排队状态模型,再由主页面和轻量窗口分别读取同一份状态快照。HarmonyOS 7 提供了闪控窗等多形态窗口体验,但入场资格、排队序号和状态迁移首先属于业务数据,而不是某个窗口的私有属性。把状态机放在界面之外,才能为后续的闪控球和闪控窗衔接打下基础。

排队状态迁移与入场进度设计

一、先确定排队状态的业务含义

以城市艺术夜展的电子入场凭证为例,一张凭证在生命周期中会经过预约成功、进入排队、叫号、入场等阶段,还可能因为活动结束或者用户取消而终止。状态名称要描述业务事实,不能用“弹窗打开”“按钮被点击”等界面事件代替。按钮只能提交动作请求,最终状态由被确认的业务事件更新。

状态业务含义页面展示重点
RESERVED已领取入场资格,尚未进入排队活动时间、预约信息
WAITING处于等待队列排队序号、预计等候时间
CALLED已收到入场提醒入口位置、有效提醒
ADMITTED入场记录已确认入场完成状态
CANCELLED预约或队列已经取消取消结果与重新预约入口
EXPIRED超出规定的使用时段凭证失效原因

这里有两个值得明确的设计点。其一,WAITING 可以多次收到序号或等候时间变化,但阶段本身无需反复切换。其二,ADMITTED、CANCELLED 和 EXPIRED 是本次凭证的终止阶段,后续普通排队数据不能重新把它们变成等待中。确有业务恢复需求时,应由服务端产生新的凭证或者明确的恢复流程,而不是前端自行修改枚举值。

排队序号也应与“前方还有多少人”分开。序号 28 不必然意味着前面还有 27 人,队伍可能包含分时段分组、不同入口或临时优先通道。预计等候时间同样不是用本地倒计时当成权威值,而是由经过计算的数据快照提供。页面可以提示更新时间,避免用户把过期数据当成最新结果。

二、模型只保留可信业务字段

工程可按 model/QueueModel.ets、logic/QueueReducer.ets、pages/Index.ets 三层拆分。模型定义状态与消息字段;归约器负责决定是否接纳一条更新;页面只负责呈现结果。这样后续切换到紧凑窗口时,不必复制状态判定代码。

model/QueueModel.ets:

export enum EntryPhase {
  RESERVED = 'RESERVED',
  WAITING = 'WAITING',
  CALLED = 'CALLED',
  ADMITTED = 'ADMITTED',
  CANCELLED = 'CANCELLED',
  EXPIRED = 'EXPIRED'
}

export interface QueueSnapshot {
  ticketId: string;
  eventId: string;
  phase: EntryPhase;
  position: number;
  waitMinutes: number;
  seq: number;
  updatedAt: number;
}

export interface QueueEvent {
  ticketId: string;
  phase: EntryPhase;
  position: number;
  waitMinutes: number;
  seq: number;
  updatedAt: number;
}

export function initialSnapshot(): QueueSnapshot {
  return {
    ticketId: 'entry-7264', eventId: 'art-night',
    phase: EntryPhase.RESERVED, position: 0,
    waitMinutes: 0, seq: 1, updatedAt: 1793475000000
  };
}

export function phaseLabel(phase: EntryPhase): string {
  switch (phase) {
    case EntryPhase.RESERVED: return '已预约';
    case EntryPhase.WAITING: return '排队中';
    case EntryPhase.CALLED: return '请前往入口';
    case EntryPhase.ADMITTED: return '已入场';
    case EntryPhase.CANCELLED: return '已取消';
    default: return '凭证已失效';
  }
}

ticketId 是排队业务的稳定关联键,eventId 用于关联活动详情;seq 是凭证维度的单调递增版本。updatedAt 作为用户可见的更新时间,不能替代版本做冲突判定:不同节点的时钟可能有偏差,而同一毫秒内也可能发生多个事件。这里用数值保存时间,避免因为显示格式差异干扰状态比较。

代码把排队位置和时间设置为非负数约束,终止状态可以统一填入 0。实际数据协议还可以增加入口分区、叫号批次和失效原因字段,但应优先确认这些字段究竟来自哪一方,避免在界面中臆造业务事实。

三、用纯函数约束合法迁移

只比较最新收到的状态并直接覆盖页面,是很危险的写法。网络重试可能让旧事件重新到达,两个订阅源也可能向同一页面提供不同版本的数据。一个更稳妥的方案是让消息先通过纯函数校验,再决定是否产生新快照。

logic/QueueReducer.ets:

import { EntryPhase, QueueEvent, QueueSnapshot } from '../model/QueueModel';

function terminal(phase: EntryPhase): boolean {
  return phase === EntryPhase.ADMITTED ||
    phase === EntryPhase.CANCELLED || phase === EntryPhase.EXPIRED;
}

function allowed(from: EntryPhase, to: EntryPhase): boolean {
  if (from === to) return true;
  if (terminal(from)) return false;
  if (to === EntryPhase.CANCELLED || to === EntryPhase.EXPIRED) return true;
  if (from === EntryPhase.RESERVED) return to === EntryPhase.WAITING;
  if (from === EntryPhase.WAITING) return to === EntryPhase.CALLED;
  if (from === EntryPhase.CALLED) return to === EntryPhase.ADMITTED;
  return false;
}

export function reduceQueue(oldValue: QueueSnapshot,
  event: QueueEvent): QueueSnapshot {
  if (event.ticketId !== oldValue.ticketId) return oldValue;
  if (event.seq <= oldValue.seq || terminal(oldValue.phase)) return oldValue;
  if (!allowed(oldValue.phase, event.phase)) return oldValue;
  if (!Number.isFinite(event.position) || event.position < 0) return oldValue;
  if (!Number.isFinite(event.waitMinutes) || event.waitMinutes < 0) return oldValue;
  if (!Number.isFinite(event.updatedAt)) return oldValue;

  return {
    ticketId: oldValue.ticketId, eventId: oldValue.eventId,
    phase: event.phase, position: event.position,
    waitMinutes: event.waitMinutes,
    seq: event.seq, updatedAt: event.updatedAt
  };
}

该实现对同一状态内的进度变化开放更新,例如从 WAITING 的序号 28 更新到序号 24;对不允许的跨越迁移则保持原快照。如果入场服务确实会合并阶段,一次从等待中直接回传已入场,必须先调整协议和迁移表,而不能为了让页面“看起来正确”放宽所有条件。

纯函数有一个重要好处:相同输入会得到相同输出。处理重复事件、倒序事件、跨凭证事件时,无需先启动任何页面就能构造输入数据,检查输出的 seq、phase、position 是否符合预期。新增窗口形态也不会改变这条规则。

四、ArkUI 页面如何消费快照

页面的 @State 保存当前不可变快照,接收到新版本后整体赋值。初学者常见的写法是直接修改对象的深层字段,再依赖界面自动刷新;这种处理容易引入不同状态管理机制下的观察差异。用整体替换的方式,页面刷新意图更清晰。

pages/Index.ets:

import { EntryPhase, QueueEvent, QueueSnapshot,
  initialSnapshot, phaseLabel } from '../model/QueueModel';
import { reduceQueue } from '../logic/QueueReducer';

@Entry
@Component
struct Index {
  @State snapshot: QueueSnapshot = initialSnapshot();
  private next: number = 0;
  private records: QueueEvent[] = [
    { ticketId: 'entry-7264', phase: EntryPhase.WAITING,
      position: 28, waitMinutes: 12, seq: 2, updatedAt: 1793475060000 },
    { ticketId: 'entry-7264', phase: EntryPhase.WAITING,
      position: 24, waitMinutes: 9, seq: 3, updatedAt: 1793475120000 },
    { ticketId: 'entry-7264', phase: EntryPhase.CALLED,
      position: 0, waitMinutes: 0, seq: 4, updatedAt: 1793475180000 }
  ];

  private readNext(): void {
    if (this.next >= this.records.length) return;
    this.snapshot = reduceQueue(this.snapshot, this.records[this.next]);
    this.next += 1;
  }

  build() {
    Column({ space: 18 }) {
      Text('城市艺术夜展').fontSize(26).fontWeight(FontWeight.Bold)
      Text(phaseLabel(this.snapshot.phase)).fontSize(24)
        .fontColor('#1265C8')
      if (this.snapshot.phase === EntryPhase.WAITING) {
        Text(`当前序号:${this.snapshot.position}`).fontSize(34)
        Text(`预计等候:${this.snapshot.waitMinutes} 分钟`).fontSize(20)
      }
      Text(`进度版本:${this.snapshot.seq}`).fontSize(14)
      Button('读取下一条状态记录').onClick(() => this.readNext())
    }
    .width('100%').padding(24)
  }
}

这里的记录数组是本地示例数据,用来观察合法迁移、连续进度更新与叫号后的显示变化。接入实际消息源时,应把 readNext() 的输入替换为经过解析、校验和鉴权的队列事件,仍复用 reduceQueue()。不要让 UI 按钮直接把阶段改成已入场,否则服务端确认与屏幕文字会失去因果关系。

ArkUI状态消费与开发页面结构

五、为多种窗口形态保留同一份状态

闪控球的内容通常适合提炼成一个状态和一个数字,例如“排队中 · 28”;闪控窗可展示活动名称、预计等待、入口说明;主页面则保留票据详情与取消预约等复杂操作。三者的内容密度不同,却应该从同一份 QueueSnapshot 导出。推荐将显示文案、强调色、操作可用性封装成选择函数,而不是在不同窗口各维护一套独立状态机。

当主页面重新打开时,先展示最近一次持有的快照,再请求权威最新版本;如果返回事件版本较旧,归约器会保持现有状态。对于入场后仍有旧消息抵达的情形,终止状态保护同样能够减少错误回跳。窗口收起与展开只是呈现方式变化,不应触发排队身份重新生成。

真实业务中还要处理取消请求。用户点击取消后,页面可以暂时禁用重复提交,但不能立刻把 phase 写成 CANCELLED;只有请求收到确认,或者服务端事件到达,才更新凭证状态。若取消和叫号同时发生,应使用服务端最终决议及其对应版本处理,而不是比较两个按钮的点击时刻。

六、排查进度异常的顺序

现象优先排查项建议处理
序号回退为旧值seq 是否倒序或重复保留较新快照并记录事件版本
详情和紧凑入口显示不同是否各自维护状态副本统一快照源和派生文案
已入场后再次出现排队中终止状态是否仍被更新阻止终止阶段普通迁移
更新时间不断变化但数字不变是否重复接收同一事件以凭证与版本去重
切换活动后出现他人进度ticketId 是否匹配严格校验事件归属

调试时应先记录 ticketId 的受控缩略值、事件版本、旧状态、新状态及拒绝原因。不要把完整凭证密钥或可直接核验的二维码内容写入日志。若仍无法复现问题,可在测试数据中人为调整事件顺序,检查归约器对 seq 变小和跨阶段跳转的响应。

活动排队进度的手机页面效果

七、把状态设计留给后续交互复用

完成这层设计后,活动入场系统已经拥有明确的数据中心:一个凭证对应一条可更新快照,状态变更经过版本和迁移规则判断,窗口只选择需要展示的字段。下一步可以把活动信息、凭证摘要和排队进度组合为列表卡片,再依据不同窗口形态输出不同密度的信息,避免多处手写业务判断。

参考资料:HarmonyOS 7 新能力一览、ArkUI 状态变更监听、ArkTS 语言简介。

Logo

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

更多推荐