HarmonyOS 深色模式最佳实践与案例详解

本文基于 HarmonyOS 的深色模式适配体系,系统介绍颜色适配、图片适配、Web 组件适配、模式切换机制、反色能力及最佳实践。


一、引言

深夜,你窝在沙发上刷手机,屏幕亮白刺眼;清晨,你打开应用,界面却暗沉难辨——深色模式的缺失,让无数用户在这些场景中被迫忍受不佳的视觉体验。正因如此,深色模式(Dark Mode)早已从“加分项”演变为现代操作系统的“必选项”。从 iOS 到 Android ,从微信到钉钉,主流平台与应用纷纷将其纳入基础体验。对于 HarmonyOS 而言,深色模式不仅顺应了这一趋势,更借助 OLED 屏幕的像素级发光特性,在暗光环境下显著降低视觉疲劳,同时节省 30%~60% 的屏幕功耗——这对移动设备的续航尤为关键。

然而,为应用适配深色模式远非“把背景变黑、文字变白”那般简单。真实项目中,开发者通常面临三重挑战:第一,工作量巨大——动辄数十上百个页面,每个页面的背景、文字、分割线、图标、卡片都需要逐一调整,若全靠硬编码条件判断,代码将变得臃肿且难以维护;第二,品牌一致性难保——品牌色、Logo、核心视觉元素需要在深浅模式下都保持辨识度,简单的反色算法往往导致色彩失真;第三,混合内容复杂——应用内嵌入的 Web 页面、服务卡片、第三方组件,各有各的渲染机制,深色适配需要跨技术栈协同。

面对这些痛点,HarmonyOS 从 API 9 开始便提供了一套系统级、声明式的深色模式适配方案。其核心是 资源限定词(Resource Qualifiers) 机制:开发者只需在 base/dark/ 两套资源目录中分别定义浅色与深色下的颜色、图片等资源,系统便会根据当前模式自动选择对应资源,业务代码中几乎无需任何 if-else 判断。这种“数据与逻辑分离”的设计,既降低了适配成本,又避免了运行时条件分支带来的性能损耗。更进一步,HarmonyOS 还提供了 setColorMode() 接口,允许应用内独立控制深浅模式,以及 allowForceDark() 反色能力(API 21+),为快速适配或敏感区域保护提供了灵活补充。

本文将从资源适配、系统跟随、主动控制、反色机制到真实案例(喵屿 App),系统化地讲解 HarmonyOS 深色模式适配的完整技术路径。无论你是准备启动新项目,还是正在改造存量代码,都能从中找到一套可落地、可扩展的实践方案。接下来,我们从最基础的资源适配开始,逐步深入。


二、资源适配:深浅色模式的基础设施

HarmonyOS 的资源管理框架内置了对深色模式的支持。其核心设计理念是“资源覆盖”——开发者只需在 resources/ 目录下创建 dark/ 子目录,放置深色模式专属的资源文件,系统就会根据当前颜色模式自动选择对应资源。这种设计的好处在于:

  • 分离关注点:颜色、图片等视觉资源与业务逻辑解耦,UI 代码只关心“用什么资源名”,不关心“当前是什么模式”。
  • 可维护性强:新增或修改某个颜色值,只需修改资源文件,无需遍历所有页面。
  • 性能优异:资源选择在系统底层完成,没有运行时字符串比较或反射开销。

接下来,我们分别从颜色、图片和 Web 组件三个维度,详细讲解资源适配的具体做法。

2.1 颜色资源适配

颜色是深色模式适配中最基础、最频繁的工作。HarmonyOS 推荐的目录结构如下:

entry/src/main/resources/
├── base/                          # 默认资源(浅色模式兜底)
│   └── element/
│       └── color.json             # 浅色颜色定义
├── dark/                          # 深色模式限定词目录
│   └── element/
│       └── color.json             # 深色颜色覆盖(仅需定义与浅色不同的颜色)
└── rawfile/

需要注意的是,dark/ 目录并不是要求你把所有颜色都重新定义一遍,而是仅覆盖那些在深色模式下需要变化的颜色。未在 dark/ 中定义的颜色,系统会自动回退到 base/ 中的值。这既减少了冗余配置,也降低了维护成本。

下面给出一个典型的浅色配色方案(base/element/color.json):

