〖共创稿事节〗HarmonyOS 7 新特性实战(55):浅色、深色与跟随系统——三档主题的状态分层与窗口色彩模式治理
# 〖共创稿事节〗HarmonyOS 7 新特性实战(55):浅色、深色与跟随系统——三档主题的状态分层与窗口色彩模式治理
「山海万灵」的深色模式做出来之后,收到一条很具体的反馈:
> 深色模式下底部 tab 栏是一整块米白玻璃,压在深色插画上,还和下方那条浅色渐变糊成一片。
第一反应是底栏自己的颜色写错了。可底栏的底色、描边、选中态 chip 全都吃 isDarkMode,页面其它地方也确实变深了——只有这一条栏没变。继续往下查,才碰到这句真正的原因:沉浸式系统材质由系统绘制,只认窗口的深浅色,不认组件自己的颜色。
再往后,为了让「跟随系统」这一档也能用,主题从两档(浅/深)扩到三档(浅/深/跟随系统),又撞上第二个问题:setColorMode 本身就是一次全局配置变更,切一下就是肉眼可见的顿挫。
这一篇记录这两段排查,以及最后落在代码里的四个约束:状态分层、可见性门控、欠账补偿、幂等申请。

一、档位不是结果:两个状态必须分开存
最初只有两档,一个 isDarkMode 布尔量就够用了:真就是深色,假就是浅色。
加入「跟随系统」之后,这个布尔量立刻不够用。它只回答「此刻是不是深色」,回答不了「用户选的是哪一档」。举个会出错的序列:
1. 用户选「跟随系统」,系统此刻是浅色 ⇒ isDarkMode = false;2. 系统在设置里切到深色 ⇒ isDarkMode = true;3. 用户手动切回浅色 ⇒ 程序看到 isDarkMode 为真,把档位写成 LIGHT;4. 再想让应用回到「跟随系统」,已经没有任何地方记得用户当初选过这一档。
所以档位与结果要分成三个状态,各回答一个问题:
| 状态 | 回答的问题 | 谁写入 | 是否落盘 |
|---|---|---|---|
themeMode | 用户选了哪一档 | 主题弹层 / 页头按钮 | 是 |
systemDark | 系统此刻是不是深色 | Ability 的配置变更回调 | 否 |
isDarkMode | 界面此刻该用哪套配色 | 前两者推导 | 否 |

