《移动游戏产品的架构·客户端与移动运行时》7.1 UI 栈、弹窗队列与返回键语义

返回键不是「关闭当前界面」,而是从最上层消费一次导航意图。本节用页面栈、互斥弹窗队列、优先级、去重与抢占规则,解释 Android 返回键和 iOS 侧滑为何必须归一化成不同结果,并给出连点节流与 Toast 冷却的可运行模型,让误退出和重复弹窗变成可回归的断言。

事故:一次返回键,退掉了整局

《潮汐消消》每周二更新活动资源,那天会同时触发两类界面:活动入口弹窗与「回到本局」提示。某次版本里,玩家在案例设定的 90 秒单局中途切出去领奖励,回来时活动弹窗还开着;他按了一下 Android 返回键,弹窗关掉的同时页面栈也被多弹了一层,直接回到主界面,单局进度丢失。

复现时发现问题不在某一个界面,而在没有一个统一的「当前最上层是谁」。页面、模态、确认框、Toast 各自维护开关,返回键回调里又按「先关弹窗、再关页面」的顺序硬编码,结果一次系统事件消费了两层。另一条投诉来自按钮连点:奖励领取按钮在极短时间内被点了两次,发奖请求发了两遍,客户端弹了两次到账提示。

这两个问题都适合用数据结构解决,而不是继续加 if。本节把页面栈、互斥弹窗队列、返回意图归一化和重复触发防护收进一个 L1 模型,后用测试锁住行为。

页面栈与模态不是同一个栈

页面栈表达「玩家从哪里来、能回到哪里去」;弹窗队列表达「此刻谁独占屏幕」。把它们塞进同一个数组,返回键的语义就会随实现者的直觉漂移。

层是否进入页面栈返回键行为能否同时存在
页面 page是,后进先出弹出栈顶页面可以叠多层
模态 modal否,进入互斥队列关闭当前模态同一时刻只有一个
确认框 confirm否,进入互斥队列默认等同「取消」,不是「确定」同一时刻只有一个
Toast否,不参与返回不消费返回键可短时并存但不阻塞

确认框的返回语义要单独写死:Android 返回键与「取消」绑定,绝不绑定「确定」。这条规则看似保守,却能避免「误触购买确认后按返回,结果扣款成功」一类事故。页面栈的根节点永远保留,pop 到根就停止,不能把根弹掉再靠壳层兜底。

页面栈还承担一个隐性职责:页面进入后台再回来时的恢复顺序。恢复时按栈重建,而不是按弹窗队列重建;弹窗是否重新展示取决于业务,不能假设「上次开着就一定要再开」。这条边界与页面栈与返回 中的导航模型一致,但游戏还要额外处理模态与 Toast。

返回键归一化:Android 与 iOS 是两种意图

Android 的返回键是系统级事件,作用域是「当前 Activity 内最上层可关闭的东西」;iOS 没有全局返回键,左边缘侧滑是导航控制器的手势,只对页面栈生效。把两者映射成同一个 back() 调用,却要求相同结果,是一类常见错误。

输入平台语义归一化后的结果容易写错的地方
Android 返回键系统事件,可被壳层拦截先关模态;无模态则弹页面;根页面双击退出一次事件关掉模态和页面
iOS 左边缘侧滑导航栈手势只弹页面;模态保持打开把侧滑当成「关闭一切」
根页面 Android 返回无页面可弹首次进入退出窗口,窗口内再次返回才退出窗口过长导致误退出

根页面的「双击退出」窗口在模型里取 2000 毫秒,这是实验参数,不是平台规定。窗口必须可配置,并且只在 Android 路径生效;iOS 根页面侧滑应当被忽略,而不是退出应用。若游戏在玩法界面自定义了侧滑手势,还要先经过 7.3 的边缘防护,避免系统手势与玩法滑动互相抢事件。

一个实用判据:返回键一次只消费一层,且消费结果必须能用一个枚举描述。如果返回值是 void,调用方就无法区分「关了弹窗」「弹了页面」「进入退出窗口」「被阻止」,测试也只能断言副作用。

弹窗队列:优先级、去重与抢占

弹窗必须互斥。两个模态同时可见时,返回键、遮罩点击和焦点都会变得不可预测。队列按「优先级高者先出、同优先级先到先出」排序,同一业务键在队列里只允许出现一次。

优先级典型弹窗是否允许抢占当前弹窗返回键
100登录失效、强制更新是,系统级阻止返回
50升级、活动结算否,排队关闭
20每日奖励、体力恢复否,排队关闭
5轻提示(走 Toast 通道)不进入互斥队列不消费