// entry/src/main/resources/base/element/color.json
// 浅色模式下的默认配色方案
{
  "color": [
    {
      "name": "start_window_background",   // 启动页背景色
      "value": "#FFFFFF"
    },
    {
      "name": "page_background",           // 页面主背景色
      "value": "#F1F3F5"
    },
    {
      "name": "text_primary",              // 一级文字颜色
      "value": "#182431"
    },
    {
      "name": "text_secondary",            // 二级文字颜色
      "value": "#99182431"
    },
    {
      "name": "card_background",           // 卡片背景色
      "value": "#FFFFFF"
    },
    {
      "name": "divider_color",             // 分割线颜色
      "value": "#E5E5E5"
    },
    {
      "name": "brand_primary",             // 品牌主色(深浅模式一般不同)
      "value": "#007DFF"
    }
  ]
}

对应的深色覆盖配置(dark/element/color.json):

// entry/src/main/resources/dark/element/color.json
// 深色模式下的配色方案(仅覆盖需要变化的颜色,其余沿用 base/ 的值)
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#000000"                  // 启动页:白色 → 黑色
    },
    {
      "name": "page_background",
      "value": "#1A1A2E"                  // 页面背景:淡灰 → 深蓝黑
    },
    {
      "name": "text_primary",
      "value": "#EEEEEE"                  // 主文字:深色 → 亮色
    },
    {
      "name": "text_secondary",
      "value": "#AAAAAA"                  // 次文字:半透明深色 → 半透明亮色
    },
    {
      "name": "card_background",
      "value": "#2A2A3C"                  // 卡片背景:白色 → 深灰蓝
    },
    {
      "name": "divider_color",
      "value": "#333344"                  // 分割线:浅灰 → 深灰
    },
    {
      "name": "brand_primary",
      "value": "#0A84FF"                  // 品牌主色:略微调亮以适应深色背景
    }
  ]
}

在组件中引用这些颜色资源时,只需使用 $r('app.color.xxx') 语法,系统会自动根据当前模式选择 base/dark/ 中的值:

// 正确用法:通过 $r() 引用颜色资源,系统自动根据当前模式选择 base/ 或 dark/ 的值
// 无需任何条件判断代码

@Entry
@Component
struct Index {
  build() {
    Column() {
      Text('鸿蒙深色模式适配')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        // $r('app.color.text_primary') 在浅色模式返回 #182431,深色模式返回 #EEEEEE
        .fontColor($r('app.color.text_primary'))

      Text('这是一段说明文字')
        .fontSize(16)
        // 次级文字同样自动适配
        .fontColor($r('app.color.text_secondary'))
    }
    .width('100%')
    .height('100%')
    // 页面背景色自动适配
    .backgroundColor($r('app.color.page_background'))
  }
}

关键约束与最佳实践

  • dark/ 目录下的资源文件 name 必须与 base/完全一致,系统按名称匹配而非按位置匹配。
  • 只有应用存在 dark/ 资源目录,系统才将应用识别为支持深色模式,并在系统切换深色模式时主动通知应用。
  • 所有 UI 组件的颜色必须使用 $r('app.color.xxx') 引用,禁止硬编码色值(如 '#FFFFFF'Color.White)。硬编码会破坏自动适配机制,导致深色模式下部分元素“漏网”。
  • 建议采用语义化命名(如 text_primarycard_background),而非色值命名(如 color_white),这样在深色模式下语义仍然成立。

2.2 图片资源适配

与颜色类似,图片资源也可以为深色模式提供专属版本。例如,Logo 图在浅色背景下通常是深色版本,而在深色背景下则需要换成浅色版本,否则会“隐身”或对比度不足。

目录结构如下:

entry/src/main/resources/
├── base/media/
│   └── logo.png           # 浅色模式使用的 Logo
└── dark/media/
    └── logo.png           # 深色模式使用的 Logo(颜色适配暗色背景)

在代码中引用时,依然使用 $r('app.media.logo'),系统会根据当前模式自动选择对应文件:

// 图片引用方式与颜色相同,系统自动选择对应资源文件
Image($r('app.media.logo'))
  .width(120)
  .height(60)
  .objectFit(ImageFit.Contain)

对于 SVG 图标,更推荐使用“单套资源 + 动态着色”的方案。这样无需为每个图标准备两套图片文件,减少了包体积和维护成本:

