Files
MailUI4Agents/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets
JianFeeeee cbb1e1ee62 跨端: 修「深色下玻璃变灰纸板」+「主题选了不生效」(两个真 bug)
用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 bug**,
不是观感偏好问题。两个都有实测数据。

## ① 深色下玻璃 alpha 没跟着翻 →「像深色卡片浮在灰纸板上」

### 实测

深色主题 + aurora 壁纸,同一行交替采样(`/tmp/g1.raw`,1008×2232):

    y=670   卡片缝隙 rgb(199,199,199)   卡片内 rgb(27,37,50)
    y=880   卡片缝隙 rgb(201,203,201)   卡片内 rgb(27,36,53)

缝隙是**浅灰 199**、比卡片还亮。而 199 恰好 = `0.78 × 255`
(壁纸层实测 x=4..24 为 rgb(0,1,3),近黑)—— 就是 `glassCardWall` 那层白纱。

### 根因:我把 WebUI 的一句话读漏了后半句

`Theme.glassCard` / `glassCardWall` 是**单一值、不分深浅**。我当初写的理由是
「基色恒为白,主题之间只差 alpha」——但 WebUI 原话是
「**基材恒为白**,主题之间**只差 alpha**」(`index.css:406`、`:1546`)。
我只抄了前半句,把「只差 alpha」误读成「alpha 也不用变」。

WebUI `.dark` 的实际取值:

    --glass-card-a:      0.06     ← 浅色 0.92
    --glass-card-wall-a: 0.04     ← 浅色 0.78

**深浅差 15 倍**:深色下白纱要几乎撤掉让深壁纸透上来,浅色下才用厚白纱盖亮壁纸。
我用同一个 0.92 配两套主题 ⇒ 深色下壁纸被糊成浅灰,**玻璃感与深色同时消失**。

### 修法

`Theme.glassCardFor(wall, dark?)` —— 与 `accentFor`/`textSubtleFor` 同一范式,
深浅从 `Theme.isDarkNow()`(`AppStorage` 那个发布键)读,不靠调用方自觉传。
新增 `glassCardDark #0FFFFFFF` / `glassCardWallDark #0AFFFFFF`。

改后同处实测:缝隙 rgb(3..16),壁纸透上来了;浅色档回归检查未变(244/239,厚白纱仍在)。

## ② 用户选的主题被无条件覆盖 →「选了深色但界面还是浅的」

### 实测

「我的」页选「深色」后:
· 按钮显示选中、服务端也记下 `theme:'dark'`(`GET /me/appearance` 确认);
· 但界面仍浅色,且文字浅色压浅底 —— 实测背景 `rgb(243,243,243)` /
  文字 `rgb(243,243,243)`,**对比度 ≈1.0:1,完全不可读**。

### 根因(两处叠加)

① `EntryAbility.onCreate` 有一句**无条件**的
   `setColorMode(COLOR_MODE_NOT_SET)`(= 跟随系统)—— 每次冷启都把用户的选择重置掉。
   它是目录迁移时抄进来的(`git log -S` 指向 `f9d757b chore: directory migration`),
   **没有注释说明为什么,也没有谁在用**。已删除(连带上游 import)。

② `MainPage.applyAppearance()` 算出了 `isDarkNow`、发布了 `AppStorage`,
   但**从来没调用过 `store.applyTheme(...)`** ⇒ 系统色彩模式根本没被设过。

### 修法

· 删掉 EntryAbility 那句无条件覆盖(附长注释说明为什么由页面负责);
· `applyAppearance()` 补上 `store.applyTheme(ctx, snap.theme)`。

★ **顺序**:`KEY_IS_DARK` 发布必须在 `applyTheme` **之前** ——
  因为 `glassCardFor()` 是从那个键读深浅的,反过来会让卡片用上一轮的深浅画一帧。
  `applyThemeNow()` 里同一处顺序也一并修正。

## 判据

`cross-client-theme` 的两条原本把 `Theme.glassCard`/`glassCardWall` 两个**字面量**钉死,
被我这次正确的改动撞红 —— 我没有放宽它们,而是改成钉**行为**:
· 卡片必须经 `glassCardFor(...)` 取色(入口存在);
· 四个令牌都要在 Theme 里存在;
· **深色档不得与浅色档同值**(同值 = 主题感知是假的)。

变异验证:① 深色档改回与浅色同值 → 3 条红;② 卡片改回不分深浅 → 5 条红;还原 → 21/21 绿。

