事故:展开之后,家园装饰跑到了折痕上
《潮汐消消》在折叠屏内屏上出现过一次典型错位:设备展开后可用宽度约 800dp,家园装饰仍然沿用手机的紧凑布局,单列卡片被拉满整屏,右侧装饰被折痕挡住;与此同时,折叠动画每产生一帧尺寸变化,布局就重建一次,展开过程明显掉帧。
另一类投诉来自分屏:玩家把游戏放进分屏,窗口只有 400dp 宽,但客户端仍按全屏宽度计算安全区,HUD 顶部被系统状态栏压住。三件事分别是:形态判断错了、重排时机错了、安全区来源错了。
本节把这三件事拆开:尺寸决定断点,折叠状态决定形态,窗口模式决定可用宽度;安全区是输入,不是猜出来的常量。
三个输入:尺寸、折叠态与窗口模式
只看宽高比无法区分「横屏手机」和「平板」:两者都可能是 16:9 的宽屏。形态判断需要三个独立输入。
| 输入 | 来源 | 单独使用时的盲区 |
|---|---|---|
| 宽、高、DPI | 显示与窗口 API | 宽屏无法区分手机横屏与平板 |
| 折叠状态 | 折叠屏姿态回调 | 展开态也不代表一定处于大屏窗口 |
| 窗口模式 | 多窗口与自由窗口 API | 分屏宽度可能远小于屏幕物理宽度 |
| 安全区 | 系统 inset 或窗口 inset | 异形屏、刘海、圆角与手势条各不相同 |
| 刷新率 | 显示 API | 只影响帧策略,不参与形态分类 |
逻辑尺寸统一用 dp:dp = px × 160 / dpi。形态用短边判断,断点用当前窗口宽度判断。平板竖屏 800×1280dp 的短边是 800,判为平板;手机横屏 800×360dp 的短边是 360,仍然是手机。
断点与布局重算
| 断点 | 宽度范围 | 列数 | 典型形态 |
|---|---|---|---|
compact | <600dp | 1 | 手机竖屏、分屏窄窗口 |
medium | 600–839dp | 2 | 小平板、折叠内屏、手机横屏 |
expanded | >=840dp | 3 | 大平板、展开态大窗口 |
断点不是越细越好。每增加一个断点,就要多维护一套布局与一组回归用例。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;必须读取系统或窗口值。
- 用无头模型替代真机验收。模型能证明分类逻辑,不能证明铰链动画、厂商窗口行为和渲染裁切;矩阵缺口必须显式列出。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。