// SVG 图标通过 fillColor 动态着色,无需两套图片文件
// fillColor 同样可以使用 $r() 引用颜色资源实现自动适配
Image($r('app.media.icon_settings'))
  .width(24)
  .height(24)
  .fillColor($r('app.color.text_primary'))  // 深色模式自动变亮色

选型建议:对于纯色图标,优先使用 SVG + fillColor;对于复杂的位图(如照片、纹理),则应准备两套图片资源,或者使用 allowForceDark(false) 保护(详见第五章)。

2.3 Web 组件适配

在混合开发场景中,应用内嵌入的 Web 页面同样需要适配深色模式。HarmonyOS 的 Web 组件提供了 darkMode 属性和 forceDarkAccess 能力,让开发者可以灵活控制 Web 内容的深色表现。

ArkTS 侧配置

// entry/src/main/ets/pages/WebPage.ets
import { webview } from '@kit.ArkWeb';
import { ConfigurationConstant } from '@kit.AbilityKit';

@Entry
@Component
struct WebPage {
  // Web 控制器
  controller: webview.WebviewController = new webview.WebviewController();
  // 存储当前颜色模式
  @StorageProp('currentColorMode') currentMode: number =
    ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        // darkMode 设置:Auto = 跟随系统深色模式
        .darkMode(this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
          ? WebDarkMode.On
          : WebDarkMode.Off)
        // 允许 Web 页面使用强制深色算法(对未适配深色的传统网页有效)
        .forceDarkAccess(true)
        // 深色模式下 Web 组件背景色适配
        .backgroundColor(this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
          ? '#1A1A2E'
          : '#FFFFFF')
    }
  }
}

前端 HTML 侧适配

现代 Web 标准已经支持 prefers-color-scheme 媒体查询,前端开发者可以据此实现 CSS 变量切换:

<!-- rawfile/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <!-- 告知浏览器此页面支持深色模式 -->
    <meta name="color-scheme" content="light dark">
    <style>
        /* 浅色模式的 CSS 变量(默认) */
        :root {
            --bg-color: #FFFFFF;
            --text-color: #182431;
            --link-color: #007DFF;
        }
        /* 深色模式下覆盖 CSS 变量 */
        @media (prefers-color-scheme: dark) {
            :root {
                --bg-color: #1A1A2E;
                --text-color: #EEEEEE;
                --link-color: #0A84FF;
            }
        }
        body {
            background-color: var(--bg-color);
            color: var(--text-color);
        }
        a { color: var(--link-color); }
    </style>
</head>
<body>
    <h1>Web 页面深色模式适配示例</h1>
</body>
</html>

这里的关键是 color-scheme meta 标签,它告诉浏览器该页面已经考虑了深色模式,避免浏览器额外应用默认的反色策略,从而保证前端样式与 ArkTS 侧协调一致。


三、应用跟随系统的深色模式

完成了资源适配之后,下一步是让应用能够感知系统级深色模式的变化,并自动切换。HarmonyOS 提供了非常简洁的 API 来完成这一任务。

3.1 基础配置:跟随系统

EntryAbility.onCreate() 中设置颜色模式为 COLOR_MODE_NOT_SET,即可让应用跟随系统设置:

// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'EntryAbility';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(0x0000, TAG, 'EntryAbility onCreate');

    /**
     * 设置应用颜色模式为跟随系统
     * COLOR_MODE_NOT_SET(值:-1):跟随系统自动切换
     * COLOR_MODE_LIGHT(值:1):强制浅色模式
     * COLOR_MODE_DARK(值:0):强制深色模式
     * 
     * 注意:此设置应在 Ability 启动最早阶段执行,确保所有页面创建前生效
     */
    this.context.getApplicationContext()
      .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
  }
}

setColorMode() 是一个全局设置,作用于整个应用的所有 UI 组件。一旦设置,所有通过 $r() 引用的资源都会自动切换到对应的颜色模式。

为什么要在 onCreate 中尽早设置? 因为如果设置太晚,某些页面可能已经在浅色模式下完成了布局和绘制,切换时会出现短暂的闪烁或颜色错乱。最佳实践是在 Ability 生命周期的最早阶段(onCreate)完成模式设置。

3.2 监听系统模式切换

当用户在系统设置中切换深色/浅色模式时,如果应用正处于前台,我们需要能够感知这一变化并做出响应。HarmonyOS 提供了两种方式:

