鸿蒙HarmonyOS主题适配实战 —— 让深色模式成为框架默认能力,而不是逐页改造
一、前言:深色模式不是"事后补",是"地基"
先看一个真实的开发场景。
团队赶进度,所有页面都用硬编码颜色写了:
// 随处可见的硬编码 Text('标题').fontColor('#182431') Column().backgroundColor('#FFFFFF') Row().backgroundColor('#F6F8FA')上线后产品说"我们要支持深色模式"。开发者打开真机切到 Dark Mode,发现:
白底白字:
#FFFFFF背景在深色模式下还是白色,文字也是白色,完全看不见。底部边缘发白:FloatingTabBar 的渐变蒙层
#66F1F3F5在深色模式下把每个页面底部都"漂白"了。渐变刺眼:浅色柔和渐变
#EEF4FF → #FFEDE2在深色模式下像两个亮色斑块,非常刺眼。状态栏文字看不见:
#182431的状态栏内容颜色在深色背景下消失。选中态文字低对比:选中项用了蓝色背景,文字也用蓝色,低到看不清。
于是团队花了两个星期,逐页排查、逐个替换颜色值。而且每加一个新页面,深色模式就要再补一次——永远在追赶。
这个故事的根因是:
深色模式不应该是"事后补",而应该是"地基"。如果主题基础设施在项目第一天就搭好,90% 的深色适配是自动的。
本文基于 ArkUILab E011 实验,讲清楚如何在鸿蒙里搭建 Theme Foundation,让深色模式成为框架默认能力。
二、鸿蒙主题能力地图:七层体系
E011 最核心的产出,是把鸿蒙的主题能力整理成一张七层地图。请先记住一句话:
鸿蒙的主题体系不是"一个深色开关",而是一个从系统令牌到动态主题的七层体系。
HarmonyOS Theme Capability Map ├── System Token(系统令牌) │ └── $r('sys.color.*') │ text / background / card / field / separator / state / primary │ → 系统自动跟随深浅色,无需自己写 dark 资源 │ ├── Resource Color(资源颜色) │ └── resources/base/element/color.json ← 浅色值 │ └── resources/dark/element/color.json ← 深色值 │ → 自定义语义资源,系统按 colorMode 自动选择 │ ├── Semantic Token(语义令牌) │ └── ThemeTokens.ets │ ThemePageBackground() / ThemeTextPrimary() / ... │ → 项目级稳定命名,隔离"系统令牌选哪个"的决策 │ ├── Component Token(组件令牌) │ └── 未来:每个框架的专用令牌 │ → 同一语义出现至少两次后再提取 │ ├── Dynamic Theme / WithTheme(动态主题) │ └── WithTheme 支持应用内换肤 │ → v1 不用,先靠系统/资源令牌 │ ├── Material(材质) │ └── HDS blur/material APIs + ADAPTIVE 策略 │ → 未来 API 26 systemMaterial │ └── Dark Mode(深色模式) └── 跟随系统 colorMode + 令牌/资源自动切换 → 不需要自己监听模式切换最关键的三层:
System Token——
$r('sys.color.*'),系统自动跟随深浅色,是你最该用的。Resource Color——
$r('app.color.*'),通过base/dark资源限定符自动切换。Semantic Token——
ThemeTokens.ets里的ThemePageBackground()等函数,给你的项目一个稳定的命名层。
三、令牌分层策略:四层迁移链
E011 定义了一条从系统到业务的令牌迁移链:
System Token 系统令牌(首选) ↓ Project Semantic Token 项目语义令牌(ThemeTokens.ets) ↓ Component Token 框架/组件令牌(同一语义出现两次后提取) ↓ Business Color 业务/内容/演示颜色(保持本地,标注用途)对应的七条规则:
默认用系统令牌:文字、页面、卡片、组件、输入框、分割线、点击/悬停、主色都用
$r('sys.color...')。语义令牌用于框架稳定命名:当框架需要一个不依赖"具体选哪个系统令牌"的稳定名字时,用
ThemeTokens.ets。应用资源颜色用于需要 dark 分值的语义:只有当一个值需要不同的浅色/深色值,且没有系统令牌能表达这个语义时,才加
$r('app.color...')。组件令牌等重复出现后再提取:同一语义至少出现两次或成为框架 API 表面时,才提取组件令牌。
业务颜色保持本地:但要标注是"内容身份""数据可视化""渐变演示""蒙层实验"还是"官方 API 探针"。
不要因为效果在深色模式下失败就删除它:先问"它是否应该变成一个有 base/dark 分值的语义资源"。
填充主控件用专用按钮语义对:不要用通用 accent 令牌,用
ThemePrimaryButtonBackground()+ThemePrimaryButtonForeground()。
四、系统令牌:深色模式的"免费午餐"
4.1 什么是系统令牌?
鸿蒙提供了一套系统级颜色令牌,通过$r('sys.color.*')引用。这些令牌自动跟随系统深浅色模式——你不需要写任何 dark 资源,系统会帮你切换。
E011 审计发现,ArkUILab 活跃代码中使用了18 个系统颜色令牌:
ohos_id_color_background 页面背景 ohos_id_color_sub_background 次级页面背景 ohos_id_color_list_card_bg 卡片背景 ohos_id_color_component_normal 组件常规填充 ohos_id_color_component_activated 组件激活态 ohos_id_color_text_primary 主要文字 ohos_id_color_text_secondary 次要文字 ohos_id_color_text_tertiary 第三级文字 ohos_id_color_text_field_bg 输入框背景 ohos_id_color_text_field_sub_bg 输入框次级背景 ohos_id_color_list_separator 列表分割线 ohos_id_color_subheading_separator 子标题分割线 ohos_id_color_click_effect 点击效果 ohos_id_color_hover 悬停效果 ohos_id_color_primary 主色 ohos_id_color_secondary 次色 ohos_id_color_tertiary 第三色 ohos_id_color_fourth 第四色4.2 怎么用?
直接在代码里引用:
// 文字 Text('标题').fontColor($r('sys.color.ohos_id_color_text_primary')) Text('说明').fontColor($r('sys.color.ohos_id_color_text_secondary')) // 背景 Column().backgroundColor($r('sys.color.ohos_id_color_list_card_bg')) // 分割线 Divider().color($r('sys.color.ohos_id_color_list_separator'))这是深色模式最省心的方式——切到 Dark Mode 时,这些令牌自动变成深色版本的值,你什么都不用做。
4.3 审计数据
E011 迁移后扫描结果:
96 处 $r('sys.color...') 引用 18 个活跃系统令牌最常用的类别:text_secondary、list_card_bg、list_separator、primary、component_normal、text_field_bg。
五、语义令牌层:ThemeTokens.ets
5.1 为什么需要语义令牌?
系统令牌很好,但有一个问题:名字是系统级的,不是项目级的。
如果你的代码里到处写$r('sys.color.ohos_id_color_sub_background'),当某天你想把页面背景换一个系统令牌,要全局搜索替换。更糟的是,不同开发者可能对"页面背景"用了不同的系统令牌,导致不一致。
语义令牌层解决这个问题:给项目一个稳定的命名,隔离"选哪个系统令牌"的决策。
5.2 ThemeTokens.ets 的结构
// 页面背景 export function ThemePageBackground(): ResourceColor { return $r('sys.color.ohos_id_color_sub_background'); } // 卡片/表面背景 export function ThemeSurfaceBackground(): ResourceColor { return $r('sys.color.ohos_id_color_list_card_bg'); } // 组件常规填充 export function ThemeComponentBackground(): ResourceColor { return $r('sys.color.ohos_id_color_component_normal'); } // 文字 export function ThemeTextPrimary(): ResourceColor { return $r('sys.color.ohos_id_color_text_primary'); } export function ThemeTextSecondary(): ResourceColor { return $r('sys.color.ohos_id_color_text_secondary'); } export function ThemeTextTertiary(): ResourceColor { return $r('sys.color.ohos_id_color_text_tertiary'); } // 分割线 export function ThemeDividerColor(): ResourceColor { return $r('sys.color.ohos_id_color_list_separator'); } // 输入框 export function ThemeTextFieldBackground(): ResourceColor { return $r('sys.color.ohos_id_color_text_field_bg'); }5.3 完整语义令牌清单
E011 沉淀的语义令牌共 25+ 个,按用途分类:
类别 | 令牌 | 底层来源 |
|---|---|---|
页面 |
|
|
表面 |
|
|
组件 |
|
|
文字 |
|
|
强调 |
|
|
OnAccent |
|
|
主按钮 |
|
|
主按钮前景 |
|
|
分割线 |
|
|
输入框 |
|
|
透明 |
| 固定透明值 |
阴影 |
|
|
蒙层 |
|
|
搜索菜单 |
|
|
渐变 |
|
|
底部蒙层 |
|
|
浮层材质 |
|
|
5.4 使用方式
在业务代码里,优先用语义令牌,不要直接用系统令牌:
import { ThemeTextPrimary, ThemePageBackground, ThemeSurfaceBackground } from './theme/ThemeTokens'; build() { Column() { Text('标题').fontColor(ThemeTextPrimary()) } .backgroundColor(ThemePageBackground()) }例外:在一次性令牌实验或官方 API 探针代码里,直接用$r('sys.color...')是允许的。
六、base/dark 资源限定符:自定义语义的双值
6.1 什么时候需要自定义资源?
系统令牌覆盖了大部分场景,但有些语义系统令牌表达不了:
卡片阴影的 alpha 值
Overlay 蒙层的 alpha 值
OnAccent 文字颜色(在激活蓝背景上的前景色)
搜索菜单的半透明材质
渐变色块
底部渐变蒙层
浮层材质颜色
这些需要$r('app.color.*')——通过resources/base和resources/dark的资源限定符自动切换。
6.2 浅色值:base/element/color.json
{ "color": [ { "name": "arkuilab_shadow_card", "value": "#18243114" }, { "name": "arkuilab_overlay_scrim", "value": "#1824311F" }, { "name": "arkuilab_search_menu_background", "value": "#F2FFFFFF" }, { "name": "arkuilab_search_menu_border", "value": "#FFFFFFFF" }, { "name": "arkuilab_on_accent", "value": "#FFFFFFFF" }, { "name": "arkuilab_gradient_tonal_start", "value": "#EEF4FF" }, { "name": "arkuilab_gradient_tonal_end", "value": "#FFEDE2" }, { "name": "arkuilab_bottom_gradient_mask", "value": "#66F1F3F5" }, { "name": "arkuilab_floating_material_light", "value": "#FFFFFFFF" } ] }6.3 深色值:dark/element/color.json
{ "color": [ { "name": "arkuilab_shadow_card", "value": "#00000066" }, { "name": "arkuilab_overlay_scrim", "value": "#00000066" }, { "name": "arkuilab_search_menu_background", "value": "#F21D2024" }, { "name": "arkuilab_search_menu_border", "value": "#33FFFFFF" }, { "name": "arkuilab_on_accent", "value": "#FFFFFFFF" }, { "name": "arkuilab_gradient_tonal_start", "value": "#1B2738" }, { "name": "arkuilab_gradient_tonal_end", "value": "#35271F" }, { "name": "arkuilab_bottom_gradient_mask", "value": "#6613171D" }, { "name": "arkuilab_floating_material_light", "value": "#FF1D2024" } ] }关键点:
两个 JSON 文件的key 完全一致,只是值不同。
系统根据当前 colorMode自动选择读哪个文件——你不需要写
if (isDark)。浅色柔和渐变
#EEF4FF → #FFEDE2在深色模式下变成深色渐变#1B2738 → #35271F,保留了"柔和渐变"的视觉语言,只是亮度适配了暗色环境。
6.4 ⚠️ 踩坑一:底部蒙层在深色模式发白
这是 E011 最经典的踩坑:
FloatingTabBar 底部有一个渐变蒙层,浅色值是#66F1F3F5(半透明浅灰)。如果不提供 dark 值,深色模式下这个浅灰蒙层会让每个页面底部都发白。
修复:提供 dark 值#6613171D(半透明深色),让蒙层在深色模式下也是暗的。
规则:底部边缘是主题审计的一部分。TabBar 蒙层、模糊材质、渐变 overlay 必须和页面背景一起审计。
七、主题审计报告:真实数据
E011 迁移后的审计数据:
7.1 系统令牌使用
96 处 $r('sys.color...') 引用 18 个活跃系统令牌 最常用:text_secondary / list_card_bg / list_separator / primary / component_normal / text_field_bg7.2 硬编码颜色残留
130 处 HEX 引用 75 个不同 HEX 值 Color.White:1 处(FloatingTabBarShell 的 HDS systemMaterialEffect.lightColor,保留为官方探针) Color.Black / Color.Gray / RGB / ARGB:0 处7.3 必须后续迁移的硬编码
残留位置 | 问题 | 对策 |
|---|---|---|
| 不自适应 | 需要 |
Menu/ActionSheet/Feedback titleBar 透明背景 | 应该用令牌 | 下次改动这些文件时迁移到 |
| 有意保留的视觉探针 | 重复使用后应变成组件令牌 |
7.4 允许保留的硬编码
残留位置 | 为什么保留 |
|---|---|
转场/Action/Menu/Feedback Lab 的演示渐变 | 实验视觉语言的一部分 |
Quick Access 条目的业务身份色 | 内容身份,不是结构色 |
ActionSheet 蒙层值 | 等选择面框架有专用组件令牌后再迁移 |
HDS 官方模糊 TabBar 蒙层 | 等设备对比确认令牌替代后迁移 |
八、迁移实战:怎么改才对
8.1 迁移前的错误写法
// ❌ 硬编码结构色 Text('标题').fontColor('#182431') Text('说明').fontColor('#56606C') Column().backgroundColor('#F6F8FA') Column().backgroundColor('#FFFFFF') // 卡片 Divider().color('#E5E8EE') Column().backgroundColor('#F0F1F5') // 输入框8.2 迁移后的正确写法
// ✅ 语义令牌 Text('标题').fontColor(ThemeTextPrimary()) Text('说明').fontColor(ThemeTextSecondary()) Column().backgroundColor(ThemePageBackground()) Column().backgroundColor(ThemeSurfaceBackground()) // 卡片 Divider().color(ThemeDividerColor()) Column().backgroundColor(ThemeTextFieldBackground()) // 输入框8.3 渐变的正确处理
不要因为渐变在深色模式下失败就删掉它。正确的做法是把渐变终点变成有 base/dark 分值的语义资源:
// ❌ 硬编码渐变终点 .linearGradient({ angle: 135, colors: [['#EEF4FF', 0], ['#FFEDE2', 1]] }) // ✅ 语义渐变令牌(base/dark 自动切换) .linearGradient({ angle: 135, colors: [ [ThemeGradientTonalStart(), 0], [ThemeGradientTonalEnd(), 1] ] })规则:主题迁移不是视觉拍平。如果渐变、蒙层、材质是框架身份的一部分,保留效果,把终点/蒙层移到语义 base/dark 资源里。
8.4 主按钮的正确处理
每个填充背景令牌都需要一个前景搭档:
// ❌ 选中态文字用了背景/激活色,低对比 MenuItem({ content: '选项' }) .selectedOptionFontColor($r('sys.color.ohos_id_color_activated')) // 蓝字蓝底 .selectedOptionBgColor($r('sys.color.ohos_id_color_activated')) // ✅ 填充背景用激活蓝,前景用 onAccent 白 MenuItem({ content: '选项' }) .selectedOptionBgColor(ThemePrimaryButtonBackground()) // 蓝底 .selectedOptionFontColor(ThemePrimaryButtonForeground()) // 白字规则:蓝色填充控件应该用主按钮语义对(
ThemePrimaryButtonBackground+ThemePrimaryButtonForeground),不要用通用 accent 令牌。
九、框架审计:哪些框架已就绪
E011 对 ArkUILab 的所有框架做了主题就绪审计:
框架 | 主题状态 | 说明 |
|---|---|---|
Navigation Framework | 基本就绪 | HDS Navigation + title bar 令牌用系统色;窗口状态栏有一个 gap |
Navigation Header Framework | v1 就绪 | Header 标题/菜单/搜索已迁移到系统/语义令牌 |
FloatingTabBar Framework | v1 就绪 | 浮层材质和底部渐变蒙层用 base/dark 资源 |
ApplicationShell | 就绪 | 根背景用 |
Overlay Framework | v1 就绪 | 下拉浮层蒙层/卡片/输入/文字已迁移 |
Menu Framework | 部分 | 大部分用系统令牌;自定义视觉探针保留;API 26 systemMaterial 待 SDK |
Selection Framework | 部分 | 核心 text/card 令牌已有;蒙层/透明值待迁移 |
Feedback Framework | 基本就绪 | 用系统 card/text 令牌;hero 渐变保留为演示视觉 |
Search Framework | v1 就绪 | 会话页/搜索输入/菜单表面/边线/占位/结果行已迁移 |
结论:9 个框架中 5 个 v1 就绪,3 个部分就绪,1 个基本就绪。没有完全未就绪的框架。
十、最佳实践清单
10.1 禁止事项
❌ 不要用
Color.White、Color.Black、Color.Gray做结构 UI。❌ 不要用
#FFFFFF、#000000、#182431、#56606C做结构 UI。❌ 不要用 RGB / ARGB API 调用做结构 UI。
❌ 不要在实验里覆盖 app colorMode,除非实验明确研究主题覆盖。
❌ 不要创建
ThemeManager,除非有真实的用户可选应用内主题需求。
10.2 推荐事项
✅ 新框架代码用
ThemeTokens.ets。✅ 一次性令牌实验可直接用
$r('sys.color...')。✅ 需要 dark 分值时用
resources/base+resources/dark。✅ 透明色当工具令牌用(
ThemeTransparentColor/ThemeTransparentWhiteColor),不当视觉设计令牌。✅ 渐变保留,但每个结构渐变终点用
ThemeGradient...()令牌。✅ 填充主控件用
ThemePrimaryButtonBackground()+ThemePrimaryButtonForeground()。✅ 底部边缘蒙层和材质颜色必须是主题资源。
✅ 主题编辑后跑 HAP 构建验证。
10.3 审计命令
E011 沉淀了三条审计命令,建议加入 CI:
# 统计系统令牌使用 rg -o '\$r('\''sys\.color\.[^'\'']+'\''\)' entry/src/main/ets | sort | uniq -c | sort -nr # 统计应用资源令牌使用 rg -o '\$r('\''app\.color\.[^'\'']+'\''\)' entry/src/main/ets | sort | uniq -c | sort -nr # 扫描禁用的硬编码颜色 rg -n '(Color\.(White|Black|Gray|Grey)|#[0-9A-Fa-f]{3,8}\b|rgb\(|rgba\(|argb\(|ARGB|RGB)' entry/src/main/ets entry/src/main/resources10.4 迁移检查清单
页面背景用
ThemePageBackground()卡片/Sheet/Popover 用
ThemeSurfaceBackground()组件填充/输入芯片用
ThemeComponentBackground()或ThemeTextFieldBackground()文字用
ThemeTextPrimary/Secondary/Tertiary()分割线/边框用
ThemeDividerColor()主操作用
ThemeAccentColor()+ThemeOnAccentColor()填充主按钮用
ThemePrimaryButtonBackground()+ThemePrimaryButtonForeground()结构渐变用
ThemeGradient...()语义对底部蒙层/模糊材质用
ThemeBottomGradientMask()/ThemeFloatingMaterialColor()选中态前景与背景对比度足够
蒙层/阴影用
ThemeTokens.ets暴露的应用资源HAP 构建通过
十一、常见错误对照表
错误做法 | 问题 | 正确做法 |
|---|---|---|
| 深色模式下文字消失 |
|
| 深色模式白底 |
|
| 深色模式浅灰底 |
|
渐变硬编码 | 深色模式刺眼 |
|
底部蒙层只有浅色值 | 每个页面底部发白 | 提供 dark 资源值 |
选中态蓝底蓝字 | 低对比看不清 |
|
| 不自适应 | 语义令牌 |
深色失败就删渐变 | 丢失视觉语言 | 保留渐变,终点用 base/dark 资源 |
每个页面自己写 | 不可维护 | 用令牌/资源自动切换 |
直接到处用 | 不一致、难维护 | 用 |
accent 令牌做填充按钮 | 语义太宽泛 | 用 |
状态栏内容色硬编码 | 深色下不可见 | 需要 |
十二、Review 教训:四条经验
E011 在真机 review 后总结了四条关键教训:
12.1 主题迁移不是视觉拍平
如果渐变、蒙层、材质是框架身份的一部分,保留效果,把终点/蒙层移到语义 base/dark 资源里。
不要为了"适配深色模式"把所有渐变都删掉变成纯色——那会让应用失去视觉个性。正确的做法是让渐变在深色模式下也有对应的深色版本。
12.2 令牌编码的是角色,不只是颜色
ThemeAccentColor()太宽泛,不适合填充控件。ThemePrimaryButtonBackground()更清晰,映射到系统激活蓝。
一个令牌的名字应该表达用途("主按钮背景"),而不是色相("蓝色")。这样当你换品牌色时,只需要改一个地方。
12.3 每个填充背景令牌都需要前景搭档
Icon Select 的回归就是因为选中态文字用了背景/激活色,而不是
ThemePrimaryButtonForeground()。
定义ThemePrimaryButtonBackground()的同时必须定义ThemePrimaryButtonForeground()。否则一定会有人把低对比度的文字放到同色背景上。
12.4 底部边缘是主题审计的一部分
TabBar 蒙层、模糊材质、渐变 overlay 必须和页面背景一起审计。
底部边缘很容易被忽略——它不在页面正中间,但在每个页面都出现。一个#66F1F3F5的浅色蒙层会让整个应用在深色模式下底部发白。
12.5 构建成功 ≠ 主题正确
Build success 证明了类型安全,但只有模式切换才能发现对比度、漂白和过度拍平的问题。
HAP 构建通过只能证明代码能编译,不能证明深色模式看起来对。真机切到 Dark Mode 走一遍是主题验证的必要步骤。
十三、当前限制与后续方向
E011 的 Theme Foundation v1 已经成立,但仍有后续工作:
待办项 | 说明 |
|---|---|
状态栏/导航栏内容色 | 需要 |
透明 titleBar 值 | 迁移到 |
选择面/菜单组件令牌 | 等设备 review 后、重复使用稳定后提取 |
API 26 | 等本地 SDK 暴露后验证 |
API 26 | 同上 |
API 26 | 同上 |
CI 审计脚本 | 自动扫描禁用的结构硬编码颜色 |
暂不创建:
ThemeManager—— 没有真实用户可选主题需求时不需要。ThemeConfiguration—— 没有本地/用户主题覆盖实验时不需要。组件令牌注册表 —— 等重复的组件级语义出现后再提取。
十四、构建验证
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \ /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --no-daemonE011 构建结果:
CompileArkTS passed PackageHap passed BUILD SUCCESSFUL十五、写在最后
深色模式不是"事后补的体力活",而是"第一天就该搭好的地基"。如果你的主题基础设施在项目开始时就搭对,90% 的深色适配是自动的——系统令牌帮你切,base/dark 资源帮你切,你只需要管那 10% 的自定义语义。
鸿蒙的主题体系给了你足够的工具:
sys.color.*系统令牌——免费自动切换base/dark资源限定符——自定义语义的双值ThemeTokens.ets语义层——项目级稳定命名HDS 材质 ADAPTIVE 策略——模糊/材质自动适配
你需要做的是选对令牌、分层管理、审计到底。
记住这几条口诀:
系统令牌优先用,语义令牌稳命名。 base dark 双值备,自动切换不用写 if。 渐变别拍平,终点存资源。 填充按钮有搭档,背景前景配对走。 底部边缘要审计,蒙层发白坑全站。 Color.White 别用,#182431 别留。 构建过了不算对,真机切暗才算行。
本文的实现基于 ArkUILab 项目 E011 实验。它不是最终产品,但已经形成了一个可以让每个新框架自动继承的主题基础设施。如果你正在从零开始做一个鸿蒙应用,或者在给现有应用补深色模式,可以直接复用这套令牌分层策略和审计方法作为起点。
主题适配看似只是"换个深色背景",背后却涉及令牌分层、资源限定符、语义命名、渐变保留、前景搭档、底部边缘审计和真机验证等多个维度。把这些维度拆清楚,你的深色模式就不会是"逐页改造的体力活"了。