确认按钮最容易把一个练习项目变成有风险的业务系统。用户在“银幕影厅”选中了 A1、A2、B1,页面显示三张票合计144元,按下“确认”后,真的可以把这三席记作已购吗?不能。前三篇构建的 CinemaSeats 从来没有接入服务端库存:第一篇实现4排×6列座位、禁售与三席上限;第二篇用绝对截止时间实现五分钟本地暂选;第三篇将 hold_draft_v1 记录在 Preferences,要求启动时完整校验和过期清理。最后这一篇要做的不是伪造支付结果,而是把最终校验、确认预览、草稿清理与错误退路连成可审查的页面流程。

对于练习项目,“确认”意味着把当下可验证的暂选生成一张本机订单预览卡,不意味着支付成功、锁座成功或服务器库存扣减。正式票务系统还需要原子库存检查、服务端时间、幂等订单号和支付结果回调。如果本地示例一开始就把这些省略掉,却把按钮命名成“购票成功”,用户界面事实上已经作出不存在的承诺。

CinemaSeats 影厅选座与最终校验技术封面

一、四篇连载怎样组成一条完整的本地链路

在同一个 Stage 模型 ArkTS 工程里,我们始终保留 Seat { id, row, col, status }。座位以 A1~D6 的稳定身份生成,总量24;初始已售席为 A3、B5、C1、D6,已售不可选;selected: string[] 最多存放三个唯一席位;单价固定48元。上篇的本地草稿 v1|A1,A2,B1|<截止毫秒时间> 写在 Preferences 的 cinema_seat_draft 文件、hold_draft_v1 键中。保存的是席位编号和截止时间,绝不存剩余秒数或座位背景颜色。

现在需要明确四个页面阶段。selecting 允许增加、取消座位;holding 表示本地暂选有效,但还没有生成确认预览;reviewing 表示已通过一次本地校验,页面显示不可编辑的明细;expired 表示截止时间已过,必须清空、释放并重新选择。另设 busy 作为异步读取或清理的操作锁,不要用它伪装成业务阶段。这里的 reviewing 仍不是正式订单,它只是一份供用户核对的本地快照。

最重要的规则是:显示倒计时并不能替代确认前校验。用户可能停留在一个过期的画面,也可能在异步存储读取过程中修改选择。即使页面显示“04:37”,确认操作也必须重新读取当下时间、暂选席位和当前已售集合。对于同一个本地设备,最小的检查顺序是:是否正处于 holding、是否有一到三席、编号是否合法且没有重复、是否与当前已售名单冲突、截止毫秒值是否严格大于现在、存储记录是否仍与内存一致。

这个顺序不是为了“让按钮少报错”,而是将展示层和验证层分开。票数和合计金额只能从经验证的 seatIds 派生,不应信任输入界面传过来的 amount=144。如果未来票价可以变化,服务端还需在提交时重新核价,本地预览中的人民币金额只能当作示例约定。

二、使用一份不可混淆的确认快照

HoldDraft 保留原来的 seatIds 和 expiresAtMs;新增 ReviewSnapshot,只包含在确认瞬间冻结的 seatIds、ticketCount、totalYuan、checkedAtMs。即使之后用户改变筛选条件,也不允许静默修改当前预览卡。这里不创建虚假的订单ID,也不向系统声明购票权限,更不持久化所谓已付款状态。其目标是让“校验通过”和“订单已支付”在命名和数据结构上根本不同。

先写一段纯函数,它可以在没有 UI 的情况下被单独验证。它接收序列化后的草稿、当前毫秒时间和固定已售席集合,返回有效快照或失败原因。由于第三篇已给出 parseDraft,这里保留同一记录协议,额外检查本地确认逻辑的输入不变式。