抢占只留给系统级弹窗,并且必须先取消进行中的触控手势,否则玩家正在点击的按钮会在弹窗切换瞬间落到新界面上。这个顺序不是洁癖:7.3 的仲裁器会把「模态打开」当作输入门,但门打开之前已经按下的手指仍然可能抬起并触发一次点击。

去重的键必须是业务键,不是标题。同一个「奖励到账」标题可能对应日常奖励、活动奖励和补偿邮件,用标题去重会吞掉合法提示;反过来,同一个业务键被两个入口各弹一次,才是需要压掉的重复。

可运行模型:screen-stack.ts

下面的模型把页面栈、弹窗队列、返回归一化、Toast 冷却与点击节流放在一个文件里。它不依赖引擎,也不依赖真实时钟;时间由调用方传入,测试可以精确控制毫秒。

// file: src/ui/screen-stack.ts
// tier: L1
// verify: node --test test/ui/screen-stack.test.ts

export type BackSource = "android-back" | "ios-edge-swipe";

export interface Screen {
  readonly id: string;
  readonly kind: "page" | "modal" | "confirm";
}

export interface Popup {
  readonly key: string;
  readonly priority: number;
  readonly kind: "modal" | "confirm";
  readonly back: "close" | "block";
  readonly preemptive?: boolean;
}

export interface QueuedPopup {
  readonly popup: Popup;
  readonly seq: number;
}

export type EnqueueResult =
  | { readonly kind: "shown" }
  | { readonly kind: "queued"; readonly position: number }
  | { readonly kind: "duplicate" };

export type BackOutcome =
  | { readonly kind: "closed-popup"; readonly key: string }
  | { readonly kind: "popped-page"; readonly id: string }
  | { readonly kind: "exit-armed"; readonly deadlineMs: number }
  | { readonly kind: "exit" }
  | { readonly kind: "ignored"; readonly reason: string };

export const EXIT_WINDOW_MS = 2_000;
export const TOAST_COOLDOWN_MS = 1_500;
export const TAP_WINDOW_MS = 350;

function comparePopup(a: QueuedPopup, b: QueuedPopup): number {
  if (a.popup.priority !== b.popup.priority) {
    return b.popup.priority - a.popup.priority;
  }
  return a.seq - b.seq;
}

export class UiStack {
  #pages: Screen[];
  #queue: QueuedPopup[] = [];
  #active: QueuedPopup | null = null;
  #seq = 0;
  #exitDeadlineMs = 0;

  constructor(root: Screen) {
    this.#pages = [root];
  }

  get depth(): number {
    return this.#pages.length;
  }

  get top(): Screen {
    return this.#pages[this.#pages.length - 1];
  }

  get activePopup(): QueuedPopup | null {
    return this.#active;
  }

  get queuedKeys(): readonly string[] {
    return this.#queue.map((item) => item.popup.key);
  }

  pushPage(screen: Screen): void {
    if (screen.kind !== "page") {
      throw new Error("only page screens belong on the page stack");
    }
    this.#pages.push(screen);
  }

  popPage(): Screen | null {
    if (this.#pages.length <= 1) {
      return null;
    }
    return this.#pages.pop() ?? null;
  }