方式一:通过 onConfigurationUpdate 监听

UIAbilityonConfigurationUpdate 回调会在系统配置(包括颜色模式、语言、屏幕方向等)发生变化时被调用。我们可以在其中将当前颜色模式存入 AppStorage,供全局组件使用:

// EntryAbility.onConfigurationUpdate — 系统配置变更回调
onConfigurationUpdate(newConfig: Configuration): void {
  // 将当前颜色模式存入 AppStorage,供全局组件响应
  AppStorage.setOrCreate('currentColorMode', newConfig.colorMode);
  hilog.info(0x0000, TAG, `Color mode changed to: ${newConfig.colorMode}`);
}

方式二:页面中使用 @StorageProp + @Watch 响应

在任意页面组件中,我们可以通过 @StorageProp 订阅 AppStorage 中的颜色模式值,并用 @Watch 监听其变化:

// 任意页面组件
import { ConfigurationConstant } from '@kit.AbilityKit';

@Entry
@Component
struct SettingsPage {
  // 从 AppStorage 读取颜色模式,并监听变化
  @StorageProp('currentColorMode') @Watch('onColorModeChanged')
  currentMode: number = ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;

  /**
   * 颜色模式切换时的回调
   * 适用于需要在代码层面做额外处理的场景(如动态加载不同图表配色)
   */
  onColorModeChanged(): void {
    if (this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK) {
      console.info('切换到深色模式,执行额外逻辑');
      // 例如:更换 ECharts 图表的暗色主题
    } else {
      console.info('切换到浅色模式');
    }
  }

  build() {
    Column() {
      Text(`当前模式:${this.currentMode}`)
    }
  }
}

这种方式的优势在于,你可以在任意组件中响应模式变化,而不仅仅是 Ability 层面。例如,某些自定义绘制组件可能需要根据模式重新计算颜色值,@Watch 回调就提供了这样的入口。


四、应用主动设置深浅色模式

跟随系统固然方便,但很多用户仍希望应用内能提供独立的“深色/浅色/跟随系统”开关,以便在夜间阅读时单独将应用调暗,而不影响系统其他界面。HarmonyOS 的 setColorMode() 接口同样支持这种主动控制。

下面是一个完整的主题设置页面示例,包含了三种选项的 UI 和持久化逻辑:

// entry/src/main/ets/pages/ThemeSettings.ets
import { ConfigurationConstant, common } from '@kit.AbilityKit';
import PreferenceUtil from 'resource/src/main/ets/utils/PreferenceUtil';

// 存储在 Preferences 中的主题 key
const KEY_THEME_MODE = 'userThemeMode';

@Entry
@Component
struct ThemeSettings {
  // 当前选中模式:-1=跟随系统, 0=深色, 1=浅色
  @State selectedMode: number = ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;
  // 提示弹窗控制器
  promptAction = this.getUIContext().getPromptAction();

  aboutToAppear(): void {
    // 从本地持久化读取用户上次选择的主题模式
    const savedMode = PreferenceUtil.getPreferenceByNameSync('app_data', KEY_THEME_MODE, -1) as number;
    this.selectedMode = savedMode;
  }

  /**
   * 应用用户选择的颜色模式
   * @param mode - ConfigurationConstant.ColorMode 枚举值
   */
  applyColorMode(mode: number): void {
    // 通过 ApplicationContext 设置全局颜色模式
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    ctx.getApplicationContext().setColorMode(mode);
    // 持久化用户选择
    PreferenceUtil.putPreferenceByNameSync('app_data', KEY_THEME_MODE, mode);
    this.selectedMode = mode;

    this.promptAction.showToast({
      message: mode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
        ? '已切换为深色模式'
        : mode === ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT
          ? '已切换为浅色模式'
          : '已切换为跟随系统'
    });
  }

