【三国志 App 实战系列 19】HarmonyOS ArkTS 深浅色跟随系统:从配置读取到主题 token 刷新
系列背景:这个系列记录一个本地优先的三国历史知识 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 体系怎么设计 | AppColors、AppTheme.LIGHT/DARK |
页面颜色来源统一 |
| 第 19 篇 | token 何时切换、如何恢复 | ConfigurationConstant.ColorMode、themeMode、Preferences |
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 里恢复。也就是说启动流程分两段:
- Ability 层先恢复系统默认,保证没有脏状态。
- 页面层读本地 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));
}
}
这段代码有两个职责:
- 更新页面状态,让 ArkUI 重新计算依赖
palette()的颜色。 - 调用
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 本地预检和内容管理“高质量”标识如何组成一套可复用的交付检查流程。
更多推荐


所有评论(0)