落到代码上,三个声明各自带着自己的 @Watch:
// 三个状态各回答一个问题:
// themeMode —— 用户选了哪一档(意图,落盘)
// systemDark —— 系统此刻是不是深色(由 Ability 写入)
// isDarkMode —— 界面此刻该用哪套配色(结果,@Watch 触发同步)
@StorageProp(SHANHAI_THEME_MODE_KEY) @Watch('onThemeModeChanged') themeMode: string = 'LIGHT';
@StorageProp(SHANHAI_SYSTEM_DARK_KEY) @Watch('onSystemDarkChanged') systemDark: boolean = false;
@State @Watch('syncApplicationColorMode') isDarkMode: boolean = false;
/** 档位 → 实际深浅色。跟随系统时取系统值,否则取档位本身。 */
private applyThemeMode(): void {
const mode: ShanhaiThemeMode = normalizeShanhaiThemeMode(this.themeMode);
this.isDarkMode = mode === 'SYSTEM' ? this.systemDark : mode === 'DARK';
// 值没变时 @Watch 不会触发,这里补一次(幂等,只是把窗口色彩模式再对齐一遍)。
this.syncApplicationColorMode();
}
/** 系统深浅色变了(Ability 的配置变更回调写进来)。只在跟随系统时才动界面。 */
private onSystemDarkChanged(): void {
if (normalizeShanhaiThemeMode(this.themeMode) !== 'SYSTEM') {
return;
}
this.isDarkMode = this.systemDark;
this.syncApplicationColorMode();
}
有一个容易被忽略的细节:systemDark 变化时,如果档位不是 SYSTEM,必须直接返回。否则用户在应用内选了深色,而系统恰好切到浅色,界面会被系统回调「纠正」成浅色——用户会认为应用自己乱跳。
档位落在哪,也有讲究。这里用的不是 PersistentStorage,而是 preferences:
export type ShanhaiThemeMode = 'LIGHT' | 'DARK' | 'SYSTEM';
const PREFERENCES_NAME: string = 'shanhai_theme_preference';
const PREFERENCES_MODE: string = 'theme_mode';
/** 存储里的字符串不可信(旧版本、手改文件),落成三档之一。 */
export function normalizeShanhaiThemeMode(raw: string): ShanhaiThemeMode {
if (raw === 'DARK') {
return 'DARK';
}
if (raw === 'SYSTEM') {
return 'SYSTEM';
}
return 'LIGHT';
}
/** 启动时把档位从磁盘读进 AppStorage(在加载页面之前调,避免先闪一下浅色)。 */
export async function initShanhaiThemePreference(context: common.Context): Promise<void> {
try {
themeStore = await preferences.getPreferences(context, PREFERENCES_NAME);
const stored: preferences.ValueType = await themeStore.get(PREFERENCES_MODE, SHANHAI_THEME_MODE_DEFAULT);
const mode: ShanhaiThemeMode = normalizeShanhaiThemeMode(typeof stored === 'string' ? stored : '');
AppStorage.setOrCreate<string>(SHANHAI_THEME_MODE_KEY, mode);
} catch (_) {
// 读不出来就按默认浅色,不能因为主题读失败挡住启动。
AppStorage.setOrCreate<string>(SHANHAI_THEME_MODE_KEY, SHANHAI_THEME_MODE_DEFAULT);
}
}
不用 PersistentStorage 是因为它在真机上出现过静默不落盘的情况(工程里已读进度那条路也踩过同一个坑)。preferences 多写了 try / catch 与默认值,换来的是「主题读失败也照常启动」这条底线。
三档最终要落到三种不同的窗口色彩模式上,对应关系不能含糊:
| 档位 | 窗口色彩模式 | 界面取色来源 | 系统切色时 |
|---|---|---|---|
LIGHT | COLOR_MODE_LIGHT | 浅色资源 | 界面不动 |
DARK | COLOR_MODE_DARK | 深色资源 | 界面不动 |
SYSTEM | COLOR_MODE_NOT_SET | systemDark | 由回调驱动,界面跟着变 |
「跟随系统」必须映射成 COLOR_MODE_NOT_SET。如果把它也写成 LIGHT / DARK,应用就把窗口锁死了,系统切色时系统材质根本不会动——这一条是后面所有问题的前提。
二、为什么底栏不跟着变深
回到最初那条反馈。底栏用的是系统沉浸光感材质,挂在 Tabs 自己的浮动栏上。材质的绘制方在系统侧,它读的是窗口的色彩模式,不是组件的 fontColor / backgroundColor 之类。
也就是说,isDarkMode 那个布尔量此前只活在页面里:
- 页面里的卡片、文字、底图全都按
isDarkMode取色,所以「看起来变深了」; - 窗口的色彩模式没人动过,仍停在继承来的值上,系统材质照浅色出图。
结果就是一条米白的玻璃栏压在深色插画上。它不属于「底栏颜色写错」,属于「渲染这条栏的人不知道你切了主题」。
修法不是给底栏补一层深色,而是把主题同步到窗口:
private applyWindowColorMode(): void {
const target: ConfigurationConstant.ColorMode =
normalizeShanhaiThemeMode(this.themeMode) === 'SYSTEM'
? ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET
: (this.isDarkMode ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK
: ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
// 已经就是这个模式就别再申请一次。哨兵用 -2:`COLOR_MODE_NOT_SET` 本身就是 -1。
if (this.appliedColorMode === target) {
return;
}
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
context.getApplicationContext().setColorMode(target);
this.appliedColorMode = target;
} catch (err) {
hilog.warn(0x2026, 'ShanhaiIndex', 'setColorMode failed: %{public}s', JSON.stringify(err));
}
}
两个细节值得单独说。
调用方是 ApplicationContext,不是窗口对象。 窗口级的色彩模式设置不会带动系统材质,只有应用级的那一下才会让系统按新的深浅色重绘。这个差别不会报错,只会表现成「设置了但没效果」,所以要写进注释里。
哨兵取 -2,不能取 -1。 COLOR_MODE_NOT_SET 的值本身是 -1,如果拿 -1 当「还没申请过」的初值,那么第一次进入「跟随系统」档时会被误判成「已经就是这个模式」,一次都不申请。这类哨兵值冲突不会在编译期报出来,只能靠把枚举的真实取值写进注释。
改完之后,深色下的底栏才真的变深:同一位置的深色截图里,栏体是半透明的深色玻璃,选中项 chip 与图标照常渲染。
三、setColorMode 很贵:把代价花在看得见的时候
材质同步解决之后,新问题来了:切换有明显顿挫感。
原因在 setColorMode 的实现方式——它会让整个应用走一次全局配置变更,重新解析资源、重新布局。页面越复杂,这一下越明显。而用户在「我的」页点主题外观时,底栏未必在屏幕上;在详情页、展厅、地图覆盖层里切换时,底栏根本不可见,这次全局配置变更纯属白付。
于是把申请拆成「何时可以付」与「付哪一次」两个问题。
何时可以付:只有底栏真的在屏幕上时才值得付。底栏的可见性由四个状态共同决定(选中异兽、展厅、地图覆盖层挂载、超分查看器),四个状态变化时都要重新判一次:
/**
* 把主题档位同步给窗口色彩模式。
*
* 为什么必须同步:底部 tab 栏用的是系统沉浸光感材质,它由系统绘制,
* 只认窗口的深浅色,不认组件自己的颜色。
*
* 为什么延后 / 有时干脆不调:`setColorMode` 会让整个应用走一次全局配置变更
* (重新解析资源、重新布局),这一下就是肉眼可见的顿挫。它只在底栏真的在
* 屏幕上时才有意义。所以:
* - 底栏不可见(二级页 / 详情 / 展厅 / 地图覆盖层):只翻 isDarkMode,不动窗口;
* - 底栏可见:也等底图淡入动画结束再申请,别卡在动画中间。
* 欠下的那一次在回到 tab 根页时补上。
*/
private syncApplicationColorMode(): void {
if (!this.isBottomNavVisible()) {
this.pendingColorModeSync = true;
return;
}
if (this.colorModeSyncTimer >= 0) {
clearTimeout(this.colorModeSyncTimer);
}
this.colorModeSyncTimer = setTimeout(() => {
this.colorModeSyncTimer = -1;
this.applyWindowColorMode();
}, THEME_COLOR_MODE_DELAY_MS);
}
/** 底部 tab 栏是否真的在屏幕上。 */
private isBottomNavVisible(): boolean {
return !(this.mapOverlayMounted || this.selectedBeastId.length > 0 ||
this.showHallExplorer || this.superResViewerVisible);
}
/** 底栏可见性变化:如果它回到屏幕上,把欠着的那次申请补上。 */
private onBottomNavLayerChanged(): void {
if (this.isBottomNavVisible()) {
this.flushPendingColorMode();
}
}
/** 回到 tab 根页时补上之前欠的那次窗口色彩模式。 */
private flushPendingColorMode(): void {
if (!this.pendingColorModeSync) {
return;
}
this.pendingColorModeSync = false;
this.applyWindowColorMode();
}
这段里每一行都在回答一个具体场景:
| 场景 | 底栏可见 | 处理 | 结果 |
|---|---|---|---|
| 在 tab 根页切主题 | 是 | 延后 220ms 再申请 | 等底图淡入结束,不卡在动画中间 |
| 在异兽详情里切主题 | 否 | 只翻 isDarkMode,记一笔欠账 | 不付全局配置变更 |
| 从详情返回 tab 根页 | 变为是 | flushPendingColorMode() | 欠的那次补上 |
| 目标模式已经生效 | 是 | 直接返回 | 不重复申请 |

