五分钟暂选还剩两分十秒,用户临时切到地图应用,再回到“影厅座位编排”,页面却重新显示了空座位图。另一种情况更难发现:上一轮的倒计时已经结束,重新启动后,界面仍将 A1、A2、B1 涂成“暂选中”。这两个现象表面上都是界面恢复问题,本质却是把临时会话状态与可恢复的业务草稿混为一谈。前两篇已经构造了四排六列的座位模型与基于绝对截止时间的本地暂选机制,这一篇补上应用重新进入时的恢复规则,并明确哪些历史记录必须拒绝。

仍然使用 CinemaSeats 工程、Seat { id, row, col, status } 模型和四个已售席位 A3、B5、C1、D6。票价固定为每席 48 元,一次最多三席,示例选择 A1、A2、B1 时合计 144 元。这里使用 ArkData Preferences 记录本地临时草稿,不涉及账号登录、付款与远端锁座。Preferences 的可持久化并不等于售票系统已确认库存,页面恢复时仍须重新计算期限并执行一致性检查。

影厅草稿恢复与截止时间校验的技术封面

一、为何不把整张座位表直接存下来

上一轮 holding 阶段有三个重要量:被选中的座位 ID、截止毫秒时间戳、当前页面展示的剩余秒数。其中前两项是恢复时可能使用的输入,最后一项是随时间变化的派生值。如果把 remainingSeconds=130 直接落盘,下次打开时它并不知道已经过去了多少真实时间;如果把完整的 seats 数组写进草稿,曾经可选的席位也可能被错误当成永久有效的库存事实。

因此最小存储载荷只包含版本号、座位 ID 列表和 expiresAtMs。为了方便检查,我们把它编码成一条短字符串:v1|A1,A2,B1|截止时间毫秒值,写入 Preferences 的 hold_draft_v1。版本号方便后续格式升级;逗号分隔的 ID 是当前工程的固定格式,不拿它当通用序列化协议。选择一份小型、可验证的记录,还能降低输入损坏时的恢复成本。

不能只判断“这条 Key 存在”就恢复。工程必须验证版本正确、字段数正确、时间戳为安全整数、座位编号属于 A~D 与 1~6、没有重复 ID、总数在一到三席之间,而且任何座位都不属于当前的已售名单。最后再看时间:expiresAtMs <= Date.now() 表示过期,未来时刻若比当前时间多出五分钟以上也不接受,避免被明显篡改或过期的旧数据污染页面。由于用户可以改动设备时钟,这套校验只能保证本机示例的合理一致性,不是防作弊的可信时钟。

Preferences 官方文档提供 getPreferences、get、put、delete 与 flush 等接口;put 改变实例数据后要通过 flush 将默认 XML 格式的内容持久化。官方也明确指出它适合轻量设置类键值,而不是多进程并发事务存储。我们只在单进程的本地示例中写入一条短草稿,不借此构造订单数据库。接口与场景可对照 华为用户首选项指南 和 Preferences API 参考。

二、先约束草稿的输入,再允许进入页面状态

页面恢复不能先把旧 ID 涂蓝,发现冲突再回滚。更安全的顺序是“读原文—解析—完整校验—一次性提交 @State”,任何一个条件失败都要放弃整份草稿,而非保留部分座位。假如保存的是 A1,A3,B1,其中 A3 已售,直接丢弃这份记录比“删掉 A3 留下另外两席”更符合一次暂选行为的原子语义;用户原本选择三席,静默变成两席还可能改变票价和后续结算判断。

在 entry/src/main/ets/pages/Index.ets 中,数据模型沿用前篇;新增一个解析器,所有规则可脱离 ArkUI 单独测试。恢复的时间值只由输入和当前 now 决定,不读页面变量,因此可使用伪造的时间戳构造边界用例。

