系列背景:这个系列记录一个本地优先的三国历史知识 App 从工程骨架、主题、内容模型、听书、搜索、资源到地图交互的完整实现。第 18 篇讲的是地图全屏查看器的手势缩放和横屏边界;第 19 篇回到长期使用体验,拆解一个看似简单但很容易写错的主题问题:用户选择“跟随系统”后,系统深浅色变化、页面 token 和本地偏好必须保持一致。

当前系列文章所述应用《耳畔三国·将星落》已上架鸿蒙应用商店,欢迎各位天才程序员尝鲜、吐槽!

一、真实工程问题:跟随系统不是一个布尔值

很多 App 第一版深浅色模式会写成一个 isDark: boolean。这对“只有深色和浅色两个按钮”的场景勉强够用,但一旦加入“跟随系统”,布尔值就不够表达真实状态了。

《耳畔三国·将星落》的主题入口在首页标题区和 tablet 侧栏里。用户点击后会在三个状态之间循环:

主题状态 用户看到的文案 应用层行为 系统变化时是否自动跟随
system 跟随系统 setColorMode(COLOR_MODE_NOT_SET)
light 浅色 setColorMode(COLOR_MODE_LIGHT)
dark 深色 setColorMode(COLOR_MODE_DARK)

如果只保存 isDark = true/false,就会丢掉“用户到底是手动选了深色,还是选择了跟随系统且当前系统正好是深色”这个差异。结果可能是:用户明明选择跟随系统,强停重启后却被恢复成固定深色;或者系统切到浅色后,页面 token 没刷新。

本文分析的源码对象集中在三个文件:

源码对象 所在文件 职责
EntryAbility.onCreate() entry/src/main/ets/entryability/EntryAbility.ets 启动时把应用颜色模式恢复为跟随系统
themeMode / isDark library2/src/main/ets/pages/MainFrame.ets 表达用户选择和当前固定深浅色
systemIsDark() MainFrame.ets UIAbilityContext.config.colorMode 读取当前系统深浅色
applyThemeMode() MainFrame.ets system/light/dark 映射到 setColorMode()
restorePreferences() MainFrame.ets 从 Preferences 恢复主题偏好并立即应用
AppTheme.palette() library1/src/main/ets/theme/AppTheme.ets 根据最终深浅色返回页面 token

浅色首页状态

二、为什么这篇不重复第 2 篇

第 2 篇讲的是 AppTheme token 如何把颜色、字号、间距抽象出来,让页面不要到处硬编码颜色。第 19 篇讲的是另一个层面:运行时如何判断当前应该使用哪套 token。

两篇的差异可以这样拆:

文章 主要问题 关键对象 验收重点
第 2 篇 token 体系怎么设计 AppColorsAppTheme.LIGHT/DARK 页面颜色来源统一
第 19 篇 token 何时切换、如何恢复 ConfigurationConstant.ColorModethemeModePreferences system/light/dark 三态一致

这个区分很重要。主题 token 设计得再漂亮,如果运行时状态恢复错了,页面仍然会出现“按钮显示深色,但实际背景是浅色”的错位。反过来,运行时三态设计清楚后,后续扩展护眼模式、节日主题或跟随系统强调色,也有稳定入口。

三、启动入口:EntryAbility 先回到系统默认

应用启动时,EntryAbility 先把应用颜色模式设置为 COLOR_MODE_NOT_SET

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
    }
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }
}

这一步不是最终用户偏好,只是安全默认值。它的作用是避免应用进程沿用某一次固定深色或固定浅色状态,然后在页面还没有恢复 Preferences 之前就先显示出错误颜色。

真正的用户偏好在 MainFrame.ets 里恢复。也就是说启动流程分两段:

  1. Ability 层先恢复系统默认,保证没有脏状态。
  2. 页面层读本地 Preferences,再决定是 systemlight 还是 dark

这比在 EntryAbility 里直接读取页面 Preferences 更清楚,因为 EntryAbility 只负责应用生命周期,页面才知道当前主题入口、按钮文案和 token 刷新方式。

