系列背景:这个系列记录一个本地优先的三国历史知识 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 / isDarklibrary2/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 体系怎么设计AppColors、AppTheme.LIGHT/DARK页面颜色来源统一
第 19 篇token 何时切换、如何恢复ConfigurationConstant.ColorMode、themeMode、Preferencessystem/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,再决定是 system、light 还是 dark。

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

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

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

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

themeMode 是用户选择,取值为 system、light、dark。isDark 则只在固定深色或浅色时表达当前页面应使用哪套 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_MODE 并 flush()强停重启后偏好仍在
页面颜色通过 this.palette() 获取不在 Builder 里散落主题判断
资源分支通过 effectiveIsDark() 获取深色资源与浅色资源一致切换

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

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

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

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

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

Logo

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

更多推荐