interface Seat {
  id: string;
  row: string;
  col: number;
  status: string;
}
interface HoldDraft {
  seatIds: string[];
  expiresAtMs: number;
}
const SOLD_IDS: string[] = ['A3', 'B5', 'C1', 'D6'];
const HOLD_MS: number = 5 * 60 * 1000;
function makeSeats(): Seat[] {
  const rows: string[] = ['A', 'B', 'C', 'D'];
  const result: Seat[] = [];
  for (const row of rows) {
    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 parseDraft(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) { return undefined; }
  if (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 };
}

把正则与集合校验放在时间判断之前,有两个好处:恶意或损坏的长字符串不会直接进入渲染路径;同时,使用 Set 是为检查重复项,不是改变 ID 顺序。A1,A2,B1 的页面顺序保持不变,恢复后价格仍由 selected.length * 48 计算,不能信任存储里额外提供的总价。ID、截止时间、价格都是不同来源的事实,只有 ID 和截止时间进入缓存。

DevEco Studio 白色主题中的 CinemaSeats 草稿恢复核心代码和座位预览

三、接入 Preferences,解决恢复与保存之间的时序

为了方便在普通 Stage 工程中维护,下面示例 Index.ets 中与新增流程相关的核心实现。原先的 makeSeats()、六列 Grid、点击 toggleSeat() 保持上篇行为:只有 phase === 'selecting' 时可切换非已售席位,超过三席显示提示。新加的 getStore() 在首次使用时请求 Preferences 实例,之后复用它;读写都采用 await,不假设调用会同步落盘。

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

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

  private async getStore(): Promise<preferences.Preferences> {
    if (this.store !== undefined) { return this.store; }
    const value = await preferences.getPreferences(
      getContext(this), 'cinema_seat_draft');
    this.store = value;
    return value;
  }

  private stopTicker(): void {
    if (this.ticker !== -1) {
      clearInterval(this.ticker);
      this.ticker = -1;
    }
  }
  private refreshRemaining(): void {
    if (this.phase !== 'holding') { return; }
    const seconds = Math.max(0,
      Math.ceil((this.expiresAtMs - Date.now()) / 1000));
    this.remainingSeconds = seconds;
    if (seconds === 0) { this.expireDraft(); }
  }
  private startTicker(): void {
    this.stopTicker();
    this.refreshRemaining();
    if (this.phase === 'holding') {
      this.ticker = setInterval(() => this.refreshRemaining(), 1000);
    }
  }

  private async restoreDraft(): Promise<void> {
    try {
      const pref = await this.getStore();
      const raw = await pref.get('hold_draft_v1', '') as string;
      if (raw.length === 0) { return; }
      const valid = parseDraft(raw, Date.now());
      if (valid === undefined) {
        await pref.delete('hold_draft_v1');
        await pref.flush();
        this.notice = '旧暂选无效,已恢复为可选择状态';
        return;
      }
      this.selected = [...valid.seatIds];
      this.expiresAtMs = valid.expiresAtMs;
      this.phase = 'holding';
      this.notice = '已恢复本地暂选,请核对座位';
      this.startTicker();
    } catch (_err) {
      this.notice = '草稿读取失败,请重新选择';
    }
  }

  private async beginHold(): Promise<void> {
    if (this.phase !== 'selecting' || this.selected.length === 0) {
      return;
    }
    this.phase = 'saving';
    const endsAt = Date.now() + HOLD_MS;
    try {
      const pref = await this.getStore();
      await pref.put('hold_draft_v1',
        `v1|${this.selected.join(',')}|${endsAt}`);
      await pref.flush();
      this.expiresAtMs = endsAt;
      this.phase = 'holding';
      this.notice = '仅在本机保留暂选草稿';
      this.startTicker();
    } catch (_err) {
      this.phase = 'selecting';
      this.notice = '保存失败,暂选未生效';
    }
  }

  private async expireDraft(): Promise<void> {
    this.stopTicker();
    this.phase = 'expired';
    this.selected = [];
    this.remainingSeconds = 0;
    try {
      const pref = await this.getStore();
      await pref.delete('hold_draft_v1');
      await pref.flush();
      this.notice = '暂选已过期,可重新选择';
    } catch (_err) {
      this.notice = '暂选已失效,缓存待下次清理';
    }
  }
  private toggleSeat(id: string): void {
    if (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 = '最多选择三席';
    }
  }
  private resetSelection(): void {
    if (this.phase === 'expired') {
      this.phase = 'selecting';
      this.notice = '请选择座位';
    }
  }
  build() {
    Column({ space: 16 }) {
      Text('影厅座位编排').fontSize(26).fontWeight(FontWeight.Bold)
      Text('A3、B5、C1、D6 已售;单席 ¥48')
        .fontColor('#66758A').fontSize(14)
      Grid() {
        ForEach(this.seats, (seat: Seat) => {
          GridItem() {
            Text(seat.id).fontSize(17)
              .fontColor(Color.White)
              .width('90%').height(54)
              .textAlign(TextAlign.Center)
              .backgroundColor(SOLD_IDS.includes(seat.id) ? '#9CA6B7' :
                this.selected.includes(seat.id) ? '#157FC1' : '#426D83')
              .borderRadius(9)
              .onClick(() => this.toggleSeat(seat.id))
          }
        }, (seat: Seat) => seat.id)
      }.columnsTemplate('repeat(6, 1fr)')
        .columnsGap(6).rowsGap(10).height(280)
      Text(this.phase === 'holding'
        ? `本地暂选剩余 ${Math.floor(this.remainingSeconds / 60)} 分 ${this.remainingSeconds % 60} 秒`
        : this.notice).fontColor('#285C74')
      Text(`当前选择:${this.selected.join('、') || '无'}`)
      Text(`合计:¥${this.selected.length * 48}`)
      if (this.phase === 'selecting') {
        Button('保存五分钟本地暂选')
          .onClick(() => this.beginHold())
      } else if (this.phase === 'holding') {
        Button('暂选中,请核对座位').enabled(false)
      } else if (this.phase === 'expired') {
        Button('重新选择').onClick(() => this.resetSelection())
      }
    }.padding(18).width('100%').height('100%')
  }
  aboutToAppear(): void { this.restoreDraft(); }
  onPageShow(): void { this.startTicker(); }
  onPageHide(): void { this.stopTicker(); }
  aboutToDisappear(): void { this.stopTicker(); }
}

