事故:一次返回键,退掉了整局
《潮汐消消》每周二更新活动资源,那天会同时触发两类界面:活动入口弹窗与「回到本局」提示。某次版本里,玩家在案例设定的 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 瑕疵。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。