四、状态模型:themeMode 表达选择,isDark 表达固定模式

MainFrame.ets 的主题状态不是一个布尔值,而是两个字段协作:

@State isDark: boolean = false;
@State themeMode: string = 'system';

themeMode 是用户选择,取值为 systemlightdarkisDark 则只在固定深色或浅色时表达当前页面应使用哪套 token。跟随系统时,页面不能直接信 isDark,而要实时读取系统配置。

核心判断在 effectiveIsDark()

private systemIsDark(): boolean {
  try {
    const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    return context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
  } catch (err) {
    return false;
  }
}

private effectiveIsDark(): boolean {
  if (this.themeMode === 'system') {
    return this.systemIsDark();
  }
  return this.isDark;
}

private palette(): AppColors {
  return AppTheme.palette(this.effectiveIsDark());
}

这里的关键点是“最终是否深色”独立成 effectiveIsDark()。页面里的按钮、卡片、地图遮罩、听书背景都不需要关心用户选择来自哪里,只要调用 this.palette()。主题策略被收敛在一个函数里,页面布局不会被三态逻辑污染。

深色首页状态

五、用户切换:三态循环不能丢掉 system

主题按钮文案由 themeButtonText() 负责,点击后由 switchThemeMode() 做三态循环:

private themeButtonText(): string {
  if (this.themeMode === 'system') {
    return '跟随系统';
  }
  if (this.themeMode === 'dark') {
    return '深色';
  }
  return '浅色';
}

private switchThemeMode(): void {
  if (this.themeMode === 'system') {
    this.applyThemeMode('light');
  } else if (this.themeMode === 'light') {
    this.applyThemeMode('dark');
  } else {
    this.applyThemeMode('system');
  }
  this.persistThemeMode();
}

这段实现的用户路径是:

跟随系统 -> 浅色 -> 深色 -> 跟随系统

为什么不是“浅色 -> 深色”两态切换?因为 system 是用户明确选择的模式,不是默认值的代名词。只要用户选过跟随系统,就应该被持久化,并在重启后继续保留。

六、applyThemeMode:把业务三态映射到系统 ColorMode

真正和 HarmonyOS 应用颜色模式交互的是 applyThemeMode()

private applyThemeMode(mode: string): void {
  this.themeMode = mode;
  this.isDark = mode === 'dark';
  try {
    const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (mode === 'dark') {
      context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
    } else if (mode === 'light') {
      context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
    } else {
      context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    }
  } catch (err) {
    hilog.warn(DOMAIN, TAG, 'apply theme mode failed: %{public}s', JSON.stringify(err));
  }
}

这段代码有两个职责:

  1. 更新页面状态,让 ArkUI 重新计算依赖 palette() 的颜色。
  2. 调用 setColorMode(),让应用层颜色模式与用户选择一致。

边界也很明确:mode === 'system' 时不能设置成浅色或深色,而是设置为 COLOR_MODE_NOT_SET,把选择权交还给系统。这里如果写错成 COLOR_MODE_LIGHT,按钮仍然显示“跟随系统”,但系统深色切换不会生效,是典型的状态错位。

七、Preferences 恢复:先读偏好,再 apply 一次

主题偏好和收藏、笔记、听书进度一样,都存放在同一个 Preferences 文件里:

const PREF_NAME: string = 'records_three_kingdoms';
const PREF_THEME_MODE: string = 'theme_mode';

恢复逻辑集中在 restorePreferences()

private restorePreferences(): void {
  if (this.pref === null) {
    this.applyThemeMode(this.themeMode);
    return;
  }
  try {
    const themeRaw: preferences.ValueType = this.pref.getSync(PREF_THEME_MODE, 'system');
    if (typeof themeRaw === 'string' && (themeRaw === 'system' || themeRaw === 'light' || themeRaw === 'dark')) {
      this.themeMode = themeRaw;
    }
    const commentedRaw: preferences.ValueType = this.pref.getSync(PREF_HAS_COMMENTED, false);
    this.hasCommented = commentedRaw === true;
  } catch (err) {
    hilog.warn(DOMAIN, TAG, 'restore preferences failed');
  }
  this.applyThemeMode(this.themeMode);
}

