HarmonyOS 6.1 全场景实战|《灵犀厨房》【深色模式】三个键让你的 App 一键切换黑夜白天

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 仅监听(不直接控制)

核心规则

  1. 如果调用了 setColorMode(COLOR_MODE_DARK),则应用永久深色onConfigurationUpdate 不再触发
  2. 如果调用了 setColorMode(COLOR_MODE_NOT_SET),则恢复"跟随系统",系统切深色时资源自动从 dark/ 目录加载
  3. 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.jsonbase/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:手动切换深色模式

  1. 打开《灵犀厨房》→ 进入"我的"页面
  2. 找到"深色模式" Toggle,将其拨到开启状态
  3. 期望结果:整个页面立即切换为深色主题。卡片背景变深(#2D2D2D)、文字变亮(#F5F5F5)、分割线变暗(#404040)

验证场景 2:重启后记忆偏好

  1. 在验证场景 1 的基础上,完全退出应用(从任务管理器中划掉)
  2. 重新打开《灵犀厨房》
  3. 期望结果:应用启动后直接呈现深色主题,Toggle 开关已处于"开启"位置

验证场景 3:跟随系统(默认行为)

  1. 将"深色模式" Toggle 拨到关闭状态(即跟随系统)
  2. 进入系统设置 → 显示 → 开启深色模式
  3. 期望结果:应用自动切换为深色主题(无需重启)
  4. 切回系统浅色模式 → 应用自动恢复浅色

验证场景 4:跨会话持久化

  1. 手动开启深色模式 → 关闭应用 → 重启手机 → 重新打开应用
  2. 期望结果:深色模式偏好依然保持

九、总结与下篇预告

本篇我们基于 HarmonyOS 6.1.0(API 23)的底层 API 原理,为《灵犀厨房》构建了一套完整的三模式深色切换体系。核心要点:

  • 资源层dark/base/ 同名颜色资源,系统自动匹配,无需代码干预
  • 初始化层EntryAbility.onCreate() → 读 Preferences → 按偏好调/不调 setColorMode
  • 交互层ProfileTabContent Toggle → 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 后端

如果你觉得这篇文章对你有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬。你的支持,是我继续输出高质量技术内容的全部动力。

纯血鸿蒙,用心造厨。我们下一篇见!