HarmonyOS 6.1 全场景实战|《灵犀厨房》【深色模式】三个键让你的 App 一键切换黑夜白天
摘要 :你的《灵犀厨房》在凌晨 2 点被用户打开时,弹出一片刺眼的白光------这体验就像夜钓时有人拿探照灯照你眼睛。你可能会说"不就是加个深色模式嘛,资源目录丢两套颜色就行了"。但问题来了:用户想要手动控制 ("我就要永久深色"),又想要跟随系统 ("日落自动切"),还得记住偏好("下次打开别变回来")。三态切换 + 持久化 + 即时生效,这听起来简单,踩坑的人可不少。本篇将基于 HarmonyOS 6.1.0(API 23),从底层 API 原理出发,揭秘最优雅的"三模式深色切换"完整方案。
一、引言:从"刺痛双眼"到"视觉自由"
经过前 21 篇的积累,《灵犀厨房》已经接入了桌面卡片、元服务、语音操控。但当用户进入"我的"页面时,一个体验漏洞被暴露:
| 场景 | 操作路径 | 痛点 |
|---|---|---|
| 深夜刷食谱 | 打开 App → 白色背景刺眼 | 系统已切深色,但 App 我行我素,眼睛抗议 😫 |
| 白天强光下 | 浅色模式看不清文字 | App 跟着系统走了深色,户外完全看不见文字 😵 |
| 每次冷启动 | 上次手动切了深色,重启后又变回浅色 | 用户偏好没被记住,每次都得重新设置 😡 |
| 颜色对比度 | 深色下文字灰成一片,卡片和背景融在一起 | 简单"反色"导致可读性崩溃,WCAG 对比度不达标 📉 |
而深色模式的三态管理完美解决:
🎯 跟随系统 = 自动化。手动深色 = 强制暗。手动浅色 = 强制亮。Toggle 一拨,瞬间切换。Preferences 持久化,冷启动自动恢复。三态共存,永不打架。
HarmonyOS 的 setColorMode 不是简单的"设一个值就完事"------它涉及资源系统的深浅目录自动匹配 、ApplicationContext 的全局模式设置 、以及与系统回调 onConfigurationUpdate 的博弈关系。理解这三者的底层逻辑,才算真正掌握了纯血鸿蒙的深色模式。
二、核心原理:setColorMode 的三体博弈
2.1 你必须先知道的三个概念
在正式编码前,彻底搞懂这三个概念的关系,能帮你避开 90% 的坑:
| 概念 | 本质 | 作用域 | 优先级 |
|---|---|---|---|
| 资源目录 system | resources/dark/ vs resources/base/ 目录下的同名资源文件 |
全局、按限定词自动匹配 | 最低(被 setColorMode 覆盖) |
setColorMode() |
通过 ApplicationContext 强制指定颜色模式 |
整个应用进程 | 最高(一旦设置,资源目录失效) |
onConfigurationUpdate |
系统全局配置变更的回调通知 | AbilityStage / UIAbility |
仅监听(不直接控制) |
核心规则:
- 如果调用了
setColorMode(COLOR_MODE_DARK),则应用永久深色 ,onConfigurationUpdate不再触发 - 如果调用了
setColorMode(COLOR_MODE_NOT_SET),则恢复"跟随系统",系统切深色时资源自动从dark/目录加载 onConfigurationUpdate只在没有调用过setColorMode的情况下才会被系统回调
⚠️ 血泪警告 :很多开发者以为
setColorMode(COLOR_MODE_NOT_SET)是"恢复默认",调用后onConfigurationUpdate会重新触发。大错特错! 一旦应用进程内调用过setColorMode(哪怕只一次),后续onConfigurationUpdate就永久静默了。只有重启应用才能恢复监听。这就是为什么我们的架构必须在启动时判定偏好,而不是依赖运行时回调。
2.2 三模式架构全景图