interface HoldDraft {
  seatIds: string[];
  expiresAtMs: number;
}
interface ReviewSnapshot {
  seatIds: string[];
  ticketCount: number;
  totalYuan: number;
  checkedAtMs: number;
}
const SOLD_IDS: string[] = ['A3', 'B5', 'C1', 'D6'];
function parseHold(raw: string, now: number): HoldDraft | undefined {
  const fields: string[] = raw.split('|');
  if (fields.length !== 3 || fields[0] !== 'v1') { return undefined; }
  const ids: string[] = fields[1].split(',');
  if (ids.length < 1 || ids.length > 3 ||
      new Set<string>(ids).size !== ids.length) { return undefined; }
  for (const id of ids) {
    if (!/^[A-D][1-6]$/.test(id) || SOLD_IDS.includes(id)) {
      return undefined;
    }
  }
  if (!/^\d+$/.test(fields[2])) { return undefined; }
  const expiresAtMs: number = Number(fields[2]);
  if (!Number.isSafeInteger(expiresAtMs) ||
      expiresAtMs <= now || expiresAtMs > now + 300000) {
    return undefined;
  }
  return { seatIds: [...ids], expiresAtMs: expiresAtMs };
}
function makeReview(raw: string, now: number): ReviewSnapshot | undefined {
  const draft: HoldDraft | undefined = parseHold(raw, now);
  if (draft === undefined) { return undefined; }
  return { seatIds: [...draft.seatIds],
    ticketCount: draft.seatIds.length,
    totalYuan: draft.seatIds.length * 48, checkedAtMs: now };
}

例如 v1|A1,A2,B1|未来两分钟 可以得到三席和144元。v1|A1,A1|... 因重复拒绝,v1|A3|... 因已售拒绝,v1|D7|... 因不存在拒绝,恰好到期的记录用 expiresAtMs <= now 拒绝。写成纯函数的价值在于:无论按钮、恢复逻辑还是自动化测试调用它,都会经过同样的门槛,而不是在不同页面复制三套略有区别的校验。

CinemaSeats 本地暂选核对界面,显示座位和应付示意金额

三、在 Stage 页面接上确认前重新读取与状态守卫

新建工程时可将代码组织为 entry/src/main/ets/model/SeatModel.ets、entry/src/main/ets/utils/HoldValidator.ets 和 entry/src/main/ets/pages/Index.ets。为了方便独立复制,下方代码合并了前三篇的座位生成、选择和 beginHold(),以及新增的确认预览逻辑,可以作为一个 Index.ets 的完整示例。上方已经展示的纯函数在下方代码里再次出现,是为了保证粘贴单个完整代码块即可检查页面;它们在项目中应当只保留一份声明。

import { preferences } from '@kit.ArkData';

interface Seat {
  id: string;
  row: string;
  col: number;
  status: string;
}
interface HoldDraft {
  seatIds: string[];
  expiresAtMs: number;
}
interface ReviewSnapshot {
  seatIds: string[];
  ticketCount: number;
  totalYuan: number;
  checkedAtMs: number;
}
const SOLD_IDS: string[] = ['A3', 'B5', 'C1', 'D6'];
const DRAFT_KEY: string = 'hold_draft_v1';
const HOLD_MS: number = 300000;

function makeSeats(): Seat[] {
  const result: Seat[] = [];
  for (const row of ['A', 'B', 'C', 'D']) {
    for (let col: number = 1; col <= 6; col++) {
      const id: string = `${row}${col}`;
      result.push({ id: id, row: row, col: col,
        status: SOLD_IDS.includes(id) ? 'sold' : 'free' });
    }
  }
  return result;
}
function parseHold(raw: string, now: number): HoldDraft | undefined {
  const fields: string[] = raw.split('|');
  if (fields.length !== 3 || fields[0] !== 'v1') { return undefined; }
  const ids: string[] = fields[1].split(',');
  if (ids.length < 1 || ids.length > 3 ||
      new Set<string>(ids).size !== ids.length) { return undefined; }
  for (const id of ids) {
    if (!/^[A-D][1-6]$/.test(id) || SOLD_IDS.includes(id)) {
      return undefined;
    }
  }
  if (!/^\d+$/.test(fields[2])) { return undefined; }
  const expiresAtMs: number = Number(fields[2]);
  if (!Number.isSafeInteger(expiresAtMs) ||
      expiresAtMs <= now || expiresAtMs > now + HOLD_MS) {
    return undefined;
  }
  return { seatIds: [...ids], expiresAtMs: expiresAtMs };
}
function makeReview(raw: string, now: number): ReviewSnapshot | undefined {
  const draft: HoldDraft | undefined = parseHold(raw, now);
  if (draft === undefined) { return undefined; }
  return {
    seatIds: [...draft.seatIds], ticketCount: draft.seatIds.length,
    totalYuan: draft.seatIds.length * 48, checkedAtMs: now
  };
}

