用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 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
276 lines
14 KiB
Plaintext
276 lines
14 KiB
Plaintext
/*
|
||
* 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');
|
||
}
|
||
} |