文本图进一步详细阐述:
┌─────────────────────────────────────────────────────┐
│ 用户操作 │
│ ┌──────────────────────────────────────────────┐ │
│ │ Toggle 开关 → 保存 'dark'/'light' 到 DB │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────┐ │
│ │ ProfileTabContent (UI 层) │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ isDark: @Local boolean │ │ │
│ │ │ toggleDarkMode(isDark) → setColorMode │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ save/flush │
│ ┌──────────────────▼───────────────────────────┐ │
│ │ Preferences 'app_settings' │ │
│ │ key: 'darkMode' values: 'dark'/'light'/'auto'│ │
│ └──────────────────┬───────────────────────────┘ │
│ │ read on cold start │
│ ┌──────────────────▼───────────────────────────┐ │
│ │ EntryAbility.onCreate() │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ initColorMode() │ │ │
│ │ │ → 读 DB │ │ │
│ │ │ → 'dark' → setColorMode(DARK) │ │ │
│ │ │ → 'light' → setColorMode(LIGHT) │ │ │
│ │ │ → 'auto' → DO NOTHING (跟随系统) │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────┐ │
│ │ 资源系统 (§r 加载) │ │
│ │ dark/element/color.json ⇄ base/element/ │ │
│ │ ↓ 自动匹配 ↓ │ │
│ │ 所有组件的 backgroundColor / fontColor │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
三、分层架构:数据流全景图
按照《灵犀厨房》四层架构,深色模式涉及三个层次:
| 层级 | 职责 | 关键文件/API |
|---|---|---|
| 资源层 | 定义深浅两套颜色、自动按模式匹配 | dark/element/color.json、base/element/color.json |
| Ability 层 | 冷启动时读取用户偏好、调用 setColorMode 初始化 |
EntryAbility.initColorMode() |
| UI 层 | Toggle 开关交互、即时切换 + 持久化 | ProfileTabContent.toggleDarkMode() |
分层交互图:
#mermaid-svg-9bTXyZkJ8TyeOXyV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9bTXyZkJ8TyeOXyV .error-icon{fill:#552222;}#mermaid-svg-9bTXyZkJ8TyeOXyV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9bTXyZkJ8TyeOXyV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .marker.cross{stroke:#333333;}#mermaid-svg-9bTXyZkJ8TyeOXyV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9bTXyZkJ8TyeOXyV p{margin:0;}#mermaid-svg-9bTXyZkJ8TyeOXyV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster-label text{fill:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster-label span{color:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster-label span p{background-color:transparent;}#mermaid-svg-9bTXyZkJ8TyeOXyV .label text,#mermaid-svg-9bTXyZkJ8TyeOXyV span{fill:#333;color:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .node rect,#mermaid-svg-9bTXyZkJ8TyeOXyV .node circle,#mermaid-svg-9bTXyZkJ8TyeOXyV .node ellipse,#mermaid-svg-9bTXyZkJ8TyeOXyV .node polygon,#mermaid-svg-9bTXyZkJ8TyeOXyV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .rough-node .label text,#mermaid-svg-9bTXyZkJ8TyeOXyV .node .label text,#mermaid-svg-9bTXyZkJ8TyeOXyV .image-shape .label,#mermaid-svg-9bTXyZkJ8TyeOXyV .icon-shape .label{text-anchor:middle;}#mermaid-svg-9bTXyZkJ8TyeOXyV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .rough-node .label,#mermaid-svg-9bTXyZkJ8TyeOXyV .node .label,#mermaid-svg-9bTXyZkJ8TyeOXyV .image-shape .label,#mermaid-svg-9bTXyZkJ8TyeOXyV .icon-shape .label{text-align:center;}#mermaid-svg-9bTXyZkJ8TyeOXyV .node.clickable{cursor:pointer;}#mermaid-svg-9bTXyZkJ8TyeOXyV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .arrowheadPath{fill:#333333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9bTXyZkJ8TyeOXyV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9bTXyZkJ8TyeOXyV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9bTXyZkJ8TyeOXyV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster text{fill:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV .cluster span{color:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9bTXyZkJ8TyeOXyV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9bTXyZkJ8TyeOXyV rect.text{fill:none;stroke-width:0;}#mermaid-svg-9bTXyZkJ8TyeOXyV .icon-shape,#mermaid-svg-9bTXyZkJ8TyeOXyV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9bTXyZkJ8TyeOXyV .icon-shape p,#mermaid-svg-9bTXyZkJ8TyeOXyV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9bTXyZkJ8TyeOXyV .icon-shape .label rect,#mermaid-svg-9bTXyZkJ8TyeOXyV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9bTXyZkJ8TyeOXyV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9bTXyZkJ8TyeOXyV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9bTXyZkJ8TyeOXyV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 💾 存储
🎨 UI 层
🚀 Ability 层
读取
返回模式
拨动
写入
调用或不调用
调用
setColorMode
自动加载
📁 资源层
dark/element/color.json
base/element/color.json
EntryAbility
initColorMode()
ProfileTabContent
Toggle 开关
toggleDarkMode()
Preferences
app_settings
文本图进一步详细阐述:
┌──────────────────────────────────────────────────────────────┐
│ 资源层 (Resource) │
│ │
│ base/element/color.json dark/element/color.json │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ "bg_page": "#FFF5F0" │ ←──────→ │ "bg_page": "#121212" │ │
│ │ "bg_card": "#FFFFFF" │ 同名异值 │ "bg_card": "#2D2D2D" │ │
│ │ "text_primary":"#333"│ │ "text_primary":"#F5F5"│ │
│ │ "divider": "#F0F0F0" │ │ "divider": "#404040" │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ ↕ ↕ │
│ light 模式 dark 模式 │
└──────────────────────────────────────────────────────────────┘
│ │
┌────────┴─────────┐ ┌─────────┴─────────┐
│ COLOR_MODE_LIGHT │ │ COLOR_MODE_DARK │
│ setColorMode() │ │ setColorMode() │
│ 或 跟随系统浅色 │ │ 或 跟随系统深色 │
└──────────────────┘ └───────────────────┘
│ │
┌─────────────┴─────────────────────────────────────┴──────────┐
│ ApplicationContext.setColorMode() │
│ ↓ ↑ │
│ ● 手动设置 → 永久锁定 │
│ ● 不设置 → 跟随系统 │
└──────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────────────────────────────────────────┐
│ Preferences 'app_settings' │
│ key: 'darkMode' → 'dark' | 'light' | 'auto' │
└──────────────────────────────────────────────────────────────┘
│
┌─────────┴──────────┐
│ read on onCreate() │
▼ ▼
EntryAbility ProfileTabContent
initColorMode() toggleDarkMode()
四、关键实现步骤
Step 1:资源双目录 ------ 深色模式的"弹药库"
在 entry/src/main/resources 下创建 dark/element/color.json,定义与 base/element/color.json 完全同名的颜色资源,但赋予深色系色值。
关键设计原则:
- 同名是关键------系统通过
name字段匹配,不是通过目录结构 - 对比度优先------深色模式不是简单反转,要保证 WCAG AA 标准(文字对比度 ≥ 4.5:1)
- 背景分层------卡片要比页面亮一度,分割线要比卡片暗一度,构建视觉层次
json
// dark/element/color.json(深色模式颜色)
{
"color": [
{ "name": "bg_page", "value": "#121212" }, // 最深:页面背景
{ "name": "bg_secondary", "value": "#1E1E1E" }, // 次深:次要区域
{ "name": "bg_card", "value": "#2D2D2D" }, // 稍亮:卡片层次
{ "name": "text_primary", "value": "#F5F5F5" }, // 高亮白:主要文字
{ "name": "text_secondary","value": "#CCCCCC" }, // 中灰:次要文字
{ "name": "text_hint", "value": "#999999" }, // 暗灰:提示文字
{ "name": "divider", "value": "#404040" } // 分割线:比背景亮但不过分
]
}
json
// base/element/color.json(浅色模式颜色,保持原风格)
{
"color": [
{ "name": "bg_page", "value": "#FFF5F0" },
{ "name": "bg_secondary", "value": "#F5F5F5" },
{ "name": "bg_card", "value": "#FFFFFF" },
{ "name": "text_primary", "value": "#333333" },
{ "name": "text_secondary","value": "#666666" },
{ "name": "text_hint", "value": "#999999" },
{ "name": "divider", "value": "#F0F0F0" }
]
}
在代码中,组件通过 $r('app.color.xxx') 引用颜色:
typescript
// ProfileTabContent.ets
Column()
.backgroundColor($r('app.color.bg_secondary'))
Text('灵犀厨房·智能烹饪助手')
.fontColor($r('app.color.text_primary'))
Step 2:EntryAbility ------ 冷启动的"模式裁判"
在 EntryAbility.onCreate() 中调用 initColorMode()。这是一个只在启动时执行一次的逻辑,它决定了整个应用进程的生命周期内使用哪种颜色模式。
typescript
// EntryAbility.ets
import { ConfigurationConstant } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.initColorMode(); // ★ 在最早的生命周期调用
// ... 其他初始化
}
/** 初始化颜色模式:读取用户偏好或跟随系统 */
private async initColorMode(): Promise<void> {
try {
const pref = await preferences.getPreferences(this.context, 'app_settings');
const darkMode = await pref.get('darkMode', 'auto') as string;
if (darkMode === 'dark') {
this.context.getApplicationContext().setColorMode(
ConfigurationConstant.ColorMode.COLOR_MODE_DARK
);
hilog.info(DOMAIN, 'LingxiKitchen', '应用用户设置:深色模式');
} else if (darkMode === 'light') {
this.context.getApplicationContext().setColorMode(
ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
);
hilog.info(DOMAIN, 'LingxiKitchen', '应用用户设置:浅色模式');
} else {
// auto模式 → 什么都不做,系统自动根据全局设置加载资源
hilog.info(DOMAIN, 'LingxiKitchen', '跟随系统深色模式');
}
} catch (err) {
hilog.warn(DOMAIN, 'LingxiKitchen',
'读取颜色模式偏好失败: %{public}s', JSON.stringify(err));
// ★ 失败兜底:不做任何设置,让资源系统自动处理
}
}
}
启动判定流程:

Step 3:ProfileTabContent ------ 用户切换的"控制台"
在"我的"页面添加 Toggle 开关,并实现 toggleDarkMode() 方法。
typescript
// ProfileTabContent.ets
import { ConfigurationConstant, common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
@ComponentV2
export struct ProfileTabContent {
@Local isDark: boolean = false; // Toggle 绑定状态
private prefContext: preferences.Preferences | null = null;
async aboutToAppear(): Promise<void> {
// ... 其他初始化
// ★ 读取深色模式状态,同步 Toggle 位置
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.prefContext = await preferences.getPreferences(context, 'app_settings');
const darkMode = await this.prefContext.get('darkMode', 'auto') as string;
if (darkMode === 'dark') {
this.isDark = true;
} else if (darkMode === 'light') {
this.isDark = false;
} else {
// auto 模式:读取当前系统实际颜色模式
this.isDark = context.config.colorMode ===
ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
}
} catch (err) {
console.error('[ProfileTabContent] 读取深色模式失败:', JSON.stringify(err));
}
}
/** 切换深色模式 → 写 DB + 调 setColorMode 双保险 */
private async toggleDarkMode(isDark: boolean): Promise<void> {
try {
// 1. 写 DB:持久化用户偏好
if (this.prefContext) {
await this.prefContext.put('darkMode', isDark ? 'dark' : 'light');
await this.prefContext.flush(); // ★ 确保立即落盘
}
// 2. 调 API:即时生效
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
context.getApplicationContext().setColorMode(
isDark ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK
: ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
);
console.info('[ProfileTabContent] 深色模式已切换:', isDark ? '深色' : '浅色');
} catch (err) {
console.error('[ProfileTabContent] 切换深色模式失败:', JSON.stringify(err));
}
}
@Builder
buildSettingsGroup() {
Column() {
// ... 其他设置项
// ★ 深色模式 Toggle
Row() {
Row({ space: 10 }) {
Text('🌙').fontSize(18);
Text('深色模式').fontSize(15).fontColor('#333')
}
Blank()
Toggle({ type: ToggleType.Switch, isOn: $$this.isDark })
.selectedColor('#FF6B35')
.onChange(async (isOn: boolean) => {
await this.toggleDarkMode(isOn);
})
}.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
}
}
}
切换流程:
ApplicationContext Preferences ProfileTabContent 👤 用户 ApplicationContext Preferences ProfileTabContent 👤 用户 #mermaid-svg-sSmneM8CpqBqWRRO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sSmneM8CpqBqWRRO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sSmneM8CpqBqWRRO .error-icon{fill:#552222;}#mermaid-svg-sSmneM8CpqBqWRRO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sSmneM8CpqBqWRRO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sSmneM8CpqBqWRRO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sSmneM8CpqBqWRRO .marker.cross{stroke:#333333;}#mermaid-svg-sSmneM8CpqBqWRRO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sSmneM8CpqBqWRRO p{margin:0;}#mermaid-svg-sSmneM8CpqBqWRRO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sSmneM8CpqBqWRRO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sSmneM8CpqBqWRRO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sSmneM8CpqBqWRRO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sSmneM8CpqBqWRRO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sSmneM8CpqBqWRRO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sSmneM8CpqBqWRRO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sSmneM8CpqBqWRRO .sequenceNumber{fill:white;}#mermaid-svg-sSmneM8CpqBqWRRO #sequencenumber{fill:#333;}#mermaid-svg-sSmneM8CpqBqWRRO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sSmneM8CpqBqWRRO .messageText{fill:#333;stroke:none;}#mermaid-svg-sSmneM8CpqBqWRRO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sSmneM8CpqBqWRRO .labelText,#mermaid-svg-sSmneM8CpqBqWRRO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sSmneM8CpqBqWRRO .loopText,#mermaid-svg-sSmneM8CpqBqWRRO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sSmneM8CpqBqWRRO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sSmneM8CpqBqWRRO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sSmneM8CpqBqWRRO .noteText,#mermaid-svg-sSmneM8CpqBqWRRO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sSmneM8CpqBqWRRO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sSmneM8CpqBqWRRO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sSmneM8CpqBqWRRO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sSmneM8CpqBqWRRO .actorPopupMenu{position:absolute;}#mermaid-svg-sSmneM8CpqBqWRRO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sSmneM8CpqBqWRRO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sSmneM8CpqBqWRRO .actor-man circle,#mermaid-svg-sSmneM8CpqBqWRRO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sSmneM8CpqBqWRRO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 拨动 Toggle isDark 变化 (双向绑定) put('darkMode', 'dark'/'light') flush() 立即落盘 setColorMode(DARK/LIGHT) 锁定颜色模式 资源系统自动重绘 界面瞬间切换
Step 4:关于 onConfigurationUpdate 的真相
很多旧版教程会让你在 EntryAbility 中重写 onConfigurationUpdate 来监听深色模式变化。但在这个三模式架构中,我们刻意不重写它。原因:
typescript
// ★ 不推荐的写法------我们故意不用
onConfigurationUpdate(newConfig: Configuration): void {
// 这个回调在 setColorMode 被调用后就永久静默了
// 写了也是白写,还可能误导维护者以为"它在工作"
}
| 场景 | onConfigurationUpdate 是否触发 |
原因 |
|---|---|---|
应用未调用过 setColorMode |
✅ 正常触发 | 系统检测到配置变更,回调通知 |
应用调用过 setColorMode(DARK) |
❌ 永久静默 | 应用已锁定颜色模式,系统不再通知 |
应用调用过 setColorMode(NOT_SET) |
❌ 依然静默 | 进程级标记不会清除,只能重启 |
我们的方案完全绕开了
onConfigurationUpdate。在"跟随系统"模式下,资源体系自动切换,无需代码介入。在"手动模式"下,用户操作直接驱动setColorMode,也不需要系统回调。这就是"零回调"的优雅。
五、代码增删改清单
| 文件 | 新增/修改 | 职责 |
|---|---|---|
entry/src/main/resources/dark/element/color.json |
新增 | 深色模式颜色资源,与 base 目录完全同名 |
entry/src/main/ets/entryability/EntryAbility.ets |
修改 | 新增 initColorMode(),启动时判定偏好 |
entry/src/main/ets/components/ProfileTabContent.ets |
修改 | 新增 isDark 状态、toggleDarkMode() 方法和 Toggle UI |
六、血泪避坑总结
| 现象 | 真相 | 解决方案 |
|---|---|---|
getConfiguration() 拿不到 colorMode |
该 API 返回的是 Promise<Configuration>,直接访问 .colorMode 编译报错 |
使用 context.config.colorMode(同步属性)或 ApplicationContext.getColorMode()(仅特定版本) |
updateConfiguration 方法不存在 |
ResourceManager 没有这个 API |
使用 ApplicationContext.setColorMode() 是唯一的合法设置方式 |
getContext(this) 已废弃 |
API 23 中将此方法标记为 deprecated | 使用 this.getUIContext().getHostContext() as common.UIAbilityContext |
| Toggle 拨了但重启后不记住 | 只调了 setColorMode,没写 Preferences.flush() |
必须同时写 DB + flush(),put 后不 flush 等于白写 |
onConfigurationUpdate 不触发 |
只要进程内调用过 setColorMode(哪怕仅一次),回调永久静默 |
不要在"手动模式"下依赖此回调,架构上直接用 Preferences 驱动的初始化逻辑 |
| 深色模式文字看不清 | 简单把白色改黑色、黑色改白色,对比度崩溃 | 遵循 WCAG AA 标准的颜色分层设计:文字 #F5F5F5、次要 #CC、提示 #999,不能直接用纯黑 #000 |
七、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 模式存储介质 | Preferences | 轻量、跨进程、API 稳定。put + flush 保证持久化 |
| 颜色模式设置方式 | setColorMode |
唯一的系统级 API,调用后立即全局生效,资源系统自动切换目录 |
| 获取当前模式 | context.config.colorMode |
UIAbilityContext 同步属性,无需异步 await,性能最优 |
| 获取 Context | getUIContext().getHostContext() |
getContext(this) 已废弃,新 API 通过 UIContext 桥接获取 |
| 启动时初始化逻辑 | onCreate() 中同步调用 |
在最早的生命周期设置颜色模式,避免启动闪白/闪黑 |
| 跟随系统实现方式 | 不调用 setColorMode |
资源系统默认就按系统设置匹配目录,不设置 = 跟随 |
| 深色模式图标 | 普通 Emoji 🌙 |
系统 SymbolGlyph 存在资源名称不稳定的问题,Emoji 零风险 |
八、运行验证
验证场景 1:手动切换深色模式
- 打开《灵犀厨房》→ 进入"我的"页面
- 找到"深色模式" Toggle,将其拨到开启状态
- 期望结果:整个页面立即切换为深色主题。卡片背景变深(#2D2D2D)、文字变亮(#F5F5F5)、分割线变暗(#404040)
验证场景 2:重启后记忆偏好
- 在验证场景 1 的基础上,完全退出应用(从任务管理器中划掉)
- 重新打开《灵犀厨房》
- 期望结果:应用启动后直接呈现深色主题,Toggle 开关已处于"开启"位置
验证场景 3:跟随系统(默认行为)
- 将"深色模式" Toggle 拨到关闭状态(即跟随系统)
- 进入系统设置 → 显示 → 开启深色模式
- 期望结果:应用自动切换为深色主题(无需重启)
- 切回系统浅色模式 → 应用自动恢复浅色
验证场景 4:跨会话持久化
- 手动开启深色模式 → 关闭应用 → 重启手机 → 重新打开应用
- 期望结果:深色模式偏好依然保持
九、总结与下篇预告
本篇我们基于 HarmonyOS 6.1.0(API 23)的底层 API 原理,为《灵犀厨房》构建了一套完整的三模式深色切换体系。核心要点:
- 资源层 :
dark/与base/同名颜色资源,系统自动匹配,无需代码干预 - 初始化层 :
EntryAbility.onCreate()→ 读Preferences→ 按偏好调/不调setColorMode - 交互层 :
ProfileTabContentToggle →toggleDarkMode()→ 写 DB + 调 API,双保险 - 架构哲学 :
auto模式不做任何设置,"什么都不做比做太多更优雅" - 避坑核心 :永不依赖
onConfigurationUpdate(它被setColorMode永久静默),用context.config.colorMode替代getConfiguration()
API 使用对比速查表
| 想要做什么 | ❌ 错误用法 | ✅ 正确用法 |
|---|---|---|
| 设置深色模式 | resourceManager.getConfiguration().colorMode = DARK |
context.getApplicationContext().setColorMode(DARK) |
| 获取当前模式 | resourceManager.getConfiguration().colorMode |
context.config.colorMode |
| 获取 Context (组件内) | getContext(this) (deprecated) |
this.getUIContext().getHostContext() as common.UIAbilityContext |
| 持久化用户偏好 | pref.put(key, value) |
pref.put(key, value) + pref.flush() |
| 跟随系统 | setColorMode(COLOR_MODE_NOT_SET) |
不调用 setColorMode(资源系统自动跟随) |
📚 本系列持续更新中,敬请期待,下一篇更精彩。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇代码 + 架构文档 + Flask 后端如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。
纯血鸿蒙,用心造厨。我们下一篇见!