《移动游戏产品的架构·客户端与移动运行时》8.3 平板、折叠屏与异形屏

平板、折叠屏与异形屏不能用宽高比一个数判断。本节把尺寸、折叠状态与窗口模式拆成三个输入,给出 600/840dp 断点、折叠动画的稳定窗口、安全区换算与重排判据,并用无头模型列出矩阵缺口,明确哪些结论必须回到真机矩阵验证,不能由无头模型替代。

事故:展开之后,家园装饰跑到了折痕上

《潮汐消消》在折叠屏内屏上出现过一次典型错位:设备展开后可用宽度约 800dp,家园装饰仍然沿用手机的紧凑布局,单列卡片被拉满整屏,右侧装饰被折痕挡住;与此同时,折叠动画每产生一帧尺寸变化,布局就重建一次,展开过程明显掉帧。

另一类投诉来自分屏:玩家把游戏放进分屏,窗口只有 400dp 宽,但客户端仍按全屏宽度计算安全区,HUD 顶部被系统状态栏压住。三件事分别是:形态判断错了、重排时机错了、安全区来源错了。

本节把这三件事拆开:尺寸决定断点,折叠状态决定形态,窗口模式决定可用宽度;安全区是输入,不是猜出来的常量。

三个输入:尺寸、折叠态与窗口模式

只看宽高比无法区分「横屏手机」和「平板」:两者都可能是 16:9 的宽屏。形态判断需要三个独立输入。

输入来源单独使用时的盲区
宽、高、DPI显示与窗口 API宽屏无法区分手机横屏与平板
折叠状态折叠屏姿态回调展开态也不代表一定处于大屏窗口
窗口模式多窗口与自由窗口 API分屏宽度可能远小于屏幕物理宽度
安全区系统 inset 或窗口 inset异形屏、刘海、圆角与手势条各不相同
刷新率显示 API只影响帧策略,不参与形态分类

逻辑尺寸统一用 dp:dp = px × 160 / dpi。形态用短边判断,断点用当前窗口宽度判断。平板竖屏 800×1280dp 的短边是 800,判为平板;手机横屏 800×360dp 的短边是 360,仍然是手机。

断点与布局重算

断点宽度范围列数典型形态
compact<600dp1手机竖屏、分屏窄窗口
medium600–839dp2小平板、折叠内屏、手机横屏
expanded>=840dp3大平板、展开态大窗口

断点不是越细越好。每增加一个断点,就要多维护一套布局与一组回归用例。600 与 840 这两个值来自 mga-lab 的比较参数,项目可以调整,但必须同时调整断点、列数与测试。

断点切换需要回滞:从 medium 回落到 compact 时,宽度要低于 600 - 24 = 576dp 才切换。否则窗口在 600dp 附近轻微拖动,就会在单列与双列之间反复跳。向上进入新断点仍按阈值判断,向下才使用回滞带。

折叠与多窗口的状态机

折叠状态形态断点规则
folded折叠外屏按外屏短边判断,通常为 compact
half-open折叠内屏按内屏宽度判断,可能是 medium 或 expanded
flat 且短边 >=600dp平板按窗口宽度判断
flat 且短边 <600dp手机按窗口宽度判断
任意状态且非全屏保留形态,标记多窗口强制 compact,列数为 1

多窗口是独立维度,不是「窄一点的平板」。分屏窗口即使物理屏幕很宽,可用宽度也可能只有 400dp;此时应使用单列布局,并关闭依赖全屏的装饰动画。自由窗口还要额外处理标题栏与圆角带来的 inset。

折叠状态变化还会产生「取消」:玩家展开到一半又合上。状态机必须允许回到原形态,并且不触发重排,否则一次未完成的折叠会留下两套布局状态。

重排还是只改视口

不是每次尺寸变化都要重建布局。把变化分成四类,才能同时保证正确与流畅。

动作触发条件代价例子
relayout形态、断点、方向、宽高比类别或窗口模式变化重建布局树手机转平板、展开内屏
defer结构变化尚未稳定等待,不重建折叠动画中间帧
viewport-only结构不变,仅尺寸或安全区变化调整视口与 HUD键盘弹起、状态栏变化
no-op最终状态与当前一致无折叠取消、重复回调

结构变化用 120ms 的稳定窗口合并:连续回调只要形态还在变,就持续 defer;同一结构持续超过窗口,才提交一次 relayout。这个窗口是案例设定下的比较参数,项目要按折叠动画时长调整。提交时记录原因(形态、断点、方向、宽高比、窗口模式),线上才能解释「为什么这次重排了」。

异形屏与安全区

安全区必须来自系统或窗口 inset,不能写死状态栏高度。刘海、挖孔、曲面、圆角、手势条在不同厂商与不同姿态下都不同。