代码由前后两个代码块拼成同一份 Index.ets:第一个代码块给出模型、座位表生成与校验函数,第二个代码块从导入模块开始给出页面组件。实际复制时,把首段声明放到 @Entry 之前,并保留每个文件只出现一个 @Entry。这样既可以单独测试解析函数,也可以将页面直接接入既有工程;生产项目里可再将 parseDraft 和存储访问提取到 model、storage 目录。生命周期接口的触发范围可核对 ArkUI 页面及组件生命周期;使用定时器必须配对清理,详见 Timer API。

这里有两个需要在工程集成时额外处理的竞态。其一,restoreDraft() 是异步任务,onPageShow() 可能早于存储读取完成;因此恢复成功后必须主动调用 startTicker(),不能只指望页面显示回调。其二,用户极快重复点击“暂选”可能触发两次 put,所以写入前立即将 phase 改成 saving,让后续点击被状态守卫拒绝。若允许“释放暂选”按钮,释放流程同样应先进入处理中状态,再删除缓存;删除失败时不能把内存状态伪装成持久化已完成。

四、恢复不是“信任”,而是一组可复现的判定

关键测试应覆盖以下输入。合法记录 v1|A1,A2,B1|未来两分钟时刻 可以恢复三席,并显示随当前时间推导的剩余秒数;v1|A3,B1|... 因为 A3 已售整份拒绝;v1|A1,A1|... 因重复 ID 拒绝;超过三席拒绝;v0、非法时间串、过去时间和超出五分钟的未来时间均拒绝。这些测试可以直接调用 parseDraft,无需先渲染页面。一个极容易漏掉的边界是恰好截止的瞬间:采用 expiresAtMs <= now,而非小于,保证到期当下直接过期。

把设备切入后台两分钟再回前台,onPageShow() 应依照 Date.now() 校正,而不是简单继续后台未触发的回调次数;如果进程被关闭,重新打开后 restoreDraft() 应执行相同验证。再人为把 Preferences 的座位串写成重复 ID,预期只出现“旧暂选无效”,而不会出现一半高亮、一半清空的画面。清理失败时继续维持不可使用的状态,下一次打开仍会做时间和席位检查,这比让坏记录再次变成有效选择安全。

恢复判定、过期清理与页面状态更新的数据流程

还需区分测试设计与设备实测。上面的毫秒值用于逻辑用例,不能据此声称预览环境里计时器精度、恢复耗时或闪退率已经验证。真实验收时,应在 DevEco Studio 中用对应 SDK 新建 Stage 工程,逐项核验导入路径、组件生命周期、Preferences 目录是否可写以及测试设备的窗口切换行为;必要时将存储异常代码记录到 HiLog,但不要在未获取真实运行日志时写出成功截图。

在功能合并前,还要梳理保存与读取的先后关系。Preferences 是单实例缓存,但 getPreferences() 仍返回 Promise;冷启动读取、用户点击保存、页面销毁等动作可能交错。正式项目可引入页面操作序号或单飞 Promise,保证旧恢复结果不会覆盖新选座;对于当前示例,初次恢复未结束前,最好在 UI 上禁用暂选提交入口。若需求包含多页面同时改写同一个草稿,应将写入集中到一个存储服务,而非每个页面各自管理 Key。这个问题不是加几个按钮就能消除的,需要从所有权层面明确由谁能修改本地草稿。

此外,已有 onPageHide() 只负责关闭 UI 更新定时器,它不会改变截止时间;不能在离开页面时把存储过期时间重新延后五分钟。重新进入时,同一段校验代码应再次计算当前剩余时间。这样,“恢复成功”意味着保存的草稿仍处于本地有效期,而不是页面状态恰好被保留在内存中。用户手动改时钟会影响示例结果,这也是前端时间模型必须在文档中写明的边界。

五、这一步完成了什么,仍缺什么

本篇把座位草稿从内存延伸到单机可恢复的轻量持久化,并为恢复设置了明确的拒绝边界:只保存最小字段,恢复前完整校验,到期后主动清理,UI 只在存储成功后进入 holding。它还保留了上一轮的 24 席、四个固定已售座位、三席上限和每席 48 元的连续工程设定,没有偷换成真实票务锁座。

如果要做真正可售卖的订单,还需要服务端库存原子校验、服务端签发的锁定截止时间、并发冲突恢复、支付与超时释放补偿。这些能力不能由 Preferences 或 Date.now() 独立提供。连载的最后一篇将把页面可访问性、选择态提示、失败路径和多种窗口尺寸下的布局放在一起验收,让这套练习工程在交互层形成完整闭环,而不把本地示例包装为正式票务产品。

Logo

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

更多推荐