@Entry
@Component
struct Index {
  @State seats: Seat[] = makeSeats();
  @State selected: string[] = [];
  @State phase: string = 'selecting';
  @State remainingSeconds: number = 0;
  @State notice: string = '请选择座位';
  @State busy: boolean = false;
  @State review: ReviewSnapshot | undefined = undefined;
  private expiresAtMs: number = 0;
  private timerId: number = -1;
  private store?: preferences.Preferences;

  private async getStore(): Promise<preferences.Preferences> {
    if (this.store !== undefined) { return this.store; }
    this.store = await preferences.getPreferences(
      getContext(this), { name: 'cinema_seat_draft' });
    return this.store;
  }
  private stopTicker(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }
  private refreshClock(): void {
    if (this.phase !== 'holding') { return; }
    this.remainingSeconds = Math.max(0,
      Math.ceil((this.expiresAtMs - Date.now()) / 1000));
    if (this.remainingSeconds === 0) {
      this.stopTicker();
      this.phase = 'expired';
      this.selected = [];
      this.notice = '暂选已到期,请重新选择';
      this.clearDraft();
    }
  }
  private startTicker(): void {
    this.stopTicker();
    this.refreshClock();
    if (this.phase === 'holding') {
      this.timerId = setInterval(() => this.refreshClock(), 1000);
    }
  }
  private async clearDraft(): Promise<boolean> {
    try {
      const pref = await this.getStore();
      await pref.delete(DRAFT_KEY);
      await pref.flush();
      return true;
    } catch (_err) {
      this.notice = '清理存储失败,下次进入仍需重新核验';
      return false;
    }
  }
  private toggleSeat(id: string): void {
    if (this.busy || this.phase !== 'selecting' || SOLD_IDS.includes(id)) {
      return;
    }
    if (this.selected.includes(id)) {
      this.selected = this.selected.filter((item: string) => item !== id);
    } else if (this.selected.length < 3) {
      this.selected = [...this.selected, id];
    } else {
      this.notice = '一次最多选择3席';
    }
  }
  private async beginHold(): Promise<void> {
    if (this.busy || this.phase !== 'selecting' || this.selected.length === 0) {
      return;
    }
    this.busy = true;
    const now: number = Date.now();
    const expiresAtMs: number = now + HOLD_MS;
    try {
      const pref = await this.getStore();
      await pref.put(DRAFT_KEY, `v1|${this.selected.join(',')}|${expiresAtMs}`);
      await pref.flush();
      this.expiresAtMs = expiresAtMs;
      this.phase = 'holding';
      this.notice = '本地暂选已保存,请在有效期内核对';
      this.startTicker();
    } catch (_err) {
      this.notice = '保存失败,尚未进入暂选';
    } finally { this.busy = false; }
  }
  private async restoreDraft(): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    try {
      const pref = await this.getStore();
      const raw = await pref.get(DRAFT_KEY, '') as string;
      const draft = parseHold(raw, Date.now());
      if (draft !== undefined) {
        this.selected = [...draft.seatIds];
        this.expiresAtMs = draft.expiresAtMs;
        this.phase = 'holding';
        this.notice = '已恢复本地暂选';
        this.startTicker();
      } else if (raw.length > 0) {
        this.selected = [];
        this.phase = 'expired';
        this.notice = '旧草稿无效,请重新选择';
        await this.clearDraft();
      }
    } catch (_err) {
      this.notice = '读取失败,暂不能确认';
    } finally { this.busy = false; }
  }
  private async confirmPreview(): Promise<void> {
    if (this.busy || this.phase !== 'holding') { return; }
    this.busy = true;
    try {
      const pref = await this.getStore();
      const raw = await pref.get(DRAFT_KEY, '') as string;
      const now: number = Date.now();
      const draft = parseHold(raw, now);
      const snapshot = makeReview(raw, now);
      if (snapshot === undefined || draft === undefined ||
          draft.expiresAtMs !== this.expiresAtMs ||
          snapshot.seatIds.join(',') !== this.selected.join(',') ||
          now >= this.expiresAtMs) {
        this.phase = 'expired';
        this.selected = [];
        this.review = undefined;
        this.notice = '暂选状态不一致,请重新选择';
        await this.clearDraft();
        return;
      }
      this.stopTicker();
      this.review = snapshot;
      this.phase = 'reviewing';
      this.notice = '本地预览已建立,尚未锁座';
    } catch (_err) {
      this.notice = '校验失败,请重试';
    } finally { this.busy = false; }
  }
  private async abandonPreview(): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    const cleared = await this.clearDraft();
    if (cleared) {
      this.review = undefined;
      this.selected = [];
      this.phase = 'selecting';
      this.notice = '已退出预览,可重新选座';
    }
    this.busy = false;
  }
  aboutToAppear(): void { this.restoreDraft(); }
  onPageShow(): void { this.startTicker(); }
  onPageHide(): void { this.stopTicker(); }
  aboutToDisappear(): void { this.stopTicker(); }
  build() {
    Column({ space: 13 }) {
      Text('银幕影厅').fontSize(28).fontWeight(FontWeight.Bold)
      Text('四排六列|每席¥48|最多3席').fontColor('#66798B')
      Grid() {
        ForEach(this.seats, (seat: Seat) => {
          GridItem() {
            Button(seat.id)
              .height(46).width('90%')
              .backgroundColor(seat.status === 'sold' ? '#9CA6B7' :
                this.selected.includes(seat.id) ? '#1AAE9B' : '#7EBCD0')
              .enabled(seat.status !== 'sold' && this.phase === 'selecting')
              .onClick(() => this.toggleSeat(seat.id))
          }
        }, (seat: Seat) => seat.id)
      }.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
        .columnsGap(5).rowsGap(8).height(240)
      if (this.phase === 'selecting') {
        Text(`已选:${this.selected.join('、') || '无'}`)
        Text(`合计 ¥${this.selected.length * 48}`)
        Button('保存五分钟本地暂选').enabled(!this.busy && this.selected.length > 0)
          .onClick(() => this.beginHold())
      } else if (this.phase === 'holding') {
        Text(`本地暂选剩余 ${this.remainingSeconds} 秒`)
        Text(`座位 ${this.selected.join('、')}|合计 ¥${this.selected.length * 48}`)
        Button('核对并生成确认预览').enabled(!this.busy)
          .onClick(() => this.confirmPreview())
      } else if (this.phase === 'reviewing' && this.review !== undefined) {
        Text('本地确认预览').fontSize(22).fontWeight(FontWeight.Medium)
        Text(`座位:${this.review.seatIds.join('、')}`)
        Text(`共${this.review.ticketCount}席,合计¥${this.review.totalYuan}`)
        Text('尚未锁座、支付或出票').fontColor('#B14C45')
        Button('返回选座').enabled(!this.busy)
          .onClick(() => this.abandonPreview())
      } else {
        Button('重新选择').onClick(() => {
          this.phase = 'selecting'; this.notice = '请选择座位';
        })
      }
      Text(this.notice).fontSize(13).fontColor('#43667E')
    }.padding(18).width('100%').height('100%')
  }
}