  build() {
    Column({ space: 12 }) {
      Text('主题模式').fontSize(18).fontWeight(FontWeight.Bold)

      // 跟随系统
      Row() {
        Text('跟随系统')
        Radio({
          value: 'system',
          group: 'themeGroup'
        })
          .checked(this.selectedMode === ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET)
          .onChange((isChecked: boolean) => {
            if (isChecked) {
              this.applyColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16 })

      // 浅色模式
      Row() {
        Text('浅色模式')
        Radio({
          value: 'light',
          group: 'themeGroup'
        })
          .checked(this.selectedMode === ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT)
          .onChange((isChecked: boolean) => {
            if (isChecked) {
              this.applyColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16 })

      // 深色模式
      Row() {
        Text('深色模式')
        Radio({
          value: 'dark',
          group: 'themeGroup'
        })
          .checked(this.selectedMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK)
          .onChange((isChecked: boolean) => {
            if (isChecked) {
              this.applyColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.page_background'))
  }
}

设计要点

  • 持久化:用户的选择需要保存在本地(如 Preferences),这样下次启动应用时才能恢复正确的模式。在 EntryAbility.onCreate() 中读取该值并调用 setColorMode()(参考第七章的喵屿案例)。
  • 即时生效setColorMode() 是全局且实时的,调用后所有正在显示的页面都会立即刷新颜色,无需重启应用。
  • 与系统解耦:当用户选择“浅色”或“深色”时,应用不再跟随系统变化;选择“跟随系统”则重新绑定系统事件。这一逻辑完全由应用层控制,系统 API 只提供设置能力,不干预应用的选择策略。

五、利用反色能力快速适配深色模式

对于存量项目或快速原型阶段,开发者可能希望以最小的改动实现深色模式的基本可用性。HarmonyOS 从 API 21 开始提供的 allowForceDark() 反色能力,正是为此而生。

5.1 allowForceDark 的基本原理

当组件启用反色时,系统会在深色模式下自动对其颜色值进行“智能反色”或“亮度变换”——通常是将浅色背景变为深色,深色文字变为浅色,同时保持一定色相偏移,避免颜色失真。这一过程由系统底层算法完成,开发者无需手动计算。

// allowForceDark API 来自 @kit.ArkUI,API 21+ 可用
@Entry
@Component
struct ForceDarkDemo {
  build() {
    Column() {
      // 区域一:启用反色 — 深色模式下文字自动变亮、背景自动变暗
      Column() {
        Text('这段文字在深色模式下会自动反色')
          .fontSize(20)
          .fontColor(Color.Blue)            // 深色模式自动变浅蓝色
      }
      .backgroundColor('#FFE0E0')            // 深色模式自动变暗红色
      .allowForceDark(true)                  // 启用反色能力
      .padding(16)
      .width('100%')
      .margin({ bottom: 16 })

      // 区域二:禁用反色 — 保持原有颜色不变
      Column() {
        Text('这段文字在任何模式下颜色不变')
          .fontSize(20)
          .fontColor(Color.Blue)            // 始终为 Blue
      }
      .backgroundColor('#FFE0E0')            // 始终为浅红色
      .allowForceDark(false)                 // 禁用反色能力
      .padding(16)
      .width('100%')
    }
    .padding(20)
  }
}

5.2 反色能力的继承与隔离

反色能力遵循以下继承规则,这使得开发者可以在大范围启用反色的同时,精准地“隔离”某些不需要反色的区域:

  • 父组件设置 allowForceDark(true):所有子组件默认继承反色能力。
  • 子组件设置 allowForceDark(false):该子组件及其后代不受反色影响,即便祖先设置了 allowForceDark(true)

这种“就近覆盖”的规则,与 CSS 的 color-scheme 继承机制类似,非常直观:

@Entry
@Component
struct ForceDarkInheritance {
  build() {
    Column() {
      // 父级启用反色
      Column() {
        Text('父组件启用反色,此文字会被反色')
          .fontColor(Color.Black)

        // 子组件禁用反色 — 隔离父级影响
        Row() {
          Button('我不受反色影响')
            .backgroundColor(Color.Grey)
        }
        .allowForceDark(false)  // 按钮及其内部元素不会被反色算法处理

      }
      .allowForceDark(true)
    }
    .width('100%').height('100%')
  }
}

5.3 典型使用场景与建议

场景 建议 原因
品牌 Logo、品牌色区域 allowForceDark(false) 品牌视觉需要保持一致性,反色可能改变品牌识别色
用户上传的图片 allowForceDark(false) 反色会破坏图片原始色彩,导致照片出现“负片”效果
普通文本内容 allowForceDark(true) 自动适配,减少适配工作,尤其适合快速迭代阶段
已做双套资源适配的区域 allowForceDark(false) 避免反色与手动适配冲突,造成双重变换

重要提示allowForceDark 要求 API 21+,低版本设备(如 compatibleSdkVersion 低于 21)无法使用。对于需要兼容 API 17~20 的项目,只能完全依赖资源限定词方式适配深色模式。此外,反色算法并非万能,对于渐变、阴影、透明度等复杂样式可能效果不佳,因此仍建议优先采用资源适配方案。


六、深浅色模式的使用建议与注意事项

6.1 设计层面的最佳实践

  1. 语义化命名:使用 text_primarypage_background 等语义名称,而非 color_whitecolor_black_1 等色值命名。语义命名在深色模式下更有意义,也方便未来拓展更多主题(如高对比度模式)。
  2. 品牌色微调:深色模式下的品牌色应略微调亮(如 #007DFF#0A84FF),保持视觉辨识度,避免在暗背景下显得“发灰”。
  3. 对比度合规:主文字对比度 ≥ 4.5:1,大号文字(≥18sp)≥ 3:1,这是 WCAG 无障碍标准的基本要求。可以使用在线工具检测对比度。
  4. 纯黑避免使用:深色背景推荐 #1A1A1A#121212(Material Design 推荐),而非 #000000。纯黑背景在 OLED 屏幕上虽然省电,但容易产生“黑洞”效应,削弱层次感。
  5. 分割线减淡:深色模式下分割线颜色应更暗(例如从 #E5E5E5 变为 #333344),避免亮线在暗背景上过于突兀。
  6. 阴影调整:深色模式下阴影应减弱或使用亮色边框替代,因为深色背景上投射深色阴影几乎不可见。

6.2 常见问题排查

问题 可能原因 解决方案
深色模式切换无效 未创建 dark/ 资源目录 创建并填充 dark/element/color.json
部分文字未适配 代码中硬编码了色值 改用 $r('app.color.xxx')
Web 组件全黑 未配置 Web 深色模式 添加 .darkMode(WebDarkMode.Auto)
品牌 Logo 变色 反色算法影响了图片 添加 .allowForceDark(false)(API 21+)
SVG 图标颜色异常 未设置 fillColor 使用 Image.fillColor($r(...)) 动态着色
切换模式后部分页面未刷新 页面未使用 @StorageProp 监听 在 Ability 的 onConfigurationUpdate 中更新 AppStorage

七、喵屿应用中的深色模式实践

理论终须落地。喵屿App 作为一款已在鸿蒙应用市场上架的产品,其深色模式适配经历了从“能用”到“好用”的迭代过程。本节我们将以喵屿的真实代码为例,展示深色模式在完整应用中的集成方式。
在这里插入图片描述

在这里插入图片描述

7.1 双套颜色 Token 体系

喵屿将应用中所有颜色收敛为语义化 Token,集中存放于 base/element/color.json(浅色)和 dark/element/color.json(深色)中。所有卡片、页面、按钮均通过 $r('app.color.xxx') 引用这些 Token,系统根据当前模式自动选择对应值。

浅色 Token 示例(摘录):

// entry/src/main/resources/base/element/color.json(浅色 — 摘录)
{
  "color": [
    { "name": "card_bg",              "value": "#FFFBF5" },
    { "name": "card_text_primary",    "value": "#1A1A1A" },
    { "name": "card_text_secondary",  "value": "#334155" },
    { "name": "card_text_hint",       "value": "#999999" },
    { "name": "card_accent",          "value": "#FF6B35" },
    { "name": "card_badge_error_bg",  "value": "#FFF0F0" },
    { "name": "card_badge_error_text","value": "#D81E06" },
    { "name": "card_badge_warn_bg",   "value": "#FFF8E1" },
    { "name": "card_badge_warn_text", "value": "#EA9518" },
    { "name": "card_divider",         "value": "#EEEEEE" },
    { "name": "card_brand",           "value": "#BBBBBB" }
  ]
}

深色 Token 覆盖(仅列出需要变化的项):

// entry/src/main/resources/dark/element/color.json(深色 — 摘录)
// 仅覆盖需要变化的颜色,其余沿用 base/ 的值
{
  "color": [
    { "name": "card_bg",              "value": "#1E1E1E" },
    { "name": "card_text_primary",    "value": "#E5E5E5" },
    { "name": "card_text_secondary",  "value": "#CCCCCC" },
    { "name": "card_text_hint",       "value": "#888888" },
    { "name": "card_accent",          "value": "#FF8C55" },
    { "name": "card_badge_error_bg",  "value": "#3D1818" },
    { "name": "card_badge_error_text","value": "#FF6B6B" },
    { "name": "card_badge_warn_bg",   "value": "#3D3018" },
    { "name": "card_badge_warn_text", "value": "#FFC107" },
    { "name": "card_divider",         "value": "#333333" },
    { "name": "card_brand",           "value": "#5A5A5A" }
  ]
}

这种 Token 化设计的优势在于:当需要进行配色调整时,我们只需修改 color.json 文件,所有引用该 Token 的组件自动生效,无需逐个页面查找修改。

7.2 启动时读取用户偏好

喵屿在 EntryAbility.onCreate() 中,从 Preferences 读取用户上次选择的模式(KEY_SETTING_LIGHT_STATUS),并在页面加载前应用该模式,避免启动后二次切换造成的闪烁:

// entry/src/main/ets/entryability/EntryAbility.ets(摘录)
// 系统 API:setColorMode() — API 9+,ConfigurationConstant.ColorMode
import { ConfigurationConstant } from '@kit.AbilityKit';

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 初始化 Preferences 上下文
  PreferenceUtil.initContext(this.context.getApplicationContext());

  try {
    // 从本地持久化读取用户偏好模式:-1=自动, 0=深色, 1=浅色
    const lightStatus = PreferenceUtil.getPreferenceByNameSync(
      BaseConstants.DEFAULT_NAME,          // "catIsland"
      BaseConstants.KEY_SETTING_LIGHT_STATUS, // "lightStatus"
      1                                     // 默认浅色
    ) as number;
    // 在页面加载前设置颜色模式,避免启动后二次切换
    this.context.getApplicationContext().setColorMode(lightStatus);
  } catch (err) {
    hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s',
      JSON.stringify(err));
  }
}

7.3 设置页模式切换

喵屿的设置页右上角工具栏提供了模式切换入口,使用 bindContextMenu 弹出三选一菜单。菜单项分别对应“开灯”(浅色)、“关灯”(深色)和“自动”(跟随系统)。
在这里插入图片描述

// entry/src/main/ets/pages/Settings.ets(摘录)
// 系统 API:ConfigurationConstant.ColorMode — API 9+
import { ConfigurationConstant } from '@kit.AbilityKit';

@Component
export struct Settings {
  /*
   * lightStatus 取值:
   *   -1 = 自动(跟随系统)
   *    0 = 关灯(强制深色)
   *    1 = 开灯(强制浅色)
   */
  @State lightStatus: number = 1;

  aboutToAppear(): void {
    // 页面出现时从 Preferences 读取当前模式
    this.lightStatus = PreferenceUtil.getPreferenceByNameSync(
      BaseConstants.DEFAULT_NAME, BaseConstants.KEY_SETTING_LIGHT_STATUS, 1
    ) as number;
  }

  build() {
    // ...标题栏中显示当前模式图标...
    Row({ space: 12 }) {
      // 根据当前模式显示不同图标:自动 / 关灯 / 开灯
      Image(this.lightStatus == -1 ? $r('[resource].media.auto')
        : (this.lightStatus == 0 ? $r('[resource].media.no_light')
          : $r('[resource].media.light')))
        .size({ width: 30, height: 30 })
        .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.9 })
        .bindContextMenu(this.isShowLight, this.lightMenu, {
          enableArrow: true,
          arrowOffset: 15,
          placement: Placement.LeftTop,
          backgroundBlurStyle: BlurStyle.Thick,
          onDisappear: () => { this.isShowLight = false }
        })
        .onClick(() => {
          VibrateUtil.buttonVibrate(VibrateEffect.soft)
          this.isShowLight = true
        })
    }
  }

  /**
   * 模式切换菜单:开灯(浅色)/ 关灯(深色)/ 自动(跟随系统)
   * 每次选择后持久化到 Preferences,下次启动时自动应用
   */
  @Builder lightMenu() {
    Menu() {
      // 选项一:开灯 — 强制浅色模式
      MenuItem({
        startIcon: $r("[resource].media.light"),
        content: "开灯"
      })
        .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.9 })
        .onClick(() => {
          VibrateUtil.buttonVibrate(VibrateEffect.soft)
          // 通过 ApplicationContext 设置全局颜色模式
          this.getUIContext()
            .getHostContext()?.getApplicationContext()
            .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
          // 持久化用户选择
          this.lightStatus = 1
          PreferenceUtil.putPreferenceByName(
            BaseConstants.DEFAULT_NAME, BaseConstants.KEY_SETTING_LIGHT_STATUS,
            this.lightStatus
          )
        })

      // 选项二:关灯 — 强制深色模式
      MenuItem({
        startIcon: $r("[resource].media.no_light"),
        content: "关灯"
      })
        .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.9 })
        .onClick(() => {
          VibrateUtil.buttonVibrate(VibrateEffect.soft)
          this.lightStatus = 0
          this.getUIContext()
            .getHostContext()?.getApplicationContext()
            .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
          PreferenceUtil.putPreferenceByName(
            BaseConstants.DEFAULT_NAME, BaseConstants.KEY_SETTING_LIGHT_STATUS,
            this.lightStatus
          )
        })

      // 选项三:自动 — 跟随系统
      MenuItem({
        startIcon: $r("[resource].media.auto"),
        content: "自动"
      })
        .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.9 })
        .onClick(() => {
          VibrateUtil.buttonVibrate(VibrateEffect.soft)
          this.lightStatus = -1
          this.getUIContext()
            .getHostContext()?.getApplicationContext()
            .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
          PreferenceUtil.putPreferenceByName(
            BaseConstants.DEFAULT_NAME, BaseConstants.KEY_SETTING_LIGHT_STATUS,
            this.lightStatus
          )
        })
    }
  }
}

交互细节:喵屿通过 onClick 短按弹出弹窗,提升了易用性。用户选择后,setColorMode() 立即生效,同时将选择持久化,确保下次启动应用时恢复相同的模式。

7.4 服务卡片深色适配

喵屿包含三张服务卡片(catInfopetInfopetTodo),卡片进程独立于主应用,因此其深色模式需要单独配置。我们在 form_config.json 中为每张卡片设置 "colorMode": "auto",使得卡片能够自动跟随系统深色模式,而不受主应用内部切换的影响。

卡片页面中的所有颜色同样通过 $r('app.color.card_xxx') 引用,系统在卡片渲染时根据当前系统模式自动选择深浅色值。由于卡片资源与主应用共享同一套 dark/ 目录,因此颜色 Token 的定义保持一致,无需为卡片单独维护一套配色。

这种设计保证了主应用和卡片在深色模式下的视觉一致性,同时卡片代码中没有任何模式判断逻辑,保持了代码的简洁性。


八、总结

HarmonyOS 深色模式适配的核心思路是关注点分离:将颜色定义从业务逻辑中抽离到资源文件,借助系统资源限定词机制自动切换。这种设计不仅降低了适配成本,还提升了代码的可维护性和可测试性。

适配路径推荐(按优先级排序)