  enqueue(popup: Popup): EnqueueResult {
    if (this.#active?.popup.key === popup.key) {
      return { kind: "duplicate" };
    }
    if (this.#queue.some((item) => item.popup.key === popup.key)) {
      return { kind: "duplicate" };
    }
    const item: QueuedPopup = { popup, seq: (this.#seq += 1) };
    if (this.#active === null) {
      this.#active = item;
      return { kind: "shown" };
    }
    if (popup.preemptive === true && popup.priority > this.#active.popup.priority) {
      this.#queue.push(this.#active);
      this.#active = item;
      this.#queue.sort(comparePopup);
      return { kind: "shown" };
    }
    this.#queue.push(item);
    this.#queue.sort(comparePopup);
    return { kind: "queued", position: this.#queue.indexOf(item) + 1 };
  }

  dismissActive(): QueuedPopup | null {
    if (this.#active === null) {
      return null;
    }
    const closed = this.#active;
    this.#active = this.#queue.shift() ?? null;
    return closed;
  }

  back(source: BackSource, nowMs: number): BackOutcome {
    if (this.#active !== null) {
      if (source === "ios-edge-swipe") {
        return { kind: "ignored", reason: "edge-swipe-navigates-pages-only" };
      }
      if (this.#active.popup.back === "block") {
        return { kind: "ignored", reason: "popup-blocks-back" };
      }
      const key = this.#active.popup.key;
      this.#active = this.#queue.shift() ?? null;
      return { kind: "closed-popup", key };
    }
    if (source === "ios-edge-swipe") {
      const popped = this.popPage();
      return popped
        ? { kind: "popped-page", id: popped.id }
        : { kind: "ignored", reason: "gesture-at-root" };
    }
    const popped = this.popPage();
    if (popped !== null) {
      return { kind: "popped-page", id: popped.id };
    }
    if (nowMs < this.#exitDeadlineMs) {
      this.#exitDeadlineMs = 0;
      return { kind: "exit" };
    }
    this.#exitDeadlineMs = nowMs + EXIT_WINDOW_MS;
    return { kind: "exit-armed", deadlineMs: this.#exitDeadlineMs };
  }
}

export class ToastCenter {
  #lastShown = new Map<string, number>();

  show(key: string, nowMs: number, cooldownMs: number = TOAST_COOLDOWN_MS): boolean {
    const last = this.#lastShown.get(key);
    if (last !== undefined && nowMs - last < cooldownMs) {
      return false;
    }
    this.#lastShown.set(key, nowMs);
    return true;
  }

  reset(key?: string): void {
    if (key === undefined) {
      this.#lastShown.clear();
      return;
    }
    this.#lastShown.delete(key);
  }
}

export class TapThrottle {
  #lastStarted = new Map<string, number>();

  tryStart(key: string, nowMs: number, windowMs: number = TAP_WINDOW_MS): boolean {
    const last = this.#lastStarted.get(key);
    if (last !== undefined && nowMs - last < windowMs) {
      return false;
    }
    this.#lastStarted.set(key, nowMs);
    return true;
  }

  reset(key?: string): void {
    if (key === undefined) {
      this.#lastStarted.clear();
      return;
    }
    this.#lastStarted.delete(key);
  }
}

模型有三个刻意保留的边界。第一,enqueue 不自动关闭当前弹窗,除非新弹窗显式声明 preemptive 且优先级更高;第二,back 对 iOS 侧滑永远不关闭模态;第三,Toast 与点击节流各自维护冷却表,不共享状态,避免「提示冷却」误伤「按钮防连点」。

与相邻章节的关系

弹窗持有资源句柄,关闭时必须释放,否则会回到资源泄漏定位与回归 讨论的问题。弹窗的位置与安全区由多分辨率、刘海屏与安全区 约束,尤其是底部 Home 指示条附近的主按钮。输入层如何把「模态打开」变成可执行的拦截,见触控仲裁与误触防护 。

案例设定里,对照案例《潮汐方舟》是 8–12 分钟强联网战斗,返回键不能直接关战斗界面:战斗中的返回应转为「请求撤退」确认框,并由服务端确认后再退出。它的弹窗队列因此需要额外的网络状态位,不能照搬消消的本地关闭语义。

小结

返回键的可靠性来自结构,不来自回调里的判断顺序。页面栈负责「回到哪里」,互斥队列负责「谁在屏幕最上层」,归一化层负责「Android 与 iOS 各自能做什么」,节流层负责「同一动作不要发生两次」。四者分开,才能分别测试、分别回归。

阅读导航:上一节:6.3 资源泄漏定位与回归 · 下一节:7.2 多分辨率、刘海屏与安全区 。

常见错误与边界

  • 用一个数组同时存页面和模态,返回键按数组尾部弹出。页面与模态的生命周期不同,混存会让「关弹窗」变成「退页面」。
  • 把 iOS 侧滑实现成「关闭最上层」。侧滑是导航手势,不是通用关闭手势;模态应保持打开,除非产品明确要求。
  • 让 Toast 进入返回栈。Toast 不消费返回键,也不阻塞输入;把它当模态会让玩家连按两次返回才退出。
  • 用标题或文案做弹窗去重键。标题会复用,业务键不会;去重键应包含业务类型与实体 id。
  • 允许任意高优先级弹窗抢占。只有系统级弹窗可以抢占,且抢占前必须取消进行中的触控手势。
  • 退出窗口写成固定 2 秒且不分平台。窗口应由配置给出,并在 iOS 路径完全禁用退出语义。
  • 账号切换或回到登录页时只清页面栈,不清弹窗队列。上一账号的奖励弹窗会落到新账号上,这是数据越权而不是 UI 瑕疵。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查