From cac026e9e2e9f22444c434b6930794570d460864 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 09:47:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E9=BB=91=E8=BE=B9=E7=9C=9F=E7=9A=84=E6=B6=88=E4=BA=86=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=85=A8=E5=B1=8F=20+=20=E9=81=BF?= =?UTF-8?q?=E8=AE=A9=E6=98=AF"=E5=90=8C=E4=B8=80=E5=A5=97=E4=B8=9C?= =?UTF-8?q?=E8=A5=BF=E7=9A=84=E4=B8=A4=E5=8D=8A"=EF=BC=8C=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E5=8F=AA=E5=88=A0=E4=BA=86=E4=B8=80=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户第三次报同一条:「你再看看页面底部,那么大的黑色,你看从头到尾都没 修好,你能不能好好看看我给你的示例工程怎么处理上下黑边的」。 ## 根因:上一次把"两半"当成了"一件事",删掉一半就以为修好了 `6861934` 的注释白纸黑字写着「★ **刻意不用** `setWindowLayoutFullScreen(true)`」, 理由是「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏 被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)」。 那次实测**是真的**,结论**下错了**:被盖住不是"不该全屏",而是 **只做了全屏、没做避让**。示例工程里这两件事本来就是**同一套东西的两半**: common/.../util/WindowUtil.ets → setWindowLayoutFullScreen(true) + getWindowAvoidArea(TYPE_SYSTEM / TYPE_NAVIGATION_INDICATOR) features/mine/.../view/MineView.ets:251 → .margin({ top: statusBarHeight + …, bottom: naviIndicatorHeight }) 只做前半 ⇒ 内容跑到状态栏底下没人让(那次退回的原因); 只做后半 ⇒ 黑边照旧(这三次报修的原因)。退回的代价是**黑边留了三天**。 ## 实测(模拟器 1256x2760,四页一致) 修前:顶部纯黑 136px、底部纯黑 60px + 手势条 20px 修后:四页**纯黑段均为 0**;y=0..135 是壁纸(时钟浮在上面,正是示例工程的效果) y=2662+ 壁纸铺到底、底栏浮在手势区之上 ## 改了什么 - `entryability/EntryAbility.ets`:拆出 `setupFullScreenWindow()`, 在 `loadContent` **回调里**调(与示例工程同一位置 —— `px2vp` 要用 `getUIContext()`, 那要有已加载内容才拿得到)。全屏 + 读两个避让区 + 订 `avoidAreaChange`。 `setWindowSystemBarEnable(['status'])` 保留(状态栏要看得见),但**不再靠它**消黑边。 - `model/WindowInsets.ts`(新):纯逻辑 `insetsFromAvoidArea()`,不 import SDK —— 判据才能在 node 里直接喂样本验换算。形参叫 `toVp` 而**不是** `px2vp`: 后者是 SDK 已废弃的全局函数名,`harmony-system-api` 按名字扫,同名形参会误报。 - `pages/MainPage.ets`:`@StorageLink(KEY_WINDOW_INSETS)` 订阅;状态栏高度当 **内容层**的 `padding-top`(**不是**根 Stack —— 壁纸必须铺到屏幕四边,根上加 padding 会把壁纸一起缩进去,黑边只是换个地方出现);底栏与内容末尾让开手势条。 reserve 收成**一个** `recomputeNavReserve()`,宽度变化与避让变化两个触发点共用 (转屏只改避让不改宽度,各写一份迟早漏一个)。 ## 判据:`test/harmony-window.test.mjs`(8 条) 钉的是"两半必须同时存在"——**只钉一半的话,"退回某一半"照样能全绿通过**, 而那次退回恰恰就是删了一半。纯逻辑 3 条(换算/取不到就是 0 不猜/键名是常量) + 接线 4 条(全屏在、避让在、布局真消费了值、纯逻辑模块不许 import SDK) + 设备行为 1 条(全屏没把应用搞成白屏)。 **变异自检两个方向都跑过**(这是本轮最该记的一步): - 删掉 `setWindowLayoutFullScreen`(重演 6861934)⇒ 2 条红 ✓ - 删掉 `getWindowAvoidArea`(只全屏不让位)⇒ 1 条红 ✓ ★ 第一版判据**锚错了**:正则直接扫全文,而注释里正好有 `setWindowLayoutFullScreen(true)` 这个串 —— 把真正的调用删掉后判据**仍然全绿**。锚落在"代码对自己的描述"上了。 加 `stripped()` 剥注释后,变异才咬得住。这条与仓里那条"判据的锚不能落在 被守对象的自述上"是同一件事,这次是它的实例。 ## 顺带修的两条既有判据(不是放宽,是它们把"当时的字符串"当成了"要守的坑") - `harmony-nav` ④:留白断言写死了 `bottom: NAV_BAR_BOTTOM` 那个字面串。 它本来要守的是"留白靠 padding 不靠 margin"(margin 在 ArkUI 里加在宽度外面, 100% + margin 会顶出父容器)—— 那是另一件事。改成剥注释后验三段在不在、 离底是否**从** `NAV_BAR_BOTTOM` **起**。变异(padding→margin)仍判红 ✓ - `harmony-widescreen` ④:同上,写死了 `? 0 : NAV_CONTENT_RESERVE`。 改成"宽屏必为 0、窄屏含 NAV_CONTENT_RESERVE(可再加避让)"。 两处都是"加一个正当的避让"与"真犯那个错"会红得一模一样 —— 那就不再是守坑, 是守字符串。 另:`align-refs` / `build-stamp` / `packaging` 三条 broken 是前端 `99a2d7a` (另一个人改的 `CalendarView.tsx`)带来的,与本轮无关,留给他。 --- client/electron/test/harmony-nav.test.mjs | 14 +- .../electron/test/harmony-widescreen.test.mjs | 14 +- client/electron/test/harmony-window.test.mjs | 193 ++++++++++++++++++ client/electron/test/run-all.mjs | 5 + .../main/ets/entryability/EntryAbility.ets | 107 ++++++++-- .../entry/src/main/ets/model/WindowInsets.ts | 122 +++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 69 ++++++- 7 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 client/electron/test/harmony-window.test.mjs create mode 100644 client/harmony/entry/src/main/ets/model/WindowInsets.ts diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index be78944..8a4e31a 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -241,10 +241,20 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质), * 1256(应各留 16vp=56px)⇒ 看起来是**贴边通栏**,不是浮在壁纸上的胶囊, * 玻璃材质也随之失去意义(背后没有内容/壁纸可透)。 * 这与收件箱头卡片修过的是同一个坑。 + * ★ 2026-09-18:`bottom` 现在多加了 `windowInsets.navIndicator`(全屏后要让开系统 + * 手势条,见 `harmony-window.test.mjs`)。所以断言改成:**剥注释后**看这三段在不在、 + * 左右是不是 `NAV_BAR_SIDE`、离底是不是**从 `NAV_BAR_BOTTOM` 起的**。 + * + * 为什么不直接写死 `bottom: NAV_BAR_BOTTOM` 那个字面串:那条正则本来就在断言 + * "留白靠 padding(不是 margin)",而"离底留白多少"是另一件事(那件事由 + * `NAV_BAR_BOTTOM` 的取值和 `harmony-window` 的避让判据各自守)。 + * 把它写死会让"加一个正当的避让"和"改用 margin"红得一模一样 —— + * 那这条判据就不再是"守着那个坑",而是"守着当时的那个字符串"。 */ - assert.match(bar, /\.padding\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/, + const barCode = stripComments(bar); + assert.match(barCode, /\.padding\(\{[^}]*left:\s*NAV_BAR_SIDE[^}]*right:\s*NAV_BAR_SIDE[^}]*bottom:\s*NAV_BAR_BOTTOM/, '留白要用外层容器的 padding(左右 + 离底)—— width(100%) + margin 在 ArkUI 里不缩宽,会顶出父容器'); - assert.ok(!/\.margin\(\{[^}]*NAV_BAR_SIDE/.test(bar), + assert.ok(!/\.margin\(\{[^}]*NAV_BAR_SIDE/.test(barCode), '★ 不许再用 margin 做留白:ArkUI 的 margin 加在宽度外面,100% 宽度 + margin 会撑出屏幕边界'); assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)'); assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index 4e2cfb2..d525399 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -91,9 +91,19 @@ test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768 * ★ 2026-09-17:让位方式改了 —— 宽屏不再靠"内容 padding 归零", * 而是宽屏时 navReserve 本身变成 0(没有底部条)。 * 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。 + * + * ★ 2026-09-18:窄屏那一支多加了 `windowInsets.navIndicator`(全屏后要让开系统 + * 手势条,见 `harmony-window.test.mjs`)。所以断言改为:**宽屏一定是 0**, + * 窄屏是 `NAV_CONTENT_RESERVE` **加上避让**(不是写死那个字面表达式)。 + * 写死的话,"加一个正当的避让"与"宽屏忘了归零"会红得一模一样 —— + * 而这条判据要守的是后者。 */ - assert.match(code_, /this\.navReserve = this\.isWide \? 0 : NAV_CONTENT_RESERVE/, - '宽屏时 navReserve 要归零(没有底部条,列表不需要为它让位)'); + const reserve = /this\.navReserve = this\.isWide \? 0 : ([^;]+);/.exec(code_); + assert.ok(reserve, '要按 `isWide ? 0 : <窄屏值>` 的开关式写 navReserve'); + const narrowExpr = reserve[1].trim(); + assert.notEqual(narrowExpr, '0', '窄屏不能归零(窄屏有底部条,列表要让位)'); + assert.match(narrowExpr, /NAV_CONTENT_RESERVE/, + '窄屏要让条高那么大的位(至少含 NAV_CONTENT_RESERVE)'); }); test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => { diff --git a/client/electron/test/harmony-window.test.mjs b/client/electron/test/harmony-window.test.mjs new file mode 100644 index 0000000..cf5003d --- /dev/null +++ b/client/electron/test/harmony-window.test.mjs @@ -0,0 +1,193 @@ +/* + * 窗口全屏 + 避让区(消上下黑边)—— 判据。 + * + * ── 这条为什么必须存在(它挡的是一个**已经发生过三次**的退回)── + * + * 用户报过一次同一条:「底部那个黑条是啥意思」(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 { 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 依赖),否则判据连编都编不过'); +}); + +/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */ + +/* + * 上面四条都是**静电扫源码** —— 它们证不了"屏幕上真的没有黑边"。 + * 真正的证据是设备上读数。没设备/前台不是我们 ⇒ **跳过**(不是绿、不是红), + * 与本仓 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()); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index e291f7f..5bc53e2 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -78,6 +78,11 @@ const SUITE = [ ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25], // P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位 ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18], + // 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半: + // `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。 + // 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天); + // 只做后半 ⇒ 黑边照旧。**两半缺哪一半都要判红**,所以变异自检两个方向都跑过。 + ['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8], // 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号 ['test/appearance-defaults.test.mjs', [], 4], ['test/build-stamp.test.mjs', [], 7], diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index 90a4ffa..b8380b7 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -16,9 +16,11 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; import { ApiClient } from '../api/ApiClient'; import { PushService } from '../api/PushService'; import { NotificationLedger } from '../model/PushContract'; +import { Insets, KEY_WINDOW_INSETS, insetsFromAvoidArea } from '../model/WindowInsets'; const DOMAIN = 0x0000; @@ -102,41 +104,102 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); /* - * ★ 底部黑带 = 系统的**导航栏区域**(手势条),不是我们画的。 + * ★ 窗口配置(全屏 + 避让)改到 loadContent 之后 —— 见 setupFullScreenWindow。 * - * 症状(用户 2026-09-17 真机截图):屏幕最下面一条纯黑。 - * 根因:窗口默认给系统导航条留了位置,而那块区域在深色主题下是黑的; - * 界面于是看起来底下多出一条黑带,壁纸/面板到不了屏幕底部。 + * ── 这里曾经写着「刻意不用 setWindowLayoutFullScreen(true)」并记了一个**错误的结论** ── + * 原文:「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏被 + * 时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)。」 * - * 做法:**隐藏系统导航条**(`setWindowSystemBarEnable(['status'])`)—— - * 底部那条本来就是我们自绘的悬浮玻璃条(用户要求「悬浮 + 四周留白」), - * 系统那条黑底手势条不是设计的一部分。隐藏后内容铺满全高(到屏幕底)。 + * 那次实测本身是真的,**结论下错了**:被盖住不是"不该全屏",而是 + * **只做了全屏、没做避让**。示例工程(`/tmp/harmonyos-samples-reference`)的 + * `WindowUtil` 里,`setWindowLayoutFullScreen` 与 `getWindowAvoidArea` 是 + * **同一套东西的两半** —— 少了后一半,前一半当然是灾难。 * - * ★ **刻意不用 `setWindowLayoutFullScreen(true)`。** - * 实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局, - * 于是页签栏被时钟/电量盖住(截图硬证:「07:43」与「收件箱」重叠)。 - * 而我们并不需要内容跑到状态栏底下 —— 那要多一层 AvoidArea 避让才收得住, - * 为一个没人要求的效果引入一层布局风险不划算。 - * 状态栏**保留**(时间/电量要看得见),且它占的位置由系统留好。 + * 而那次退回的代价是**黑边一直在**(用户 2026-09-17 报、2026-09-18 又报 + * 「你看从头到尾都没修好」)。2026-09-18 实测 1256x2760 四页一致: + * 顶部纯黑 136px、底部 60px + 手势条 20px。 + * + * 现在回到示例工程的做法:全屏 + 读避让 + 布局让位(三处配套,见 setupFullScreenWindow)。 */ - try { - const win: window.Window = windowStage.getMainWindowSync(); - win.setWindowSystemBarEnable(['status']).catch((err: Object) => { - hilog.error(DOMAIN, 'testTag', 'setWindowSystemBarEnable failed: %{public}s', JSON.stringify(err)); - }); - } catch (err) { - hilog.error(DOMAIN, 'testTag', 'window setup failed: %{public}s', JSON.stringify(err)); - } - windowStage.loadContent('pages/LoginPage', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + /* + * ★ 在 loadContent **之后**才配窗口(与示例工程同一位置): + * 示例 `BaseAbilityHelper.doOnWindowStageCreate` 在 loadContent 回调里调 + * `WindowUtil.initialize(windowStage)`。 + * 理由:`getUIContext()`(`px2vp` 要用)要有已加载的内容才拿得到。 + */ + this.setupFullScreenWindow(windowStage); }); } + /** + * 全屏布局 + 读出避让区 —— 消掉上下黑边的**两半**。 + * + * ① `setWindowLayoutFullScreen(true)`:内容铺到屏幕四边。**这就是**消黑边的动作。 + * ② `getWindowAvoidArea`:读出被状态栏/导航条遮住的高度,写进 AppStorage。 + * ③ `MainPage` 根容器把它当 padding 的 top/bottom 用 ⇒ 内容让开时钟/手势区。 + * + * 少了 ②③,① 会让页签被时钟盖住(这正是上一次退回的原因); + * 少了 ①,黑边就一直在(这正是三次报修的原因)。 + */ + private setupFullScreenWindow(windowStage: window.WindowStage): void { + let win: window.Window; + try { + win = windowStage.getMainWindowSync(); + } catch (err) { + hilog.error(DOMAIN, 'testTag', '取主窗口失败,全屏/避让配置跳过:%{public}s', JSON.stringify(err)); + return; + } + /* ── ① 全屏 ── */ + win.setWindowLayoutFullScreen(true).then(() => { + hilog.info(DOMAIN, 'testTag', 'setWindowLayoutFullScreen(true) ok'); + }).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'setWindowLayoutFullScreen failed: %{public}s', JSON.stringify(err)); + }); + /* + * 状态栏**保留可见**(时间/电量要看得见),只是内容铺到它底下。 + * 下面这条 `setWindowSystemBarEnable(['status'])` 是上一位留下的: + * 它对状态栏仍有效,且留着不会更糟 —— 但它**不是**消黑边的手段 + * (实测没能消掉底部那 60px + 20px)。一并保留,不再靠它。 + */ + win.setWindowSystemBarEnable(['status']).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'setWindowSystemBarEnable failed: %{public}s', JSON.stringify(err)); + }); + /* ── ② 读避让 + 监听变化 ── */ + this.publishInsets(win); + try { + win.on('avoidAreaChange', () => { this.publishInsets(win); }); + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'avoidAreaChange 订阅失败:%{public}s', JSON.stringify(err)); + } + } + + /** + * 读一次避让区并写进 `AppStorage`(键 `KEY_WINDOW_INSETS`)。 + * + * ★ 换算必须用 `win.getUIContext().px2vp`,**不能**用全局 `px2vp()` —— + * 全局那个已被 SDK 标 `@deprecated`,判据 `harmony-system-api` 对全局调用默认判红 + * (本仓已踩过这次:`harmony-system-api.test.mjs`)。 + */ + private publishInsets(win: window.Window): void { + try { + const system: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); + const navIndicator: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); + const uiContext = win.getUIContext(); + const next: Insets = insetsFromAvoidArea(system, navIndicator, (px: number) => uiContext.px2vp(px)); + AppStorage.setOrCreate(KEY_WINDOW_INSETS, next); + hilog.info(DOMAIN, 'testTag', 'insets: statusBar=%{public}d navIndicator=%{public}d', + next.statusBar, next.navIndicator); + } catch (err) { + hilog.error(DOMAIN, 'testTag', '读避让区失败(保持默认 0,黑边照旧):%{public}s', JSON.stringify(err)); + } + } + onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); diff --git a/client/harmony/entry/src/main/ets/model/WindowInsets.ts b/client/harmony/entry/src/main/ets/model/WindowInsets.ts new file mode 100644 index 0000000..27570f1 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/WindowInsets.ts @@ -0,0 +1,122 @@ +/* + * 窗口避让区(safe area / avoid area)—— 全屏布局的另一半。 + * + * ── 为什么需要这个文件(2026-09-18 用户第三次报同一条)── + * + * 用户原话:「你再看看页面底部,那么大的黑色,你看从头到尾都没修好, + * 你能不能好好看看我给你的示例工程怎么处理上下黑边的」。 + * + * 实测(模拟器 1256x2760 @3.5 ⇒ 359vp 宽)四页一致: + * · 顶部纯黑 y=0..135 (136px ≈ 39vp,状态栏) + * · 底部纯黑 y=2662..2721 (60px ≈ 17vp) + * · 底部纯黑 y=2740..2759 (20px ≈ 6vp,手势条) + * 中间 y=2724..2736 是 rgb(34,34,34) —— 系统导航栏本身,不是我们的内容。 + * + * ── 前一次为什么没修好(这是关键,别再退回)── + * + * `EntryAbility` 里原本写着: + * 「★ 刻意不用 setWindowLayoutFullScreen(true)。实测过:它确实也消掉黑带, + * 但会连状态栏区域一起吃进布局,页签栏被时钟/电量盖住(截图硬证「07:43」 + * 与「收件箱」重叠)。」 + * + * 那段经历是真的,**但结论下错了**:被盖住不是"不该全屏",而是"只做了全屏、 + * 没做避让"。示例工程(`/tmp/harmonyos-samples-reference`)的做法是**两件事一起**: + * + * ① `setWindowLayoutFullScreen(true)` 窗口铺满(内容延伸到状态栏/导航条底下) + * ② `getWindowAvoidArea(TYPE_SYSTEM / + * TYPE_NAVIGATION_INDICATOR)` 读出上下被系统遮住的高度 + * ③ 布局里 `top: statusBarHeight` / + * `bottom: naviIndicatorHeight` 把这高度当内边距用 + * + * 只做 ① ⇒ 内容跑到状态栏底下且**没人往后让** ⇒ 被时钟盖住(正是当时看到的现象)。 + * 于是那次把 ① 整个退回了 —— 连带把"黑边消失"也退掉了,因为 ① 才是消黑边的那个动作。 + * + * 示例工程里这套在 `common/src/main/ets/util/WindowUtil.ets`(`registerBreakpoint` + * 监听 `avoidAreaChange`)与 `features/mine/.../MineView.ets:251`(消费端): + * `.margin({ top: statusBarHeight + NAVIGATION_HEIGHT, bottom: naviIndicatorHeight })` + * + * ── 本工程的做法 ── + * + * 与示例工程同构,但不引入它的 `GlobalInfoModel`/`AppStorage` 那一套: + * 避让高度是**窗口级**的量,直接放 `@StorageLink` 让页面读(页面切换/横竖屏都跟着变)。 + * 「读」在 `EntryAbility.onWindowStageCreate` 里做(`WindowUtil.initialize` 的位置), + * 「用」在 `MainPage` 的根容器(唯一根,改动面最小)。 + * + * ★ 与"隐藏系统导航条"(`setWindowSystemBarEnable(['status'])`)的关系: + * 那个调用**留不住** —— 实测它没能消掉底部黑边(60px + 20px 都还在)。 + * 而且语义上它是在跟系统讨"请别画导航条",而全屏 + 避让是**我们自己在内容里让位**, + * 不依赖系统肯不肯隐藏。所以现在以全屏 + 避让为准,那条调用留着(它对状态栏仍有效, + * 且退回旧行为时不会更糟),但它不再是消黑边的手段。 + */ + +/** + * 一条边被系统遮住的高度(vp)。 + * + * 用 class 而不是 interface:判据里要能 `new Insets()` 拿默认值, + * 且 ArkTS 对"对象字面量必须能对应到具名类型"要求严格, + * class 带默认字段写起来最省事(示例工程也是 class + AppStorage)。 + */ +export class Insets { + /** 状态栏高度(vp)。全屏后顶部这段是被时钟/电量占住的,内容要从它下面开始 */ + statusBar: number = 0; + /** 底部手势条/导航条高度(vp)。全屏后这段要让出来,否则最后一行压在系统手势区 */ + navIndicator: number = 0; +} + +/** + * 读数用的键名。 + * + * 抽成常量而不是各处写字面量:`@StorageLink('xxx')` 拼错**不会报错**, + * 只会恒等于默认值(表现为"避让永远是 0,黑边照旧")—— 那正是这次要修的病症, + * 不能用一个拼错了就不声不响的机制来修它。判据钉这个常量。 + */ +export const KEY_WINDOW_INSETS: string = 'agentmail.window.insets'; + +/** + * 把 SDK 给的 `window.AvoidArea` 转成 vp 的 `Insets`。 + * + * 为什么单独一个纯函数:换算要用 `UIContext.px2vp`(全局 `px2vp` 已废弃, + * 判据 `harmony-system-api` 对全局调用默认判红),而 `UIContext` 要传进来 —— + * 这样这个函数**不依赖窗口对象**,可以在判据里直接喂样本验。 + * + * ★ 形参叫 `toVp` 而**不叫** `px2vp`:后者是 SDK 已废弃的**全局函数名**, + * 而 `harmony-system-api` 那条判据是按名字扫源码的(它认的是"调了废弃的全局函数")—— + * 一个同名形参会被它读成调用。名字改成 `toVp` 后,语义一样而扫描不再误报。 + * (实测:我第一版就叫 `px2vp`,那条判据直接判红 `WindowInsets.ts: px2vp`。) + * + * 形参用**窄接口**而不是 import SDK 类型:判据用 `--experimental-strip-types` 跑纯 `.ts`, + * **没有** ArkUI 运行时可以 import(`@kit.ArkUI` 在 node 里不存在)⇒ 若这里 import, + * 判据就连编都编不过。这是本仓反复用的办法:纯逻辑与 SDK 隔离,SDK 那侧只留薄薄一层接线。 + */ +export function insetsFromAvoidArea( + system: AvoidAreaLike | undefined, + navIndicator: AvoidAreaLike | undefined, + toVp: (px: number) => number +): Insets { + const out: Insets = new Insets(); + /* + * 取不到就留 0 —— **不是**"猜一个 39vp"。0 的表现是"黑边照旧"(与修之前一样), + * 猜一个的表现是"按猜的值让位、错得看不出来"。两害相权取能暴露的那个。 + */ + const sysTop: number = system?.topRect?.height ?? 0; + const navBottom: number = navIndicator?.bottomRect?.height ?? 0; + out.statusBar = sysTop > 0 ? toVp(sysTop) : 0; + out.navIndicator = navBottom > 0 ? toVp(navBottom) : 0; + return out; +} + +/** + * `window.AvoidArea` 里我们真正要用的那部分形状。 + * + * 只声明用到的字段(`topRect`/`bottomRect` 的 `height`),不搬整个 SDK 类型 —— + * 判据要构造样本喂进 `insetsFromAvoidArea`,形状越窄越好构造、也越不容易把 + * "我们其实没读的字段"写成契约。 + */ +export interface InsetsRect { + height: number; +} + +export interface AvoidAreaLike { + topRect?: InsetsRect; + bottomRect?: InsetsRect; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index c711695..0f142b0 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -71,6 +71,7 @@ import { PushService, PushRoute } from '../api/PushService'; import { CalendarPage } from './CalendarPage'; import { SettingsPane } from './SettingsPage'; import { LengthMetrics } from '@kit.ArkUI'; +import { Insets, KEY_WINDOW_INSETS } from '../model/WindowInsets'; import { LIST_FADE_LENGTH, NAV_BAR_BOTTOM, @@ -1829,6 +1830,19 @@ struct MainPage { * 那是真的;但文档里把它列成"未验渲染",听着像已经画出来了,这是我说得比证据强,已改)。 * 现在补上:预设档画渐变、图片档画图 + 压暗。 */ + /** + * 窗口避让区(vp)—— 由 `EntryAbility.setupFullScreenWindow` 读窗口写进来。 + * + * ★ 全屏布局的**另一半**:`setWindowLayoutFullScreen(true)` 让内容铺到屏幕四边 + * (黑边因此消失),代价是内容会跑到状态栏/手势条底下 —— 这个值就是用来让开的。 + * 少了它,页签会被时钟盖住(上一次退回的原因); + * 少了全屏,黑边就一直在(用户三次报修的原因)。 + * + * `@StorageLink` 而不是 `@StorageProp`:跟随变化(横竖屏/折叠改避让高度)。 + * 键名走 `KEY_WINDOW_INSETS` 常量 —— `@StorageLink('xxx')` 拼错不报错、只会恒为默认 0, + * 那正好是本轮要修的病症(避让永远 0 = 黑边照旧),不能用会静默失效的写法。 + */ + @StorageLink(KEY_WINDOW_INSETS) @Watch('recomputeNavReserve') windowInsets: Insets = new Insets(); @State bgPlan: BackgroundPlan = new BackgroundPlan(); /** * 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。 @@ -1847,6 +1861,11 @@ struct MainPage { * ★ 这个值加在滚动容器的 `contentEndOffset`,**不是**加在窗格的 padding 上: * 窗格必须**满高**,内容才滑得到条底下 —— 否则玻璃条背后只剩壁纸, * 系统材质无东西可糊,看起来就是一块普通浅色面板(用户 2026-09-17 的反馈)。 + * + * ★ 它由 `recomputeNavReserve()` **一处**算出来,两个触发点都调它: + * ① 窗口宽度变(`onAreaChange` → isWide 可能变); + * ② 避让区变(`@Watch` 订到 windowInsets —— **横竖屏/折叠会只改避让不改宽度**, + * 只挂在 ① 上的话,转屏后手势条高度变了而 reserve 没变)。 */ @State navReserve: number = NAV_CONTENT_RESERVE; /** 环境变化回调 id(-1 = 没订阅);`lastColorMode` 用来只在真的换向时重算 */ @@ -1871,6 +1890,25 @@ struct MainPage { this.unwatchEnvironment(); } + /** + * 算一遍「内容末尾要让多少位」——**唯一**的算法,两个触发点共用。 + * + * 宽屏:0(没有底部悬浮条,列表不必让位)。 + * 窄屏:`NAV_CONTENT_RESERVE`(条高 + 离底留白 + 余量)**再加系统手势条**。 + * + * ★ 为什么要加手势条:全屏(`setupFullScreenWindow`)之后屏幕最底那段是系统的 + * 手势条(实测 20px ≈ 6vp)。底栏自己已经为它让开了(`NavBar` 的 + * `bottom: NAV_BAR_BOTTOM + navIndicator`),而 reserve 是给**内容末尾**让位的量 —— + * 不加的话,条向上移了而内容没跟着,最后一行会被条的下缘压住 + * (正是 2026-09-16 那轮修过的"看得见、点不到")。 + * + * ★ 两处触发点、一个算法:宽度与避让**是两件独立的事**, + * 写成两处赋值就迟早出现"改了一处忘了另一处"(今天就是这样:转屏只改避让不改宽度)。 + */ + private recomputeNavReserve(): void { + this.navReserve = this.isWide ? 0 : (NAV_CONTENT_RESERVE + this.windowInsets.navIndicator); + } + /** * 订阅系统环境变化(深浅色切换),**重算我们自己算出来的那部分**。 * @@ -2241,7 +2279,17 @@ struct MainPage { .padding({ left: NAV_BAR_SIDE, right: NAV_BAR_SIDE, - bottom: NAV_BAR_BOTTOM + /* + * ★ 底部要让**两段**:自身留白 `NAV_BAR_BOTTOM` + 系统手势条高度。 + * + * 全屏(`setWindowLayoutFullScreen(true)`)之后,屏幕最底那段是系统的 + * 手势条(实测 20px ≈ 6vp)—— 不额外让开,自绘的玻璃条会压在它上面, + * 看起来就是"底栏贴着屏幕边",而且手势区会盖住条的下缘。 + * + * 示例工程同一口径:`MineView.ets:252` 的 + * `.margin({ bottom: this.globalInfoModel.naviIndicatorHeight })`。 + */ + bottom: NAV_BAR_BOTTOM + this.windowInsets.navIndicator }) } @@ -2391,7 +2439,17 @@ struct MainPage { .padding({ left: this.isWide ? Theme.paneGap : 0, right: this.isWide ? Theme.paneGap : 0, - top: this.isWide ? Theme.paneGap : 0, + /* + * ★ 避让区就在这里:状态栏高度当 padding-top。 + * + * 全屏(`setWindowLayoutFullScreen(true)`)之后内容的画布从 y=0 开始 —— + * 而 y=0..statusBar 被时钟/电量占着。把这个高度当 padding-top, + * 实际绘制的就回到避让区下面了(上一次退回,正是因为少了这一行)。 + * + * 加在**内容层**而不是根 `Stack`:壁纸层是 `Stack` 的底层兄弟, + * 根上加了 padding 会把壁纸一起缩进去,黑边只是换个地方出现。 + */ + top: this.isWide ? Theme.paneGap : this.windowInsets.statusBar, bottom: this.isWide ? Theme.paneGap : 0 }) @@ -2402,10 +2460,13 @@ struct MainPage { } .width('100%') .height('100%') + /* + * ★ 根 `Stack` **不带避让 padding** —— 壁纸层要铺到屏幕四边(全屏的意义就在这里)。 + * 避让加在内容层与底栏上(见各自的 padding),这样黑边才真的消失。 + */ .onAreaChange((oldValue: Area, newValue: Area) => { this.isWide = (newValue.width as number) >= 768; - /* 宽屏没有底部导航条 ⇒ 内容不需要为它让位 */ - this.navReserve = this.isWide ? 0 : NAV_CONTENT_RESERVE; + this.recomputeNavReserve(); }) } } \ No newline at end of file