  1. 基础适配 — 创建 dark/element/color.json,定义语义化 Token,替换所有硬编码色值为 $r() 引用。
  2. 跟随系统EntryAbility.onCreate() 中调用 setColorMode(COLOR_MODE_NOT_SET),让应用自动响应系统变化。
  3. 用户控制 — 可选提供应用内深色/浅色/跟随系统三选一开关,并持久化用户选择。
  4. 图片与 Web 适配 — 深色图片放 dark/media/,Web 组件配置 darkMode 属性并配合前端 prefers-color-scheme
  5. 反色增强(API 21+) — 对品牌色、图片等敏感区域使用 allowForceDark(false) 保护,其他区域可开启反色作为快速兜底。

核心原则

  • 所有颜色使用 $r('app.color.xxx') 引用,禁止硬编码。
  • dark/ 目录只需覆盖需要变化的颜色,未覆盖的自动回退到 base/
  • 语义化命名优于色值描述,便于长期维护。
  • 启动时尽早设置颜色模式,避免闪烁。
  • 注意 API 版本兼容(allowForceDark 需 API 21+),低版本设备需完全依赖资源限定词。

通过上述方法和实践,你的 HarmonyOS 应用将能够以较低的成本、较高的质量完成深色模式适配,为用户带来一致且舒适的视觉体验。


参考来源:

Logo

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

更多推荐