# 〖共创稿事节〗HarmonyOS 7 新特性实战(55):浅色、深色与跟随系统——三档主题的状态分层与窗口色彩模式治理

「山海万灵」的深色模式做出来之后,收到一条很具体的反馈:

> 深色模式下底部 tab 栏是一整块米白玻璃,压在深色插画上,还和下方那条浅色渐变糊成一片。

第一反应是底栏自己的颜色写错了。可底栏的底色、描边、选中态 chip 全都吃 isDarkMode,页面其它地方也确实变深了——只有这一条栏没变。继续往下查,才碰到这句真正的原因:沉浸式系统材质由系统绘制,只认窗口的深浅色,不认组件自己的颜色。

再往后,为了让「跟随系统」这一档也能用,主题从两档(浅/深)扩到三档(浅/深/跟随系统),又撞上第二个问题:setColorMode 本身就是一次全局配置变更,切一下就是肉眼可见的顿挫。

这一篇记录这两段排查,以及最后落在代码里的四个约束:状态分层、可见性门控、欠账补偿、幂等申请。

深色模式下的浮动导航栏:材质随窗口色彩模式一起变深

一、档位不是结果:两个状态必须分开存

最初只有两档,一个 isDarkMode 布尔量就够用了:真就是深色,假就是浅色。

加入「跟随系统」之后,这个布尔量立刻不够用。它只回答「此刻是不是深色」,回答不了「用户选的是哪一档」。举个会出错的序列:

1. 用户选「跟随系统」,系统此刻是浅色 ⇒ isDarkMode = false;2. 系统在设置里切到深色 ⇒ isDarkMode = true;3. 用户手动切回浅色 ⇒ 程序看到 isDarkMode 为真,把档位写成 LIGHT;4. 再想让应用回到「跟随系统」,已经没有任何地方记得用户当初选过这一档。

所以档位与结果要分成三个状态,各回答一个问题:

状态回答的问题谁写入是否落盘
themeMode用户选了哪一档主题弹层 / 页头按钮是
systemDark系统此刻是不是深色Ability 的配置变更回调否
isDarkMode界面此刻该用哪套配色前两者推导否

三个状态各自回答一个问题:档位是意图,系统深浅是输入,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 与默认值,换来的是「主题读失败也照常启动」这条底线。

三档最终要落到三种不同的窗口色彩模式上,对应关系不能含糊:

档位窗口色彩模式界面取色来源系统切色时
LIGHTCOLOR_MODE_LIGHT浅色资源界面不动
DARKCOLOR_MODE_DARK深色资源界面不动
SYSTEMCOLOR_MODE_NOT_SETsystemDark由回调驱动,界面跟着变

「跟随系统」必须映射成 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 参考。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