这段代码有一个容易忽略的顺序:读到 themeRaw 后不是只赋值 this.themeMode,而是在最后调用 applyThemeMode(this.themeMode)。因为页面状态和应用颜色模式都需要重新同步。

如果只恢复 themeMode,按钮文案可能是对的,但 setColorMode() 没有被重新执行。用户强停再启动后,系统层颜色模式可能仍停留在默认状态,页面 token 刷新也不稳定。

持久化则很轻:

private persistThemeMode(): void {
  if (this.pref === null) {
    return;
  }
  try {
    this.pref.putSync(PREF_THEME_MODE, this.themeMode);
    this.pref.flush();
  } catch (err) {
    hilog.warn(DOMAIN, TAG, 'persist theme mode failed');
  }
}

这里必须调用 flush()。主题选择属于用户偏好,不能只在内存里变更,否则热切换正常,强停重启后就会回到默认 system

八、token 刷新:页面只读 palette,不直接写颜色策略

library1/src/main/ets/theme/AppTheme.ets 保存了浅色和深色两套 token:

export class AppTheme {
  static readonly LIGHT: AppColors = new AppColors(
    '#8A4F25',
    '#FFF3E7D6',
    '#B8742A',
    '#FFFFF1DC',
    '#FFF8F2E8',
    '#FFFFFBF4',
    '#FFFFFFFF',
    '#FF251A12',
    '#FF6E5A49',
    '#FF9A8777',
    '#FFFFFFFF',
    '#FFE8D7C4',
    '#3329170B',
    '#CC20140C',
    '#0020140C'
  );

  static readonly DARK: AppColors = new AppColors(
    '#D7A15A',
    '#332B2114',
    '#F0BD72',
    '#2AE6B467',
    '#FF0E1110',
    '#FF171A18',
    '#FF20241F',
    '#FFF6EBDD',
    '#FFC8B9A7',
    '#FF8D8072',
    '#FF171A18',
    '#FF2C302C',
    '#66000000',
    '#DD050607',
    '#00050607'
  );

  static palette(isDark: boolean): AppColors {
    return isDark ? AppTheme.DARK : AppTheme.LIGHT;
  }
}

页面使用方式非常统一:

.backgroundColor(this.palette().pageBg)
.fontColor(this.palette().textPrimary)
.backgroundColor(this.palette().cardBg)
.border({ width: { top: 1 }, color: this.palette().divider })

在听书和地图这类更复杂的区域,仍然通过 effectiveIsDark() 做少量资源或遮罩分支:

private audioCardBg(): ResourceStr {
  return this.effectiveIsDark() ? $r('app.media.audio_card_bg_dark') : $r('app.media.audio_card_bg_light');
}

这说明主题系统不是“所有颜色都必须只有 token”,而是策略要集中。纯颜色走 palette(),深浅色资源走 effectiveIsDark(),不要在各个 Builder 里反复判断 themeMode === 'dark'

九、调试命令:先确认链路,再验证恢复

本次写稿没有修改 ArkTS 源码,先用 rg 把主题链路定位出来:

git status --short

用于确认工作区已有改动,避免把文章发布素材和用户正在改的工程文件混在一起。

rg -n "setColorMode|ConfigurationConstant|onCreate" entry/src/main/ets/entryability/EntryAbility.ets

用于确认 Ability 启动时是否先恢复系统默认颜色模式。

rg -n "themeMode|systemIsDark|effectiveIsDark|applyThemeMode|restorePreferences|persistThemeMode" library2/src/main/ets/pages/MainFrame.ets

用于确认页面三态、Preferences 恢复和持久化链路。

rg -n "AppTheme|LIGHT|DARK|palette" library1/src/main/ets/theme/AppTheme.ets