## 验证状态

✓ 两个修复都在设备上取**像素**验证(不是看截图说"像了")
✓ `cross-client-theme` 21/21、全量套件 545 pass(3 红均为改动前既有:
  `harmony-admin` 两处 = 我上一轮 `SecuritySection` 拆分遗留、`build-stamp` = 产物戳过期)
✓ 编译通过、进程存活、无新 jscrash
2026-09-21 18:46:19 +08:00

276 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* `ConfigurationConstant` 本轮**不再需要**:原来为 `setColorMode(COLOR_MODE_NOT_SET)`
* 引的,那句已删(见 `onCreate` 里的说明)。应用色彩模式现在统一由
* `MainPage.applyAppearance()` 按用户存下来的 `snap.theme` 决定。
*/
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/*
* ★ `KeyboardAvoidMode` 必须从 `@kit.ArkUI` 引(= `@ohos.arkui.UIContext` 那个)。
*
* 全局作用域里**也有**一个同名枚举(`common.d.ts`,只有 DEFAULT/NONE 两个成员),
* 它不带 `RESIZE` ⇒ 不引这一条就会报:
* Property 'RESIZE' does not exist on type 'typeof KeyboardAvoidMode'.
* 两个同名枚举撞在一起,是本条最费时的一步。
*/
import { KeyboardAvoidMode, 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;
export default class EntryAbility extends UIAbility {
/** 通知点击去重(有界) */
private ledger: NotificationLedger = new NotificationLedger(50);
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
/*
* ★★ 2026-09-21 **删掉了这里的一句 `setColorMode(COLOR_MODE_NOT_SET)`**。
*
* 原来这里是:
* this.context.getApplicationContext()
* .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
*
* 它的语义是"跟随系统",而它是**无条件**执行的 ⇒ 每次冷启都把应用色彩模式
* 重置回跟系统走,**用户在「我的」页选的浅色/深色被丢掉**。
*
* 实测症状:选「深色」后按钮显示选中、服务端也存了 dark,但界面仍是浅色
* (背景 rgb(243,243,243) / 文字 rgb(243,243,243),对比度 ≈1:1,不可读)。
*
* 为什么会这样:它是目录迁移时抄过来的(`git log -S` 指向
* `f9d757b chore: directory migration`),**没有任何注释说明为什么**。
*
* ── 现在由谁负责 ──
*
* `MainPage.applyAppearance()`:它读本机缓存 + 服务端合并出 `snap.theme`,
* 然后调用 `store.applyTheme(ctx, snap.theme)` 真正应用。
* 那里才是"用户偏好的权威"所在。
*
* ★ 为什么不在 ability 里也读一次 preferences:
* 主题是**按账号**存的(键 `appearance.<accountId>`,见 `AppearanceStore`),
* 而 `onCreate` 时活跃账号可能还没加载出来 ⇒ 会读到错的账号、
* 或者读到空值又退回默认。**一个事实一个权威来源**,这里不重复。
*
* ★ 会不会因此丢掉"跟随系统"这条语义:不会 ——
* `theme === 'system'` 时 `colorModeValue()` 返回的正是 `COLOR_MODE_NOT_SET`,
* 由 `MainPage` 按用户**实际选中的档位**决定,而不是无条件。
*/
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
/*
* ★ 2026-09-15:冷启点通知也要跳转。
* 之前只在 onNewWant 解析 want ⇒ 冷启动(进程不在)时点击通知 onCreate 拿到 want 却丢掉跳转目标。
* 这里与 onNewWant 同一付逻辑:解析出 route 就写 pendingRoute,页面起来后读它。
* 解析不出来就不做(一条格式不认识的通知最坏应当是"没反应",不是"跳到空页")。
*/
try {
const params: Record<string, Object> = want.parameters === undefined
? {} as Record<string, Object>
: want.parameters as Record<string, Object>;
const route = PushService.routeFromWant(params, this.ledger);
if (route !== undefined) {
/*
* 冷启:页面还没挂载 ⇒ 没人监听 ⇒ 落进格子里,页面起来后由 aboutToAppear 取走。
* 走 deliverRoute 而不是直接写字段,是为了让"两种启动方式"共用一条路径 ——
* 见 PushService.deliverRoute 的说明。
*/
PushService.deliverRoute(route);
}
} catch (err) {
hilog.info(DOMAIN, 'testTag', '冷启通知跳转解析失败(静默):%{public}s', JSON.stringify(err));
}
/*
* 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。
* ★ reportToken 内部先查开关(PushService.isEnabled,默认关):关着就不取 token、
* 不弹权限、不打网关(自部署零开销)。
*/
try {
const push: PushService = PushService.getInstance(this.context);
push.reportToken(ApiClient.getInstance(this.context)).catch((err: Object) => {
hilog.info(DOMAIN, 'testTag', 'push 上报异常(静默):%{public}s', JSON.stringify(err));
});
} catch (err) {
hilog.info(DOMAIN, 'testTag', 'push 初始化跳过(静默):%{public}s', JSON.stringify(err));
}
}
/**
* 点通知拉起应用时走这里(应用已在运行时)。
* 解析不出来就**什么都不做** —— 一条格式不认识的通知,最坏结果应当是"没反应",不是"跳到空页面"。
*/
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
const params: Record<string, Object> = want.parameters === undefined
? {} as Record<string, Object>
: want.parameters as Record<string, Object>;
const route = PushService.routeFromWant(params, this.ledger);
if (route !== undefined) {
/*
* 热启(应用已在运行):页面**早就挂载完**了,`aboutToAppear` 不会重跑 ——
* 所以这里必须**主动叫醒**正在监听的页面,否则"点通知"只会把 App 弹到前台。
* 这是 2026-09-17 模拟器实测出来的:只写字段时,热启路径**完全没有跳转**。
*/
PushService.deliverRoute(route);
}
} catch (err) {
hilog.info(DOMAIN, 'testTag', '通知跳转解析失败(静默):%{public}s', JSON.stringify(err));
}
}
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
/*
* ★ 窗口配置(全屏 + 避让)改到 loadContent 之后 —— 见 setupFullScreenWindow。
*
* ── 这里曾经写着「刻意不用 setWindowLayoutFullScreen(true)」并记了一个**错误的结论** ──
* 原文:「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏被
* 时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)。」
*
* 那次实测本身是真的,**结论下错了**:被盖住不是"不该全屏",而是
* **只做了全屏、没做避让**。示例工程(`/tmp/harmonyos-samples-reference`)的
* `WindowUtil` 里,`setWindowLayoutFullScreen` 与 `getWindowAvoidArea` 是
* **同一套东西的两半** —— 少了后一半,前一半当然是灾难。
*
* 而那次退回的代价是**黑边一直在**(用户 2026-09-17 报、2026-09-18 又报
* 「你看从头到尾都没修好」)。2026-09-18 实测 1256x2760 四页一致:
* 顶部纯黑 136px、底部 60px + 手势条 20px。
*
* 现在回到示例工程的做法:全屏 + 读避让 + 布局让位(三处配套,见 setupFullScreenWindow)。
*/
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.');
/*
* ★ 键盘避让模式改成 `RESIZE`(用户 2026-09-20:「点击回复按键与新建邮件部分
* 的动画与 webui 不一致」→ 连带把回复/转发改成 WebUI 同形的**内联底栏**)。
*
* 为什么这一行是**必须**的,而不是可选项:
* 官方默认是 `KeyboardAvoidMode.OFFSET` —— 键盘弹起时**整页上移**。
* 内联底栏(回复框 / 转发条)是贴在内容**最底下**的一块,
* 页面被推高之后它就出了可视区 ⇒「取消 / 发送 / 转发」点不到。
*
* 旧版是覆盖式弹层(`height('60%')`),弹层自己有固定高度,
* 键盘弹起时内容能在弹层内部重排 —— 所以那时候靠"弹层有高度 + 说明框
* layoutWeight(1)"绕过去了。**结构一改成内联,那条绕法就失效了**,
* 必须正面解决。`harmony-admin` 那条判据当场抓到了这一点。
*
* 选 `RESIZE` 而不是 `NONE`:`NONE` 是"不避让",键盘会直接盖住底栏;
* `RESIZE` 才是"按剩余高度重排",贴底元素留在屏内。
* (`OFFSET_WITH_CARET`/`RESIZE_WITH_CARET` 是 14+ 的"光标移动也触发"变体,
* 我们的底栏只在弹起时关心一次,不需要额外跟随。)
*/
windowStage.getMainWindowSync().getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
/*
* ★ 在 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<Insets>(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');
}
onForeground(): void {
// Ability has brought to foreground
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
// Ability has back to background
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
}
}