本轮代码将24席网格、暂选草稿、恢复、确认预览放在一个 Stage 页面里,便于照着文件运行。若直接接入前三篇已有工程,则应只替换原有的 Index.ets,不要把两个 @Entry 页面拼在一起。重点依然是确认前重新读取 hold_draft_v1,而非用屏幕上的旧倒计时直接生成预览。

工程还应将“读取草稿”和“点击开始暂选”互斥,避免较慢的恢复结果覆盖刚保存的新草稿。busy 用于阻挡本组件的重复触发,但跨页面或多实例读写还需要独立存储服务序列化操作。clearDraft() 失败时,内存可保留“不可用于确认”的提示,后续启动仍要重新验证原记录;不能因为删除失败就让过期草稿恢复成有效状态。

还有一个经常被忽视的边界:前端预览并不应主动修改库存。即使用户从确认预览返回列表,原先的 Seat.status 仍来自既有静态表,不能把 A1 的状态永久改成 sold,否则本地练习会制造“自己给自己卖票”的错觉。严格说,确认预览只是把通过验证的输入冻结起来,用户退出时释放原草稿;真正售出状态只能由权威库存系统反馈。若已经发起网络请求,还要防止旧请求在新页面显示后返回并覆盖最新状态。

对可访问性也可以做最小但实用的处理:已售位置不只用灰色表示,还显示不可选择的文字或语义;选择数变化时底部摘要同步提供明确的“已选3席”提示;倒计时不要每秒通过弹窗打断用户。高对比、字体放大与窄屏下的按钮触控范围都属于交付验收的一部分,而非做完状态机以后可有可无的美化。对于座位号,排序应该始终遵循先行后列,例如 A1、A2、B1,避免用户的点击先后顺序成为订单展示顺序。