用于确认 token 来源和深浅色出口。

如果这次是代码改动而不是文章写稿,我会继续跑构建和设备验证:

.\hvigorw.bat --mode module -p module=entry@default assembleHap
hdc list targets
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms

真机验证时要看三个点:按钮文案是否恢复、页面背景是否跟随、强停重启后 theme_mode 是否仍然生效。只看热切换是不够的,因为热切换不能证明 Preferences 持久化成功。

十、问题复盘:深浅色系统最常见的错位

主题切换的坑通常不是代码报错,而是状态看起来“差一点对”:

踩坑点 现象 当前项目处理
用布尔值表达主题 无法区分固定深色和跟随系统深色 themeMode 保存三态
system 映射成浅色 按钮显示跟随系统,但系统深色不生效 COLOR_MODE_NOT_SET 交还系统
只恢复文案不 apply 重启后按钮对了,页面 token 没同步 restorePreferences() 末尾调用 applyThemeMode()
切换后不 flush 热切换正常,强停后丢失 persistThemeMode() 调用 flush()
页面到处判断 dark 后续扩展困难,局部颜色漏改 页面统一读 palette()
资源和颜色混在一起 深色图标、背景、遮罩维护混乱 图片资源用 effectiveIsDark(),颜色用 token
Ability 和页面职责混乱 生命周期里塞入页面状态逻辑 Ability 设默认,页面恢复偏好

本项目最重要的经验是:system 必须作为一等状态保存。它不是“没有选择”,而是用户明确选择“让系统决定”。只要这个概念稳定,后面的 token 刷新、资源切换和持久化恢复都会顺很多。

十一、工程验收清单:第 19 篇对应的检查点

检查项 预期结果
EntryAbility.onCreate() 调用 setColorMode(COLOR_MODE_NOT_SET) 冷启动先回到系统默认
themeMode 默认值为 system 新用户默认跟随系统
主题按钮显示 跟随系统 / 浅色 / 深色 用户能识别当前模式
switchThemeMode() 三态循环 不会丢掉 system 状态
applyThemeMode('dark') 设置 COLOR_MODE_DARK
applyThemeMode('light') 设置 COLOR_MODE_LIGHT
applyThemeMode('system') 设置 COLOR_MODE_NOT_SET
restorePreferences() 校验取值 非法值不会污染状态
恢复后再次调用 applyThemeMode() 页面 token 和应用颜色模式同步
persistThemeMode() 写入 PREF_THEME_MODEflush() 强停重启后偏好仍在
页面颜色通过 this.palette() 获取 不在 Builder 里散落主题判断
资源分支通过 effectiveIsDark() 获取 深色资源与浅色资源一致切换

这张表可以直接作为后续改主题功能时的自检单。只要其中任意一项失效,用户看到的通常不是崩溃,而是“按钮、系统设置、页面颜色三者不一致”。

十二、小结:主题系统的核心是状态语义

第 19 篇拆的不是一套新的视觉风格,而是深浅色模式在真实 HarmonyOS App 里的运行时闭环。EntryAbility 先把应用恢复到系统默认,MainFrame.etsthemeMode 保存 system/light/dark 三态,applyThemeMode() 映射到 ConfigurationConstant.ColorModerestorePreferences() 保证强停重启后偏好继续生效,最终所有页面颜色通过 AppTheme.palette() 读取 token。

这套实现的价值在于状态语义清楚。system 不是布尔值的一个变体,而是用户偏好的一种;isDark 不是用户选择,而是固定模式下的渲染状态;palette() 不是业务逻辑,而是页面颜色出口。边界分清后,深浅色切换才不会在页面越来越复杂时变成散落各处的条件判断。

下一篇会把视角从单个功能体验转到发布前工程闭环,梳理 Hvigor 构建、真机日志、截图素材、CSDN 本地预检和内容管理“高质量”标识如何组成一套可复用的交付检查流程。

Logo

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

更多推荐