项目处理方式常见错误
顶部刘海上 inset 换算为 dp,HUD 顶边下移固定 24dp,异形屏被遮挡
底部手势条下 inset 进入安全区,按钮上移只看顶部,底部按钮点不到
横屏侧边挖孔左或右 inset 生效只处理竖向,横屏内容被切
圆角四角预留内边距贴边 UI 被裁切
分屏标题栏使用窗口 inset 而非屏幕 inset全屏坐标导致偏移

模型把 inset 作为输入换算成 dp,再把 HUD 顶边设为 safeTop + 12dp。12dp 是视觉留白,不是安全区;安全区来自系统,留白来自设计。

可运行模型:form-factors.ts

下面的模型把形态分类、断点回滞、稳定窗口、安全区换算与矩阵缺口放在同一个文件里。显示快照由调用方注入,测试不依赖真实设备。

// file: src/input/form-factors.ts
// tier: L1
// verify: node --test test/input/form-factors.test.ts

export type FoldState = "flat" | "folded" | "half-open";
export type WindowMode = "fullscreen" | "split" | "freeform";
export type Breakpoint = "compact" | "medium" | "expanded";
export type FormFactor = "phone" | "foldable-cover" | "foldable-inner" | "tablet";
export type AspectClass = "standard" | "tall" | "ultra-tall";
export type Orientation = "portrait" | "landscape";

export interface InsetsPx { readonly left: number; readonly top: number; readonly right: number; readonly bottom: number; }

export interface DisplaySnapshot {
  readonly widthPx: number; readonly heightPx: number; readonly dpi: number;
  readonly foldState: FoldState; readonly windowMode: WindowMode; readonly insets: InsetsPx;
}

export interface BreakpointConfig {
  readonly mediumMinDp: number; readonly expandedMinDp: number;
  readonly hysteresisDp: number; readonly settleMs: number;
}

export const DEFAULT_BREAKPOINTS: BreakpointConfig = {
  mediumMinDp: 600, expandedMinDp: 840, hysteresisDp: 24, settleMs: 120,
};

export interface LayoutPlan {
  readonly formFactor: FormFactor; readonly breakpoint: Breakpoint;
  readonly orientation: Orientation; readonly aspectClass: AspectClass;
  readonly aspectRatio: number; readonly widthDp: number; readonly heightDp: number;
  readonly columns: number; readonly hudScale: number; readonly hudTopDp: number;
  readonly safeAreaDp: InsetsPx; readonly multiWindow: boolean;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

export function breakpointFor(
  widthDp: number,
  previous: Breakpoint | null,
  config: BreakpointConfig = DEFAULT_BREAKPOINTS,
): Breakpoint {
  const { mediumMinDp, expandedMinDp, hysteresisDp } = config;
  if (previous === null) {
    return widthDp < mediumMinDp ? "compact" : widthDp < expandedMinDp ? "medium" : "expanded";
  }
  if (previous === "compact") {
    return widthDp >= expandedMinDp ? "expanded" : widthDp >= mediumMinDp ? "medium" : "compact";
  }
  if (previous === "medium") {
    return widthDp >= expandedMinDp ? "expanded"
      : widthDp < mediumMinDp - hysteresisDp ? "compact" : "medium";
  }
  return widthDp < mediumMinDp - hysteresisDp ? "compact"
    : widthDp < expandedMinDp - hysteresisDp ? "medium" : "expanded";
}

export function classify(
  snapshot: DisplaySnapshot,
  previous: Breakpoint | null = null,
  config: BreakpointConfig = DEFAULT_BREAKPOINTS,
): LayoutPlan {
  if (snapshot.dpi <= 0 || snapshot.widthPx <= 0 || snapshot.heightPx <= 0) {
    throw new RangeError("display snapshot needs positive size and dpi");
  }
  const widthDp = Math.round((snapshot.widthPx * 160) / snapshot.dpi);
  const heightDp = Math.round((snapshot.heightPx * 160) / snapshot.dpi);
  const orientation: Orientation = widthDp >= heightDp ? "landscape" : "portrait";
  const shortDp = Math.min(widthDp, heightDp);
  const aspectRatio = Math.max(widthDp, heightDp) / shortDp;
  const aspectClass: AspectClass =
    aspectRatio < 1.78 ? "standard" : aspectRatio <= 2.1 ? "tall" : "ultra-tall";
  const formFactor: FormFactor = snapshot.foldState === "folded" ? "foldable-cover"
    : snapshot.foldState === "half-open" ? "foldable-inner"
    : shortDp >= 600 ? "tablet" : "phone";
  const multiWindow = snapshot.windowMode !== "fullscreen";
  const breakpoint = multiWindow ? "compact" : breakpointFor(widthDp, previous, config);
  const columns = breakpoint === "compact" ? 1 : breakpoint === "medium" ? 2 : 3;
  const dpPerPx = 160 / snapshot.dpi;
  const safeAreaDp: InsetsPx = {
    left: Math.round(snapshot.insets.left * dpPerPx),
    top: Math.round(snapshot.insets.top * dpPerPx),
    right: Math.round(snapshot.insets.right * dpPerPx),
    bottom: Math.round(snapshot.insets.bottom * dpPerPx),
  };
  return {
    formFactor, breakpoint, orientation, aspectClass, aspectRatio, widthDp, heightDp, columns,
    hudScale: clamp(widthDp / 375, 0.85, 1.4), hudTopDp: safeAreaDp.top + 12, safeAreaDp, multiWindow,
  };
}

export type ReconcileAction = "relayout" | "defer" | "viewport-only" | "no-op";

export interface ReconcileResult {
  readonly action: ReconcileAction; readonly plan: LayoutPlan;
  readonly reasons: readonly string[]; readonly pending: boolean;
}

interface PendingChange { readonly plan: LayoutPlan; readonly sinceMs: number; }

function structuralKey(plan: LayoutPlan): string {
  return [plan.formFactor, plan.breakpoint, plan.orientation, plan.aspectClass, plan.multiWindow].join("|");
}

function structuralReasons(previous: LayoutPlan, next: LayoutPlan): string[] {
  const reasons: string[] = [];
  if (previous.formFactor !== next.formFactor) {
    reasons.push(`form-factor:${previous.formFactor}->${next.formFactor}`);
  }
  if (previous.breakpoint !== next.breakpoint) {
    reasons.push(`breakpoint:${previous.breakpoint}->${next.breakpoint}`);
  }
  if (previous.orientation !== next.orientation) {
    reasons.push(`orientation:${previous.orientation}->${next.orientation}`);
  }
  if (previous.aspectClass !== next.aspectClass) {
    reasons.push(`aspect:${previous.aspectClass}->${next.aspectClass}`);
  }
  if (previous.multiWindow !== next.multiWindow) {
    reasons.push(`window:${previous.multiWindow}->${next.multiWindow}`);
  }
  return reasons;
}

/** 折叠动画与旋转用稳定窗口合并重排;仅尺寸或安全区变化走 viewport-only。 */
export class DisplayReconciler {
  readonly #config: BreakpointConfig;
  #applied: LayoutPlan | null = null;
  #pending: PendingChange | null = null;

