10.1 安全区与刘海屏适配
卷一 9.1 栅格、间距与安全区 从规范角度定了「UI 该留多少边距」;这一节换到管线角度:给定一批真实设备参数,把安全区算成一组整数坐标。屏幕上「哪里被挡住」最终由系统与渲染器决定,本节只做美术侧的可算几何,屏幕裁剪与合成实现外链 《移动端渲染优化》 ,不重推。适配问题的形状很固定:屏幕是一块矩形,遮挡是若干块矩形,元件是若干块矩形——只要三者都能用整数表达,命中与否就退化成矩形相交,不需要渲染器,也不需要真机。
10.1.1 安全矩形是近似,刘海是硬约束
config/ta10_safearea.json 里每台设备有三组整数:screen(屏幕尺寸)、insets(四边安全区 inset,顺序 左/上/右/下)、notches(遮挡矩形 [x,y,w,h] 列表):
| 设备 | screen | insets | safe 矩形 | 刘海 |
|---|---|---|---|---|
| phone_notch | 1170×2532 | 0/132/0/102 | [0,132,1170,2298] | 1 块(顶部居中 390×132) |
| phone_punch | 1080×2400 | 0/80/0/60 | [0,80,1080,2260] | 1 块(挖孔 80×80) |
| tablet | 2048×2732 | 0/48/0/40 | [0,48,2048,2644] | 无 |
| fold_unfolded | 2208×1768 | 0/0/0/0 | [0,0,2208,1768] | 无 |
inset 是系统给的矩形近似:它把「刘海高度」折成一个上边距,但刘海本身未必恰好贴边,所以规范里两张表都要留——安全矩形负责布局,刘海矩形负责兜底断言。元件用千分比锚点定位(ax=0 贴左、1000 贴右、500 居中),放进安全矩形的可用框:x = safe.x + (safe.w - widget.w) * ax // 1000。千分比加地板除让它天然是整数:同一份锚点在 1170 宽与 2048 宽上各自算出像素坐标,没有浮点,也没有「谁先舍入」的分歧。
10.1.2 S 层:安全矩形、锚定与相交
下面这段是 S 层脚本,读配置算每台设备的安全矩形、每个元件的矩形与刘海命中数,并同时算一份「锚到全屏」的对照版:
#!/usr/bin/env python3
# file: sim/ta10_safearea.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta10_safearea.py
"""安全区与刘海屏适配的整数几何(S 层,卷二 10.1)。
屏幕减四边 inset 得安全矩形,元件按千分比锚点走整数地板除放进可用框,再对刘海做 AABB 相交兜底;
并算「锚到全屏」的对照版。坐标 / 尺寸 / 千分比 / 命中数全整数 → 与 TS 逐位一致。落盘 out/ta10_safearea.json。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta10_safearea.json")
OUT = os.path.join(ROOT, "out")
def safe_rect(screen, insets):
l, t, r, b = insets
return [l, t, screen[0] - l - r, screen[1] - t - b]
def place(box, size, anchor):
"""在可用框 box=(x,y,w,h) 内按千分比 anchor 放 size;负可用框夹到 0。整数地板除。"""
aw, ah = max(0, box[2] - size[0]), max(0, box[3] - size[1])
return [box[0] + aw * anchor[0] // 1000, box[1] + ah * anchor[1] // 1000, size[0], size[1]]
def hits(rect, notches):
"""元件矩形碰到的刘海矩形个数(AABB:两轴都重叠才算)。"""
x, y, w, h = rect
return sum(1 for nx, ny, nw, nh in notches
if x < nx + nw and nx < x + w and y < ny + nh and ny < y + h)
def main():
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
widgets = cfg["widgets"]
lines, devices, safe_total, naive_total = [], [], 0, 0
for d in cfg["devices"]:
safe = safe_rect(d["screen"], d["insets"])
full = [0, 0, d["screen"][0], d["screen"][1]]
wl, sh, nhs = [], 0, 0
for w in widgets:
r = place(safe, w["size"], w["anchor"])
nr = place(full, w["size"], w["anchor"])
h, nh = hits(r, d["notches"]), hits(nr, d["notches"])
sh += h
nhs += nh
lines.append(f"{d['id']}|{w['id']}|{r[0]},{r[1]},{r[2]},{r[3]}")
wl.append({"id": w["id"], "rect": r, "hits": h, "naiveRect": nr, "naiveHits": nh})
safe_total += sh
naive_total += nhs
devices.append({"id": d["id"], "screen": d["screen"], "safe": safe, "notches": d["notches"],
"widgets": wl, "safeHits": sh, "naiveHits": nhs})
digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]
rep = {"project": cfg["project"], "deviceCount": len(devices), "widgetCount": len(widgets),
"placements": len(lines), "safeHits": safe_total, "naiveHits": naive_total,
"devices": devices, "digest": digest}
with open(os.path.join(OUT, "ta10_safearea.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
for d in devices:
print(f" {d['id']:<14} safe={d['safe']} safeHits={d['safeHits']} naiveHits={d['naiveHits']}")
print(f"placements={len(lines)} safeHits={safe_total} naiveHits={naive_total}")
print(f"digest={digest}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta10_safearea.py:
phone_notch safe=[0, 132, 1170, 2298] safeHits=0 naiveHits=1
phone_punch safe=[0, 80, 1080, 2260] safeHits=0 naiveHits=1
tablet safe=[0, 48, 2048, 2644] safeHits=0 naiveHits=0
fold_unfolded safe=[0, 0, 2208, 1768] safeHits=0 naiveHits=0
placements=20 safeHits=0 naiveHits=2
digest=ef36f5cc0fe191d2
4 台设备 × 5 个元件 = 20 个放置点,锚到安全矩形时刘海命中为 0;同样这 5 个元件若锚到全屏,有 2 处会压到刘海。差的这 2 处就是安全区替你挡掉的东西。
10.1.3 P 层:同一套几何的 TS 内核
下面这段是 P 层内核,与 Python 逐条对应——同一个安全矩形公式、同一个千分比地板除、同一套 AABB 相交:
// file: core/src/ta10/safearea.ts
// tier: P
// verify: node --test 'core/test/ta10_*.test.ts'
// 卷二 10.1 安全区整数几何的 TS 侧实现。与 sim/ta10_safearea.py 同一套规则:安全矩形由屏幕减
// inset 得到,元件按千分比锚点走整数地板除放进可用框,再做 AABB 相交数刘海命中。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Rect = [number, number, number, number];
export type Device = { id: string; screen: number[]; insets: number[]; notches: number[][] };
export type Widget = { id: string; size: number[]; anchor: number[] };
export function safeRect(screen: number[], insets: number[]): Rect {
const [l, t, r, b] = insets;
return [l, t, screen[0] - l - r, screen[1] - t - b];
}
export function place(box: Rect, size: number[], anchor: number[]): Rect {
const availW = Math.max(0, box[2] - size[0]);
const availH = Math.max(0, box[3] - size[1]);
const x = box[0] + Math.floor((availW * anchor[0]) / 1000);
const y = box[1] + Math.floor((availH * anchor[1]) / 1000);
return [x, y, size[0], size[1]];
}
export function hits(rect: Rect, notches: number[][]): number {
const [x, y, w, h] = rect;
let n = 0;
for (const [nx, ny, nw, nh] of notches) {
if (x < nx + nw && nx < x + w && y < ny + nh && ny < y + h) n += 1;
}
return n;
}
export function placementLines(cfg: any): string[] {
const lines: string[] = [];
for (const d of cfg.devices as Device[]) {
const safe = safeRect(d.screen, d.insets);
for (const w of cfg.widgets as Widget[]) {
const r = place(safe, w.size, w.anchor);
lines.push(`${d.id}|${w.id}|${r[0]},${r[1]},${r[2]},${r[3]}`);
}
}
return lines.sort();
}
export function digest16(lines: string[]): string {
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}
P 层测试读的是 Python 落盘的 out/ta10_safearea.json(不是同一份源码),做跨语言对照。实跑输出(node --test core/test/ta10_safearea.test.ts):
✔ 安全区几何:TS 复算与 Python 产物逐位一致
✔ 每台设备的安全矩形与命中数与 Python 一致
✔ 锚定样本:phone_notch 上 toast_top 贴安全区顶边,全屏锚定会压到刘海
ℹ tests 3
ℹ pass 3
ℹ fail 0
两侧的元件矩形 digest 都是 ef36f5cc0fe191d2——整数类,逐位一致。toast_top 在 phone_notch 上被锚到 [285,132,600,80]:y 恰好等于上 inset,贴在刘海下沿,命中 0;同一元件锚到全屏是 [285,0,600,80],与刘海矩形 [390,0,390,132] 相交,命中 1。
10.1.4 W 层:浏览器里画出来
下面这段是 W 层构建脚本,用 node:module 的 stripTypeScriptTypes 把 spec.ts 与 safearea.ts 擦除类型后内联进单文件 HTML,为每台设备画一张全分辨率画布:
// file: web/build_ta10_safearea.mjs
// tier: W
// verify: node web/build_ta10_safearea.mjs
// 卷二 10.1 W 层:把 core/src/sp00/spec.ts + ta10/safearea.ts 擦除类型后内联进单文件 HTML。
// 每台设备一张全分辨率画布:安全矩形填 surface、刘海填 danger、元件填 accent。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta10_safearea.json"), "utf8"));
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
.replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "").replace(/^export\s+/gm, "");
const js = [strip("core/src/sp00/spec.ts"), strip("core/src/ta10/safearea.ts")].join("\n");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>prism 安全区适配 · ${cfg.project}</title>
<style>body{margin:0;padding:20px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui}
#grid{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start}.dev{background:#252f3b;border:1px solid #2d3a47;border-radius:6px;padding:8px}canvas{display:block;max-width:210px;border:1px solid #3d4a57;image-rendering:pixelated}.cap{margin-top:6px;font:11px/1.5 ui-monospace,monospace;color:#94a3b8;max-width:210px}</style></head><body>
<h1>安全区与刘海屏适配看板</h1>
<div style="color:#94a3b8;font-size:12px">安全矩形填 surface · 刘海填 danger · 元件按千分比锚点填 accent</div>
<div id="grid"></div>
<script type="module">
${js}
const SPEC = ${JSON.stringify(spec)};
const CFG = ${JSON.stringify(cfg)};
const rgb = (t) => "rgb(" + t.join(",") + ")";
const placements = [], devices = {};
for (const d of CFG.devices) {
const safe = safeRect(d.screen, d.insets);
const cv = document.createElement("canvas");
cv.width = d.screen[0]; cv.height = d.screen[1]; cv.id = "cv_" + d.id;
const ctx = cv.getContext("2d");
ctx.fillStyle = rgb(SPEC.tokens.bg); ctx.fillRect(0, 0, d.screen[0], d.screen[1]);
ctx.fillStyle = rgb(SPEC.tokens.surface); ctx.fillRect(safe[0], safe[1], safe[2], safe[3]);
const ws = []; let safeHits = 0;
for (const w of CFG.widgets) {
const r = place(safe, w.size, w.anchor); const h = hits(r, d.notches);
safeHits += h; placements.push(d.id + "|" + w.id + "|" + r.join(","));
ws.push({ id: w.id, rect: r, hits: h });
ctx.fillStyle = rgb(SPEC.tokens.accent); ctx.fillRect(r[0], r[1], r[2], r[3]);
}
for (const n of d.notches) { ctx.fillStyle = rgb(SPEC.tokens.danger); ctx.fillRect(n[0], n[1], n[2], n[3]); }
const wrap = document.createElement("div"); wrap.className = "dev";
const cap = document.createElement("div"); cap.className = "cap";
cap.textContent = d.id + " " + d.screen.join("x") + " · safe " + safe.join(",") + " · hits " + safeHits;
wrap.append(cv, cap); document.getElementById("grid").append(wrap);
devices[d.id] = { safe, notches: d.notches, widgets: ws, safeHits };
}
globalThis.__prism = { placements, devices, tokens: SPEC.tokens };
document.title = "prism ta10 safearea ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/ta10_safearea.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/ta10_safearea.html (${Buffer.byteLength(html)} bytes)`);
下面这段是 W 层断言脚本,用无头 Chromium 打开看板,采样 globalThis.__prism 与 Python 落盘的 JSON 对照,再按像素验证三层颜色:
// file: web/verify_ta10_safearea.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_safearea.mjs
// 卷二 10.1 W 层断言:无头 Chromium 打开安全区看板,采样 globalThis.__prism 与 Python 落盘的
// out/ta10_safearea.json 对照;再按像素验证安全区 / 刘海 / 元件三层颜色。
import { createRequire } from "node:module";
import { createHash } from "node:crypto";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta10_safearea.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/ta10_safearea.html")).href;
mkdirSync(resolve(ROOT, "out/shots"), { recursive: true });
let pass = 0, fail = 0;
const check = (n, ok, extra = "") => { ok ? (pass += 1) : (fail += 1); console.log(`${ok ? "pass" : "fail"} ${n}${extra ? " " + extra : ""}`); };
const browser = await chromium.launch({ headless: true });
const warm = await browser.newPage(); // 冷启动预热:首个 page 读 canvas 恒空白
await warm.goto(pageUrl);
await warm.close();
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(pageUrl);
await page.waitForFunction(() => globalThis.__prism !== undefined, null, { timeout: 15000 });
const s = await page.evaluate(() => ({ placements: globalThis.__prism.placements, devices: globalThis.__prism.devices }));
check("无 pageerror", errors.length === 0, errors.join("|"));
const digest = createHash("sha256").update(s.placements.slice().sort().join("\n"), "utf8").digest("hex").slice(0, 16);
check("元件矩形 digest 与 Python 逐位一致", digest === py.digest, `${digest}`);
const flat = Object.values(s.devices).flatMap((d) => d.widgets.map((w) => [w.id, w.rect, w.hits]));
const flatPy = py.devices.flatMap((d) => d.widgets.map((w) => [w.id, w.rect, w.hits]));
check("设备数与全部元件矩形 / 命中数与 Python 逐位一致",
Object.keys(s.devices).length === py.deviceCount && JSON.stringify(flat) === JSON.stringify(flatPy), `${py.deviceCount}`);
check("安全区命中总数 = 0", py.safeHits === 0 && Object.values(s.devices).reduce((n, d) => n + d.safeHits, 0) === 0);
const px = await page.evaluate(({ T }) => { const ctx = document.getElementById("cv_phone_notch").getContext("2d");
const at = (x, y) => { const d = ctx.getImageData(x, y, 1, 1).data; return [d[0], d[1], d[2]]; };
return { widget: at(585, 172), notch: at(585, 66), safe: at(585, 600), outside: at(100, 60), T }; }, { T: spec.tokens });
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
check("元件中心 = accent 色、刘海中心 = danger 色", same(px.widget, px.T.accent) && same(px.notch, px.T.danger), `${JSON.stringify(px.widget)} ${JSON.stringify(px.notch)}`);
check("安全区空白 = surface 色、安全区之外 = bg 色", same(px.safe, px.T.surface) && same(px.outside, px.T.bg), `${JSON.stringify(px.safe)} ${JSON.stringify(px.outside)}`);
const drawn = await page.evaluate(() => { const cv = document.getElementById("cv_phone_notch");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data; let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n += 1; return n; });
check("canvas 已绘制", drawn >= 1, `${drawn} px`);
const shot = resolve(ROOT, "out/shots/ta10_safearea.png");
await page.screenshot({ path: shot, fullPage: true });
await browser.close();
check("截图非空", statSync(shot).size > 0, `${statSync(shot).size} bytes`);
console.log(`ℹ pass ${pass}`);
console.log(`ℹ fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
实跑 node web/build_ta10_safearea.mjs 后接 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_safearea.mjs:
built web/dist/ta10_safearea.html (9159 bytes)
pass 无 pageerror
pass 元件矩形 digest 与 Python 逐位一致 ef36f5cc0fe191d2
pass 设备数与全部元件矩形 / 命中数与 Python 逐位一致 4
pass 安全区命中总数 = 0
pass 元件中心 = accent 色、刘海中心 = danger 色 [245,158,11] [239,68,68]
pass 安全区空白 = surface 色、安全区之外 = bg 色 [45,58,71] [31,41,51]
pass canvas 已绘制 2962440 px
pass 截图非空 39658 bytes
ℹ pass 8
ℹ fail 0
8 项全绿。像素采样比对的是 visual_spec.json 的 sRGB 8-bit token(accent [245,158,11]、danger [239,68,68]、surface [45,58,71]、bg [31,41,51])——整数三元组,采样值与之逐位一致。canvas 已绘制 只断言「≥ 1 px」:无头 Chromium 冷启动的首个 page 读 canvas 会恒为空白,故脚本先开一个预热页走一遍再关掉。
10.1.5 三条边界
- 安全矩形是近似,刘海矩形才是硬约束。系统给的 inset 是把遮挡折成边距的经验值;挖孔、圆角、手势条未必被矩形 inset 完全覆盖,故两张表都要留,并用相交检查兜底。
- 本节只算几何,不算渲染。真机上「安全区到底是多少」由系统 API 给出,真截图回归要在真实引擎里跑——需真实环境(设备 / 引擎),命令行不可复现。
- 锚点用千分比而非百分比浮点,是为了让两侧的地板除逐位对齐;写成
0.5会引入「谁先舍入」的分歧,而500 // 1000没有歧义。
阅读导航:上一节:9.3 动画事件与逻辑一致性 · 下一节:10.2 多分辨率资源分级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。