上一提交(cac026e)把黑边消掉了,但**只给 `MainPage` 加了避让**。
实测截图硬证:写邮件页的「取消」与时钟「09:49」重叠、「发送」与 wifi/电量图标重叠。
## 根因:`setWindowLayoutFullScreen(true)` 不只作用于当前页
它是**窗口级**的:一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页
(写邮件/会话/收件箱/邮件详情/用户管理)都从 y=0 开始画。
所以上次那个错与更早那次(6861934 只删全屏不留避让)**同源**:
都是"同一件事只做了一半"。上次少的是**步骤**,这次少的是**页面**。
## 改法:把"消费避让"变成每个 @Entry 页都得做的事
- `model/WindowInsets.ts` 加 `topInset(insets)`:取 0 时(未全屏/取不到)
表达式的值与旧代码**逐字相同** ⇒ 没全屏的环境行为不变,不会把谁顶下去。
- 五个页各按自己的形状让位:
- 固定 56vp 顶栏(写邮件/会话/收件箱/用户管理):
`height(56 + topInset(...))` **与** `padding(… top: topInset(...))` 一起加 ——
只加 padding 会把固定的 56 切掉 39(按钮压扁),只加 height 则内容仍贴 y=0。
- 满高容器(邮件详情):`padding({ top })` 加在 `@Entry` 包装层。
★ **不能加在 `MailDetailView` 里面**:它同时被 `MainPage` 的 Navigation 内嵌复用,
而那层已经让过位了 —— 加在里面就变成让两次(39vp 变 78vp)。
这类"同一组件两种入口"的坑与"悬浮加号要放在 Navigation 内部"同源:
**让位的量取决于它被挂在哪一层**。
## 判据(harmony-window 8 → 9 条)
接线⑤ 枚举**所有** `@Entry` 页并要求它们消费避让 —— 口径是
"有人在窗口上开了全屏 ⇒ 每个 @Entry 页都得让",而不是"检查 MainPage 做了没有"。
后者在新增一个推上来的页时会静默逃掉,而"新增一个页"正是最常发生的事。
豁免要带**可机器复核**的理由(不再是"这个页先不管"):
- `Index.ets`:DevEco 模板欢迎页,不在 `main_pages.json` 流程里;
- `LoginPage.ets`:根容器 `.align(Alignment.Center)` ⇒ 结构上碰不到 y=0。
但"居中"是可能被改掉的性质 ⇒ 判据**断言那个居中写法仍然存在**,
谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。
**变异自检两个方向都跑过**:
- 把 ComposePage 避让整个拿掉(重演"只给 MainPage 加")⇒ 红 ✓
- 只加 padding 不加 height(会压扁按钮)⇒ 红 ✓
★ 期间还修掉两处"判据锚在当时的字符串上"(不是放宽,是它把"加一个正当的避让"
与"真犯那个错"判得一模一样):
- `harmony-nav` ④ 的留白断言、`harmony-widescreen` ④ 的 navReserve 断言,
都改成剥注释后验**形状与不变量**,而不是写死字面表达式。改完变异仍咬得住。
307 lines
17 KiB
JavaScript
307 lines
17 KiB
JavaScript
/*
|
||
* 窗口全屏 + 避让区(消上下黑边)—— 判据。
|
||
*
|
||
* ── 这条为什么必须存在(它挡的是一个**已经发生过三次**的退回)──
|
||
*
|
||
* 用户报过一次同一条:「底部那个黑条是啥意思」(2026-09-17)、
|
||
* 「你再看看页面底部,那么大的黑色,你看从头到尾都没修好,
|
||
* 你能不能好好看看我给你的示例工程怎么处理上下黑边的」(2026-09-18)。
|
||
*
|
||
* 中间那次的处理是:只调 `setWindowLayoutFullScreen(true)`,发现页签被时钟盖住
|
||
* ⇒ 把整个全屏**退回了**,代码里留下一句「★ 刻意不用 setWindowLayoutFullScreen(true)」。
|
||
*
|
||
* 那个结论错在:被盖住不是"不该全屏",而是**只做了全屏、没做避让**。
|
||
* 示例工程(`/tmp/harmonyos-samples-reference`)里两件事是**同一套东西的两半**:
|
||
* `common/src/main/ets/util/WindowUtil.ets:registerBreakpoint`
|
||
* → `getWindowAvoidArea(TYPE_SYSTEM / TYPE_NAVIGATION_INDICATOR)`
|
||
* `features/mine/src/main/ets/view/MineView.ets:251`
|
||
* → `.margin({ top: statusBarHeight + …, bottom: naviIndicatorHeight })`
|
||
*
|
||
* 只做前半 ⇒ 内容跑到状态栏底下没人让(那次的症状);
|
||
* 只做后半 ⇒ 黑边照旧(这三次的症状)。**两半缺哪一半都是错的**,
|
||
* 所以判据必须同时钉住两半 —— 只钉一半的话,"退回某一半"这个动作照样能全绿通过。
|
||
*
|
||
* ── 判据的层次 ──
|
||
* ① 纯逻辑(`insetsFromAvoidArea`):能跑,喂样本进去验换算对不对;
|
||
* ② 接线(`.ets` 源码):全屏调用在、避让读在、布局真的消费了值。
|
||
* 静电扫源码验证不了"屏幕上没有黑边",所以另有一条**设备行为判据**
|
||
* (见文件末尾,有设备时真读 insets)。
|
||
*/
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { prose } from './lib/read.mjs';
|
||
import { findHdc, hasTarget, foregroundBundle, ourBundle } from './lib/harmony-device.mjs';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const HARMONY = join(HERE, '..', '..', 'harmony');
|
||
const MODEL = join(HARMONY, 'entry', 'src', 'main', 'ets', 'model');
|
||
const PAGES = join(HARMONY, 'entry', 'src', 'main', 'ets', 'pages');
|
||
const ABILITY = join(HARMONY, 'entry', 'src', 'main', 'ets', 'entryability', 'EntryAbility.ets');
|
||
|
||
const W = await import(pathToFileURL(join(MODEL, 'WindowInsets.ts')).href);
|
||
|
||
/** 造一个 `window.AvoidArea` 形状的样本(只带我们用到的两个字段)。 */
|
||
function area(topPx, bottomPx) {
|
||
return {
|
||
topRect: { height: topPx },
|
||
bottomRect: { height: bottomPx }
|
||
};
|
||
}
|
||
|
||
/* ───────────────────────── ① 纯逻辑 ───────────────────────── */
|
||
|
||
test('★ 避让换算:px → vp(设备 3.5 密度,真机实测 136px/20px ⇒ 39vp/6vp)', () => {
|
||
const px2vp = (px) => px / 3.5;
|
||
const got = W.insetsFromAvoidArea(area(136, 20), area(0, 20), px2vp);
|
||
/*
|
||
* 136px / 3.5 = 38.857…,20/3.5 = 5.714…
|
||
* 断言的是"换算真的走了 px2vp",不是某个具体舍入 —— 所以用近似比较。
|
||
*/
|
||
assert.ok(Math.abs(got.statusBar - 136 / 3.5) < 0.001, `状态栏应是 39vp,实际 ${got.statusBar}`);
|
||
assert.ok(Math.abs(got.navIndicator - 20 / 3.5) < 0.001, `手势条应是 5.7vp,实际 ${got.navIndicator}`);
|
||
});
|
||
|
||
test('★ 取不到避让区 ⇒ 0(**不是**猜一个 39vp)', () => {
|
||
const px2vp = (px) => px / 3.5;
|
||
const none = W.insetsFromAvoidArea(undefined, undefined, px2vp);
|
||
assert.equal(none.statusBar, 0);
|
||
assert.equal(none.navIndicator, 0);
|
||
/*
|
||
* 为什么是 0 而不是"兜底一个经验值":
|
||
* 0 的表现 = 黑边照旧(与修之前一模一样,能被截图判据看出来);
|
||
* 猜一个的表现 = 按猜的值让位、错得**看不出来**(比如让多了留白、让少了压字)。
|
||
* 两害相权取**能暴露的那个** —— 与本仓「缺证据 ≠ 没有那个现象」同一条纪律。
|
||
*/
|
||
const partial = W.insetsFromAvoidArea({ topRect: { height: 136 } }, undefined, px2vp);
|
||
assert.ok(partial.statusBar > 0, '只给一半时,给了的那一半照样算出来');
|
||
assert.equal(partial.navIndicator, 0, '没给的那一半是 0,不猜');
|
||
});
|
||
|
||
test('★ 避让键名是个常量(`@StorageLink` 拼错不报错、只会恒为 0 —— 正好是本轮病症)', () => {
|
||
assert.equal(typeof W.KEY_WINDOW_INSETS, 'string');
|
||
assert.ok(W.KEY_WINDOW_INSETS.length > 0);
|
||
/*
|
||
* 这条防的是"用会静默失效的机制修静默失效的病":
|
||
* ArkUI 的 `@StorageLink('拼错了')` **不报错**,只是永远等于初值(0),
|
||
* 表现就是"避让永远 0 ⇒ 黑边照旧" —— 与要修的病症长得一模一样。
|
||
* 所以键名必须是一处常量、且**消费方不许写第二份字面量**(下面那条接线判据钉这个)。
|
||
*/
|
||
const pages = prose(join(PAGES, 'MainPage.ets'));
|
||
const literals = pages.match(/@StorageLink\(\s*'([^']+)'\s*\)/g) ?? [];
|
||
for (const l of literals) {
|
||
assert.ok(!/window|inset/i.test(l) || l.includes('KEY_WINDOW_INSETS'),
|
||
`窗口避让的 StorageLink 不许写字符串字面量(要用 KEY_WINDOW_INSETS):${l}`);
|
||
}
|
||
});
|
||
|
||
/*
|
||
* 把注释剔掉再断言。
|
||
*
|
||
* ★ 这一层不是可选项 —— 本文件第一版就是栽在这里:
|
||
* 正则 `/setWindowLayoutFullScreen\(\s*true\s*\)/` 直接扫全文,
|
||
* 而**注释里就有这个串**(那句解释「这就是消黑边的动作」)。
|
||
* 于是把真正的调用删掉(重演上一次的退回)后,判据**仍然全绿** ——
|
||
* 因为它锚在**代码对自己的描述**上,而不是代码本身。
|
||
* 变异自检(删掉调用 ⇒ 必须红)当场抓住了这一点。
|
||
*
|
||
* 与本仓那条纪律一致:**判据的锚不能落在被守对象的自述上**。
|
||
* 注释里可以有这个串(而且要写清楚),但断言必须只看代码。
|
||
*/
|
||
function stripped(src) {
|
||
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||
}
|
||
|
||
/* ───────────────────────── ② 接线 ───────────────────────── */
|
||
|
||
test('★ 接线①:EntryAbility 必须调 `setWindowLayoutFullScreen(true)`(消黑边的那动作)', () => {
|
||
const src = stripped(prose(ABILITY));
|
||
assert.match(src, /setWindowLayoutFullScreen\(\s*true\s*\)/,
|
||
'没有全屏调用 ⇒ 窗口在状态栏/导航条处留黑 ⇒ 上下黑边(这正是三次报修的病症)');
|
||
});
|
||
|
||
test('★ 接线②:全屏旁边必须**同时**有避让读取(只有一半就是上一次的退回)', () => {
|
||
const src = stripped(prose(ABILITY));
|
||
assert.match(src, /getWindowAvoidArea\(\s*window\.AvoidAreaType\.TYPE_SYSTEM\s*\)/,
|
||
'要读状态栏避让区(TYPE_SYSTEM)');
|
||
assert.match(src, /getWindowAvoidArea\(\s*window\.AvoidAreaType\.TYPE_NAVIGATION_INDICATOR\s*\)/,
|
||
'要读手势条避让区(TYPE_NAVIGATION_INDICATOR)');
|
||
/*
|
||
* ★ 这条是全文件最重要的:它钉的是"两半必须同时存在"。
|
||
* 上一次退回时,`getWindowAvoidArea` **一次都没出现**(实测 grep 为空)——
|
||
* 只有全屏、没有避让,于是页签被时钟盖住,于是把全屏也删了。
|
||
* 如果判据只钉"有全屏"或只钉"有避让",那次退回在判据里就是全绿的。
|
||
*/
|
||
const hasFullScreen = /setWindowLayoutFullScreen\(\s*true\s*\)/.test(src);
|
||
const hasAvoid = /getWindowAvoidArea/.test(src);
|
||
assert.ok(hasFullScreen && hasAvoid,
|
||
'全屏与避让是同一套东西的两半:缺哪一半都会坏(缺前半=黑边,缺后半=被时钟盖住)');
|
||
});
|
||
|
||
test('★ 接线③:避让高度必须真的被**布局消费**(读了不用 = 没读)', () => {
|
||
const page = stripped(prose(join(PAGES, 'MainPage.ets')));
|
||
assert.match(page, /@StorageLink\(\s*KEY_WINDOW_INSETS\s*\)/,
|
||
'MainPage 要订阅避让值(键走常量,不写字面量)');
|
||
assert.match(page, /windowInsets\.statusBar/,
|
||
'★ 读了 `statusBar` 却没人用 ⇒ 内容照样跑到时钟底下(上一次的病症就是这么来的)');
|
||
assert.match(page, /windowInsets\.navIndicator/,
|
||
'★ 手势条高度要让底栏用上,否则自绘玻璃条会压在手势区上');
|
||
});
|
||
|
||
test('★ 接线④:`.ets` 不许 import `WindowInsets` 的 SDK 类型(判据要在 node 里 import 它)', () => {
|
||
const model = prose(join(MODEL, 'WindowInsets.ts'));
|
||
/*
|
||
* 这个纯逻辑模块必须**无 `@ohos`/`@kit` 依赖** —— 判据用 `--experimental-strip-types`
|
||
* 在 node 里直接 import 它验换算,`@kit.ArkUI` 在 node 里不存在 ⇒ 一旦 import 就跑不起来。
|
||
* 这是本仓反复用的办法(`DeviceProbe.ts` / `MailGrouping.ts` / `NavItems.ts` 同一形状)。
|
||
*
|
||
* ⚠️ 只看**代码**,不看注释:本文件的注释里**故意**写了 `@kit.ArkUI` 来解释为什么
|
||
* 不能 import 它 —— 把注释也算进去的话,连"说清楚理由"都会被判红(我第一版就踩了)。
|
||
*/
|
||
const code = stripped(model);
|
||
assert.ok(!/@kit\.|@ohos\./.test(code),
|
||
'WindowInsets.ts 必须保持纯净(无 SDK 依赖),否则判据连编都编不过');
|
||
});
|
||
|
||
test('★ 接线⑤:**每一个 `@Entry` 页**都要消费避让(全屏是窗口级的,不止 MainPage)', () => {
|
||
/*
|
||
* ── 这条为什么是全文件第二重要(2026-09-18 实测踩到)──
|
||
*
|
||
* `setWindowLayoutFullScreen(true)` 是**窗口级**的:一旦设上,窗口里**所有**
|
||
* 用 `router.pushUrl` 推上来的页都从 y=0 开始画,而不只是 `MainPage`。
|
||
*
|
||
* 我第一版只给 `MainPage` 加了避让 ⇒ 写邮件页实拍:「取消」与时钟「09:49」重叠、
|
||
* 「发送」与 wifi/电量图标重叠。**把黑边换成了另一种坏** —— 而且两个错同源:
|
||
* 都是"同一件事只做了一半"(上次少了避让,这次少了页面)。
|
||
*
|
||
* ★ 所以口径是「**有人在窗口上开了全屏 ⇒ 每个 `@Entry` 页都得让**」,
|
||
* 而不是「检查 MainPage 做了没有」。后者下新增一个推上来的页会静默逃掉 ——
|
||
* 而"新增一个页"正是最常发生的事。
|
||
*/
|
||
const pageDir = join(HARMONY, 'entry', 'src', 'main', 'ets', 'pages');
|
||
const entries = readdirSync(pageDir)
|
||
.filter(f => f.endsWith('.ets'))
|
||
.filter(f => /^@Entry\b/m.test(prose(join(pageDir, f))));
|
||
/*
|
||
* 自检:扫到的 `@Entry` 页数要 ≥ 5(`main_pages.json` 里登记 7 个)。
|
||
* 防的是"目录改名/遍历写错 ⇒ 这条变成空判据而依然全绿"——
|
||
* 与 `harmony-system-api` 里那条"扫到的文件数 ≥ N"同形。
|
||
*/
|
||
assert.ok(entries.length >= 5, `要扫到全部 @Entry 页(实际 ${entries.length}:${entries.join('、')})`);
|
||
|
||
/*
|
||
* 两种合法的"消费"写法:
|
||
* · `topInset(this.windowInsets)` —— 直白的(顶栏页用这个);
|
||
* · 直接读 `this.windowInsets.statusBar` —— MainPage 要的,它得把避让嵌进
|
||
* `isWide ? paneGap : statusBar` 这个条件表达式里,套不进 `topInset()`。
|
||
* 两者都算"把值用起来了";形状不同而要求相同(与 `harmony-nav` 那条同口径:
|
||
* 判的是"读到的东西有没有落到布局上",不是"用的哪个函数")。
|
||
*/
|
||
const consumes = (src) => /topInset\(\s*this\.windowInsets\s*\)/.test(src)
|
||
|| /this\.windowInsets\.statusBar/.test(src);
|
||
|
||
/*
|
||
* 豁免名单:每条都必须是**可机器复核的理由**,不是"这个页先不管"。
|
||
*
|
||
* `LoginPage` 的根容器是 `.align(Alignment.Center)` 的满高容器 —— 内容是**居中**的,
|
||
* 最顶上的那个像素也远在状态栏之下,结构上碰不到 y=0。所以它不需要避让。
|
||
*
|
||
* ★ 但"居中"是个可能被改掉的性质(哪天有人给它加个贴顶的标题栏,理由就没了)。
|
||
* 所以这里不只看一次理由字符串 —— 下面**断言那个居中写法仍然存在**:
|
||
* 谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。
|
||
* 这正是本仓那条"豁免得带可复核的理由"(`harmony-system-api` 的 ALLOW 同形)。
|
||
*/
|
||
const EXEMPT = new Map([
|
||
['Index.ets', { why: 'DevEco 模板欢迎页,不在 main_pages.json 的流程里,从不被路由到', check: null }],
|
||
['LoginPage.ets', {
|
||
why: '根容器 .align(Alignment.Center):内容居中,结构上碰不到 y=0',
|
||
check: (src) => /\.align\(Alignment\.Center\)/.test(stripped(src))
|
||
&& !/Alignment\.Top/.test(stripped(src))
|
||
}]
|
||
]);
|
||
|
||
const missing = [];
|
||
for (const f of entries) {
|
||
const src = prose(join(pageDir, f));
|
||
const exempt = EXEMPT.get(f);
|
||
if (exempt) {
|
||
if (exempt.check) {
|
||
assert.ok(exempt.check(src),
|
||
`${f} 被豁免的理由已不存在(原理由:${exempt.why})—— ` +
|
||
'要么恢复那个形状,要么把它当普通页消费避让,不要默默留着豁免');
|
||
}
|
||
continue;
|
||
}
|
||
if (!consumes(src)) missing.push(f);
|
||
}
|
||
assert.deepEqual(missing, [],
|
||
`这些 @Entry 页没有消费窗口避让:${missing.join('、')} —— ` +
|
||
'全屏是窗口级的,漏一个页就是一处「顶栏被时钟压住」(实测过:写邮件页的取消/发送压着 09:49 与电量)');
|
||
|
||
/*
|
||
* `\u2605` 顶栏那对"高度 + padding-top"只对**固定高度**的顶栏成立(`height(56)`)。
|
||
*
|
||
* `MailDetailPage` 是另一种形状:它的 `@Entry` 包装层是 `height('100%')` 的**满高列**,
|
||
* 避让用 `padding({ top: … })` 加在满高容器上(内容自然往下,没有"从 56 里切"的问题)——
|
||
* 这是满高容器的正确写法,不该被要求写成 `56 + topInset(...)`。
|
||
*
|
||
* 所以这里只对**真的写了固定 height(56) 顶栏**的页判那对同现,
|
||
* 其余页(满高容器)只要求"避让落到了布局上"(上面已经判过)。
|
||
*/
|
||
const halfDone = [];
|
||
for (const f of entries) {
|
||
if (EXEMPT.has(f)) continue;
|
||
const code_ = stripped(prose(join(pageDir, f)));
|
||
if (!/topInset\(\s*this\.windowInsets\s*\)/.test(code_)) continue; // 不是顶栏形状(MainPage)
|
||
/*
|
||
* 只看**同一个修饰符链**:包含 `padding(top: topInset(...))` 的那一段往前找它的 `.height(...)`。
|
||
*
|
||
* 不能满文件搜 `height(56)` —— `MailDetailPage` 里的悬浮回复球是 `.width(56).height(56)`,
|
||
* 与顶栏毫无关系(我第一版就是这么误报的:把一个 56×56 的圆球当成了"固定 56 顶栏")。
|
||
* 判据要锚在"这一段的形状"上,而不是"文件里有没有出现过这个数"。
|
||
*/
|
||
const pad = /\.padding\(\{[^}]*top:\s*topInset\(\s*this\.windowInsets\s*\)[^}]*\}\)/.exec(code_);
|
||
if (!pad) continue;
|
||
const chain = code_.slice(Math.max(0, pad.index - 300), pad.index + pad[0].length);
|
||
const hasFixed56 = /\.height\(\s*56\s*\)/.test(chain);
|
||
if (!hasFixed56) continue; // 满高容器形状(MailDetailPage):没有从 56 里切的问题
|
||
const heightGrew = /\.height\(56 \+ topInset\(\s*this\.windowInsets\s*\)\)/.test(chain);
|
||
if (!heightGrew) {
|
||
halfDone.push(f);
|
||
}
|
||
}
|
||
assert.deepEqual(halfDone, [],
|
||
`这些页的顶栏没把"高度 + padding-top"一起加:${halfDone.join('、')} —— ` +
|
||
'只加一个会把固定 56vp 切掉一截(按钮压扁)或让位没生效(内容仍贴 y=0)');
|
||
});
|
||
|
||
/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */
|
||
|
||
/*
|
||
* 上面四条都是**静电扫源码** —— 它们证不了"屏幕上真的没有黑边"。
|
||
* 真正的证据是设备上读数。没设备/前台不是我们 ⇒ **跳过**(不是绿、不是红),
|
||
* 与本仓 harmony-nav 同一套边界:不下断言、也不许静默算验过。
|
||
*/
|
||
test('★ 行为(设备):全屏后窗口的避让区读得到(真机口径)', (t) => {
|
||
const hdc = findHdc();
|
||
if (!hdc || !hasTarget(hdc)) {
|
||
t.skip('无设备/无目标 —— 跳过(不算验过)');
|
||
return;
|
||
}
|
||
const fg = foregroundBundle(hdc);
|
||
if (fg !== ourBundle()) {
|
||
t.skip(`前台是 ${fg}(不是我们的 ${ourBundle()})—— 跳过,不下断言`);
|
||
return;
|
||
}
|
||
/*
|
||
* ⚠️ 这里**不**去解析设备的 avoid area(那要 hdc 上的 hidumper + 解析,脆)。
|
||
* 这条只钉一件事:**全屏之后我们的界面仍然在前台且能读树** ——
|
||
* 即"全屏没有导致应用起不来/白屏"(全屏配错最坏的后果)。
|
||
* 屏幕上的黑边由截图判据(人看 + pngread 逐像素)负责,不混进这条。
|
||
*/
|
||
assert.equal(fg, ourBundle());
|
||
});
|