  constructor(config: BreakpointConfig = DEFAULT_BREAKPOINTS) {
    this.#config = config;
  }

  get applied(): LayoutPlan | null {
    return this.#applied;
  }

  submit(snapshot: DisplaySnapshot, tMs: number): ReconcileResult {
    const plan = classify(snapshot, this.#applied?.breakpoint ?? null, this.#config);
    if (!this.#applied) {
      this.#applied = plan;
      return { action: "relayout", plan, reasons: ["initial-layout"], pending: false };
    }
    const applied = this.#applied;
    if (structuralKey(plan) === structuralKey(applied)) {
      this.#pending = null;
      this.#applied = plan;
      const viewportChanged = plan.widthDp !== applied.widthDp || plan.heightDp !== applied.heightDp
        || plan.safeAreaDp.left !== applied.safeAreaDp.left || plan.safeAreaDp.top !== applied.safeAreaDp.top
        || plan.safeAreaDp.right !== applied.safeAreaDp.right || plan.safeAreaDp.bottom !== applied.safeAreaDp.bottom;
      return {
        action: viewportChanged ? "viewport-only" : "no-op", plan,
        reasons: viewportChanged ? ["viewport"] : [], pending: false,
      };
    }
    if (this.#pending && structuralKey(this.#pending.plan) === structuralKey(plan)) {
      if (tMs - this.#pending.sinceMs >= this.#config.settleMs) {
        this.#applied = plan;
        this.#pending = null;
        return { action: "relayout", plan, reasons: structuralReasons(applied, plan), pending: false };
      }
      return { action: "defer", plan, reasons: ["settling"], pending: true };
    }
    this.#pending = { plan, sinceMs: tMs };
    return { action: "defer", plan, reasons: ["settling"], pending: true };
  }

  flush(tMs: number): ReconcileResult {
    if (!this.#applied) {
      throw new Error("no layout has been submitted");
    }
    if (!this.#pending) {
      return { action: "no-op", plan: this.#applied, reasons: [], pending: false };
    }
    const applied = this.#applied;
    const { plan, sinceMs } = this.#pending;
    this.#applied = plan;
    this.#pending = null;
    const reasons = structuralReasons(applied, plan);
    if (tMs - sinceMs < this.#config.settleMs) {
      reasons.push("forced-flush");
    }
    return { action: "relayout", plan, reasons, pending: false };
  }
}

export interface MatrixCase {
  readonly formFactor: FormFactor; readonly foldState: FoldState;
  readonly windowMode: WindowMode; readonly aspectClass: AspectClass;
}

export function matrixCaseOf(plan: LayoutPlan, snapshot: DisplaySnapshot): MatrixCase {
  return {
    formFactor: plan.formFactor, foldState: snapshot.foldState,
    windowMode: snapshot.windowMode, aspectClass: plan.aspectClass,
  };
}

export function matrixKey(item: MatrixCase): string {
  return [item.formFactor, item.foldState, item.windowMode, item.aspectClass].join("/");
}

export function uncoveredMatrixCases(
  cases: readonly MatrixCase[],
  coveredKeys: readonly string[],
): readonly string[] {
  const covered = new Set(coveredKeys);
  const missing = new Set<string>();
  for (const item of cases) {
    const key = matrixKey(item);
    if (!covered.has(key)) {
      missing.add(key);
    }
  }
  return [...missing].sort();
}

配套测试 test/input/form-factors.test.ts 覆盖 13 条路径:高长宽比手机、800dp 平板、折叠外屏与半开内屏、分屏强制单列、安全区换算、断点回滞、旋转延迟重排、折叠动画只重排一次、折叠取消、视口级变化、强制 flush、矩阵缺口与非法快照。运行命令是 node --test test/input/form-factors.test.ts。

模型不读取真实折叠角度、不查询厂商挖孔 API,也不验证渲染结果。它只回答「按输入应该属于哪种形态、是否该重排、缺哪些矩阵用例」。这里是模型,不是设备实测。

真机矩阵与无头模型的边界

无头模型能验证分类与判据,不能替代真机。矩阵用例至少包含形态、折叠状态、窗口模式与宽高比类别四个维度;模型会把未覆盖的组合列出来。

能由无头模型验证必须回到真机验证
dp 换算与断点分类折叠铰链角度与动画连续性
回滞与稳定窗口的重排次数厂商分屏、自由窗口的真实行为
安全区输入后的 HUD 位置挖孔、圆角、手势条的实际裁切
矩阵缺口清单展开/折叠过程中的画面撕裂与掉帧
结构与视口变化的分类应用连续性、重启与状态恢复

真机矩阵的组织方式可以参考兼容性与设备矩阵测试 。矩阵不是「买几台设备点一遍」,而是把上面的维度组合成可复现的用例,并把每台设备的系统版本、窗口模式与安全区记录下来。

双案例:消消守单列,方舟守战场

消消以手机为主,平板与折叠屏是增量场景。策略是「单列永远可用」:任何形态先保证单列布局完整可玩,再在 medium 与 expanded 上增加装饰位;分屏与折叠取消都不能触发重排。

方舟的单场战斗 8–12 分钟,布局变化会打断操作。它的策略是战斗中冻结结构性重排,只允许视口级调整;折叠或分屏变化先提示,战斗结束后再应用新布局。

与相邻章节的关系

8.1 归一化输入意图,8.2 决定画质档位,本节决定布局与安全区;三者共用同一份设备与显示快照,但回答的问题不同。9.1 会把刷新率用于帧率分级与动态分辨率,本节只把它当作输入之一。UI 适配的通用做法可以参考UI 适配 ,多窗口下的返回与页面栈可以参考页面栈与返回 。

小结

  • 形态判断需要尺寸、折叠状态、窗口模式三个输入;只看宽高比会把横屏手机当成平板。
  • 形态用短边判断,断点用窗口宽度判断;分屏强制单列,不继承物理屏幕宽度。
  • 断点回滞只作用于向下切换,稳定窗口只提交一次重排,折叠取消必须回到 no-op。
  • 安全区来自系统或窗口 inset,必须换算成 dp;固定状态栏高度在异形屏上必然出错。
  • 无头模型能列矩阵缺口,不能替代真机;折叠连续性、厂商窗口行为与渲染结果必须回到真机验证。

阅读导航:上一节:8.2 机型分档与画质策略配置化 · 下一节:9.1 帧率分级与动态分辨率 。

常见错误与边界

  • 用宽高比判断平板。横屏手机与平板可能都是宽屏;形态必须结合短边、折叠状态与窗口模式。
  • 分屏时继续使用物理屏幕宽度。可用宽度可能只有 400dp,按全屏计算会把单列布局撑成三列。
  • 折叠动画每帧重排。必须用稳定窗口合并结构变化,否则展开过程会持续掉帧,还可能留下中间态布局。
  • 断点没有回滞。窗口在阈值附近轻微变化就会反复切换列数;回滞带要写进断点配置并覆盖测试。
  • 把安全区写成固定状态栏高度。刘海、挖孔、圆角、手势条与分屏标题栏都会改变 inset;必须读取系统或窗口值。
  • 用无头模型替代真机验收。模型能证明分类逻辑,不能证明铰链动画、厂商窗口行为和渲染裁切;矩阵缺口必须显式列出。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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