四、验收时应主动制造失败路径

本地确认并非只有“有三个座位就通过”一种测试。合理的检查表首先包含正常路径:在 A1、A2、B1 三席进入暂选,存储记录与内存席位一致,截止仍在未来两分钟,点击确认后出现“本地确认预览”,票数3、合计144元。随后修改本地 Key 中某个 ID,例如把 A2 改成 A3,重新确认应拒绝,提示冲突而非生成有效预览。

第二组是时间边界:将截止时间设成刚好等于当前毫秒值时,makeReview() 必须返回 undefined;把应用切到后台五分钟再返回,不能让陈旧定时器次数决定有效性。第三组是数据结构边界:重复 A1、第四个座位、超过五分钟的未来截止时间、非法版本 v2、非数字时间戳都应拒绝。第四组是操作顺序:快速双击确认、在读取 Preferences 时反复进入页面、存储删除失败、用户主动退出预览,这些动作不得创建两份不同的本地确认结果。

从恢复、截止检查到本地确认预览的状态转移与失败退路

图片中的绿色路径表示通过校验进入预览,红色路径表示拒绝和清理;箭头表达的是业务状态迁移,不是后台售票接口调用。验证时可先直接对 parseHold()、makeReview() 设计输入输出测试,再通过 DevEco Studio 在所选 SDK 的预览环境上检查页面生命周期。真实编译、设备 HiLog 和后台切换行为,需要在对应工程环境中实际记录,不能把示意画面上的状态当作自动化测试报告。

可参考华为开发者官方的 ArkUI @State 状态管理、Preferences 轻量存储 与 Grid/GridItem 布局 文档。示例采用 API 12 及以上可用的基础 Stage/ArkUI 能力和默认 XML Preferences,不启用 API 18 后可选的 GSKV,也不要求网络、支付、定位等权限。

最后,页面还要明确加载占位:在 restoreDraft() 尚未完成时,确认按钮应不可用,提示“正在核对本地暂选”;当读取失败时不要把上次的成功预览继续留在屏幕。存储清理是尽力而为的操作,但“允许确认”必须是严格校验的结果。即使临时数据被用户手动编辑,也只会影响本地示例的可见状态,不应推动任何真实票务结算。

五、收官要留下清晰的升级边界

至此,CinemaSeats 已形成可解释的四步本地流程:从座位状态和可用性约束出发,以绝对截止时间管理会话,再用 Preferences 恢复最小草稿,最后重新校验并生成本地确认预览。每篇增加一个独立工程问题,数据模型和固定示例始终一致。用户看到的 144 元来自 3×48,本机倒计时来自截止时间差;二者都不是实际影院的售价与库存证明。

如果产品继续走向商业售票,必须让服务器而非手机决定锁定是否生效:先提交稳定席位 ID,后端以数据库事务或等价原子条件检查库存,返回签名的锁单标识和服务端到期时间,随后才进入支付;支付失败、锁超时与重复回调也要设计幂等释放。前端恢复缓存时只能恢复显示意图,不能恢复服务器已失效的资格。这样拆分,才能保证练习代码的适用边界清楚,未来接入真实票务服务也不会把本地草稿误当订单事实。

Logo

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

更多推荐