有两个实现上的坑,写的时候都撞过。
其一,这类逻辑不能放进 build()。 ArkUI 的构建函数里只允许 UI 语法,写一句「如果底栏回来了就补申请」会直接报「does not meet UI component syntax」。所以只能挂在 @Watch 上。
其二,只挂 onAppear 不够。 关掉详情页回到 tab 根页时,外层容器并不会重新挂载,onAppear 不会再触发一次。欠账就永远欠着。必须挂在决定可见性的那四个状态上。
至于「延后多久」,取的是 220ms,和页面底图淡入的时长对齐。这是个经验值,不是协议值:调大一点只是让申请来得更晚,调小一点会让顿挫和淡入叠在一起。
四、切色那一帧的底图:两张常驻,只翻透明度
setColorMode 的代价压下去之后,还剩一层顿挫:卡片(纯色和小图)瞬间就换色了,页面底图却慢半拍。
底图是一张 941×1672 的 webp,深色那张约 2.1 MB。换资源意味着重新解码,那几十到一两百毫秒就是「背景跟不上卡片」的观感来源。
压图这条路走不通:用户明确否决过「为了切主题牺牲图片质量是本末倒置」。云幕遮罩也试过——在真机上根本不显示,判定场景有限后整体撤掉。最后采纳的是第三条:两张图同时挂载,切主题只翻透明度。
/**
* 页面底图图层:浅/深两张常驻,切主题只改透明度。
*
* 起因:系统切深浅色时,卡片(纯色/小图)瞬间就变了,页面底图却要慢半拍 ——
* 底图是一张 941×1672 的 webp(深色那张 2.1 MB),换资源后要重新解码才上屏。
*
* 做法:两张图同时挂载,进页面时各解码一次;切主题只翻 opacity,配 200ms 淡入淡出。
* 解码不再发生在切换那一刻,观感与卡片同步。
*
* 代价是同一页多驻留一张全屏位图(941×1672×4 ≈ 6 MB)。
*/
const BACKDROP_FADE_MS: number = 200;
@Component
export struct ShanhaiPageBackdropLayer {
@Prop isDarkMode: boolean = false;
@Prop isWideScreen: boolean = false;
build() {
Stack() {
// 浅色在下、深色在上:淡入时是深色纸面盖住浅色,边缘不会先露出底色。
Image(shanhaiPageBackdropAsset(false, this.isWideScreen))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.isDarkMode ? 0 : 1)
.animation({ duration: BACKDROP_FADE_MS, curve: Curve.EaseInOut })
Image(shanhaiPageBackdropAsset(true, this.isWideScreen))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.isDarkMode ? 1 : 0)
.animation({ duration: BACKDROP_FADE_MS, curve: Curve.EaseInOut })
}
.width('100%')
.height('100%')
}
}
「浅色在下、深色在上」是有意为之:淡入时是深色纸面盖住浅色,边缘不会先露一条底色。反过来会在过渡的一两帧里看到浅色描边。
这个方案的取舍写在注释里:多驻留约 6 MB,换掉的是「每次切换都卡一下」。对一个以全屏插画为主要观感的应用,这笔账是划算的。
还有一条容易被漏掉的检查:resources/dark/ 下必须确认没有与 base/ 同名的资源。如果有,系统会在窗口色彩模式变化时自动切资源,就和「两张图自己交叉淡入」形成两套并行的换肤机制,画面会打架。这个工程里 dark/ 只有服务卡片用的两个文件,与 base/ 无同名项,因此这条路径是干净的。
五、跟随系统:启动前读盘,运行时靠回调
「跟随系统」这一档有两个时刻必须对齐,漏掉任何一个都会闪。
启动时,档位要在页面加载之前就读进 AppStorage,窗口色彩模式也要在页面绘制之前落一次。否则存的是深色、首帧却按浅色画一遍,再跳成深色——用户看到的是「白闪一下」。
onWindowStageCreate(windowStage: window.WindowStage): void {
// 已读进度 + 主题档位 + 分栏左栏宽度:都先读进 AppStorage 再加载页面。
// 读盘放在前面,避免「存的是深色却先白一帧」。
Promise.all([
initShanhaiReadingProgress(this.context),
initShanhaiThemePreference(this.context),
initShanhaiSplitWidth(this.context)
])
.then(() => this.loadIndex(windowStage))
.catch(() => this.loadIndex(windowStage));
}
private loadIndex(windowStage: window.WindowStage): void {
// 窗口色彩模式要按存下来的档位先落一次:页面自己也会对齐,但那时已经画过一帧了。
this.applySavedColorMode();
windowStage.loadContent('pages/Index');
}
private applySavedColorMode(): void {
const mode: ShanhaiThemeMode = currentShanhaiThemeMode();
try {
const target: ConfigurationConstant.ColorMode =
mode === 'SYSTEM' ? ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET
: (mode === 'DARK' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK
: ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
this.context.getApplicationContext().setColorMode(target);
} catch (err) {
hilog.error(DOMAIN, TAG, 'Failed to apply saved colorMode. Cause: %{public}s', JSON.stringify(err));
}
}
注意 loadContent 写在 applySavedColorMode() 之后。这个顺序不是风格问题——反过来就是先画一帧再改色,白闪会稳定复现。
运行时,系统切深浅色时要有人把真实值送回来。Ability 的配置变更回调正好承担这个职责,而且有两个入口:
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 记下系统当前的深浅色。此时应用还没设过色彩模式(继承系统),
// 配置里的 colorMode 就是系统实际值——「跟随系统」档要靠它决定界面。
this.publishSystemColorMode(this.context.config.colorMode);
}
/**
* 系统深浅色变化(用户在系统设置里切换,或我们自己调 setColorMode 之后)。
*
* 应用设成 COLOR_MODE_NOT_SET(跟随系统档)时,这里的 colorMode 就是系统的实际值;
* 设成写死的 DARK/LIGHT 时它反映的是应用自己的选择——所以「跟随系统」档切过去之后,
* 会由这里回传真实值来纠正界面。
*/
onConfigurationUpdate(newConfig: Configuration): void {
this.publishSystemColorMode(newConfig.colorMode);
}
private publishSystemColorMode(colorMode: ConfigurationConstant.ColorMode | undefined): void {
publishShanhaiSystemDark(colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
}
colorMode 在类型上是可选字段,取不到就按浅色记——界面随后还会被页面自己的档位对齐一次,所以这个默认值不会留下错误状态。
这里有个因果链值得记住:只有在 COLOR_MODE_NOT_SET 时,配置回调里的 colorMode 才代表系统的真实值。应用写死 DARK 时,这个回调反映的是应用自己的选择。因此「先设 NOT_SET,再等回调」是「跟随系统」唯一的正确顺序;反过来,先按旧值设一次 DARK,系统切到浅色时回调根本不会把界面纠正回来。
六、页头那颗按钮为什么只在明暗之间切
三档在「我的」页的主题外观弹层里选,页头和各页的主题按钮只做一件事:在浅色和深色之间切。
/** 用户在弹层里选了一档。 */
private selectThemeMode(mode: ShanhaiThemeMode): void {
// 选中即关闭弹层:弹层是栈顶 destination,只翻标志位不弹栈会留一层空壳。
this.closeThemeModeSheet();
saveShanhaiThemeMode(mode);
this.applyThemeMode();
}
/**
* 页头 / 各页的主题按钮:只在浅色与深色之间切。
*
* 刻意不参与「跟随系统」——那颗按钮是「我现在想换一下」,用户按下去必须看到明暗变化;
* 若轮到跟随系统而系统恰好同色,按下去像没反应。三档只从「我的」页的主题外观里选。
*/
private toggleThemeMode(): void {
saveShanhaiThemeMode(this.isDarkMode ? 'LIGHT' : 'DARK');
this.applyThemeMode();
}
这不是偷懒,是交互契约。页头的按钮是一颗「切换」按钮,用户按下去必须立刻看到画面变化。如果它也轮到「跟随系统」,而系统此刻恰好与应用当前颜色相同,按下去就什么都没发生——用户会以为按钮坏了。三档选择放在「我的」页的设置项里,语义是「设定」,不需要每按一次都有即时反馈。
主题弹层本身也不能硬塞进原来那一行。「我的」页的菜单面板是固定高度加固定底图的四行面板,行内展开会把底图撑变形,所以改成点开一个居中的三行单选小面板:

弹层里只画一个勾,而不是三行都画单选圈。三行都画的话,未选中的两行反而比选中的更重,视觉重心是反的。
七、验收:三种档位 × 底栏可见性 × 重启
这类改动的验收不能只看「切一下变没变」,因为它的错误大多出现在组合状态里。按三个维度组合,逐项确认:
| 档位 | 底栏可见 | 底栏不可见(详情/展厅/地图) | 冷启动 |
|---|---|---|---|
| 浅色 | 底栏与页面同时变浅 | 页面变浅、窗口不动;返回后补申请 | 首帧即浅色,无白闪 |
| 深色 | 底栏材质变深,选中 chip 正常 | 页面变深、窗口不动;返回后补申请 | 首帧即深色,无白闪 |
| 跟随系统 | 与系统此刻一致 | 页面跟随、窗口交给系统 | 首帧按系统值,切系统色后跟着变 |
补充两条只有真机能观察到的判据:
- 深色下底栏的材质层真的出图了,不是简单地换成一块深色底。判断方法是看栏体边缘的半透明过渡与下方内容的透出关系。
- 切色过程中卡片与底图的时序。双图交叉淡入上线后,卡片与底图应该同一帧开始变化,而不是卡片先跳、底图后补。
构建侧把这些约束钉成了断言,而不是留在人的记忆里。这里只列分组,不列具体写法:
| 断言分组 | 钉住的不变量 |
|---|---|
| 状态分层 | 档位与「当前是否深色」必须分开声明;系统深色必须被订阅 |
| 三档映射 | 必须映射到 NOT_SET / DARK / LIGHT 三种模式;跟随系统必须用 NOT_SET |
| 调用方 | setColorMode 必须走 ApplicationContext;启动时必须对齐一次 |
| 代价治理 | 同一模式不重复申请;底栏不可见时不申请;可见时延后;可见性变化必须触发补申请 |
| 底图 | 浅/深两张必须常驻;切主题翻透明度而不是换资源 |
| 持久化 | 档位必须走 preferences,不得用那个在真机上静默不落盘的接口 |
改完之后全量 40/40 通过,构建成功。真机侧的证据分两批:深色底栏的修复(同一轮里还顺手做了首页知识卡的底图与文字色对齐),以及三档与顿挫治理(同日第二轮、第三轮)。
有一项仍然是已知欠账:系统自己在设置里切深浅色的那一下,会卡。它同样是一次全局配置变更,加一次全屏资源的重新解码,而这一次不受应用控制。云幕遮罩那条路试过、真机不显示,评估后不再投入。要重做的话,得先解决「遮罩层与内容层的绘制顺序」这个更底层的问题。
八、这一轮真正值钱的地方
三条,都不是「某个 API 怎么用」:
1. 「界面没跟着变」先分清是谁在画。 页面自己画的颜色,改一个 @State 就够;系统画的材质,只认窗口色彩模式。混在一起排查,会一直在组件属性里找答案——而答案压根不在那一层。2. 昂贵的全局操作要记账。 setColorMode 不是不能调,是不能随便调。可见性门控、延后、欠账补偿、幂等哨兵,四件事各自对应一个真实场景,少一件就会在某个组合状态下露馅。3. 状态分层要按「谁回答哪个问题」来切。 把「用户选了哪一档」和「此刻是不是深色」合成一个布尔量,两档时没问题,加第三档立刻出错,而且错误会以「用户的选择记不住」这种很难复现的形式出现。
官方对应用级上下文与色彩模式的说明见 ApplicationContext 参考。
更多推荐
所有评论(0)