一、前言深色模式不是事后补是地基先看一个真实的开发场景。团队赶进度所有页面都用硬编码颜色写了// 随处可见的硬编码 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.ets5.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 个按用途分类类别令牌底层来源页面ThemePageBackground()sys.color.sub_background表面ThemeSurfaceBackground()sys.color.list_card_bg组件ThemeComponentBackground()sys.color.component_normal文字ThemeTextPrimary/Secondary/Tertiary()sys.color.text_*强调ThemeAccentColor()sys.color.primaryOnAccentThemeOnAccentColor()app.color.arkuilab_on_accent主按钮ThemePrimaryButtonBackground()sys.color.component_activated主按钮前景ThemePrimaryButtonForeground()app.color.arkuilab_on_accent分割线ThemeDividerColor()sys.color.list_separator输入框ThemeTextFieldBackground()sys.color.text_field_bg透明ThemeTransparentColor()/ThemeTransparentWhiteColor()固定透明值阴影ThemeCardShadowColor()app.color.arkuilab_shadow_card蒙层ThemeOverlayScrimColor()app.color.arkuilab_overlay_scrim搜索菜单ThemeSearchMenuBackground/Border()app.color.arkuilab_search_menu_*渐变ThemeGradientTonal/Cool/Warm/Violet/Accent Start/End()app.color.arkuilab_gradient_*底部蒙层ThemeBottomGradientMask()app.color.arkuilab_bottom_gradient_mask浮层材质ThemeFloatingMaterialColor()app.color.arkuilab_floating_material_light5.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.White1 处FloatingTabBarShell 的 HDS systemMaterialEffect.lightColor保留为官方探针 Color.Black / Color.Gray / RGB / ARGB0 处7.3 必须后续迁移的硬编码残留位置问题对策EntryAbility.ets状态栏内容色#182431不自适应需要uiAppearance或配置感知的窗口栏更新Menu/ActionSheet/Feedback titleBar 透明背景#00FFFFFF应该用令牌下次改动这些文件时迁移到ThemeTransparentWhiteColor()MenuLabDestination自定义 Select 样本有意保留的视觉探针重复使用后应变成组件令牌7.4 允许保留的硬编码残留位置为什么保留转场/Action/Menu/Feedback Lab 的演示渐变实验视觉语言的一部分Quick Access 条目的业务身份色内容身份不是结构色ActionSheet 蒙层值等选择面框架有专用组件令牌后再迁移HDS 官方模糊 TabBar 蒙层#66F1F3F5等设备对比确认令牌替代后迁移八、迁移实战怎么改才对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()) // 白字规则蓝色填充控件应该用主按钮语义对ThemePrimaryButtonBackgroundThemePrimaryButtonForeground不要用通用 accent 令牌。九、框架审计哪些框架已就绪E011 对 ArkUILab 的所有框架做了主题就绪审计框架主题状态说明Navigation Framework基本就绪HDS Navigation title bar 令牌用系统色窗口状态栏有一个 gapNavigation Header Frameworkv1 就绪Header 标题/菜单/搜索已迁移到系统/语义令牌FloatingTabBar Frameworkv1 就绪浮层材质和底部渐变蒙层用 base/dark 资源ApplicationShell就绪根背景用ThemePageBackground()Overlay Frameworkv1 就绪下拉浮层蒙层/卡片/输入/文字已迁移Menu Framework部分大部分用系统令牌自定义视觉探针保留API 26 systemMaterial 待 SDKSelection Framework部分核心 text/card 令牌已有蒙层/透明值待迁移Feedback Framework基本就绪用系统 card/text 令牌hero 渐变保留为演示视觉Search Frameworkv1 就绪会话页/搜索输入/菜单表面/边线/占位/结果行已迁移结论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/baseresources/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 构建通过十一、常见错误对照表错误做法问题正确做法fontColor(#182431)深色模式下文字消失ThemeTextPrimary()backgroundColor(#FFFFFF)深色模式白底ThemeSurfaceBackground()backgroundColor(#F6F8FA)深色模式浅灰底ThemePageBackground()渐变硬编码#EEF4FF深色模式刺眼ThemeGradientTonalStart()底部蒙层只有浅色值每个页面底部发白提供 dark 资源值选中态蓝底蓝字低对比看不清ThemePrimaryButtonForeground()白字Color.White做结构色不自适应语义令牌深色失败就删渐变丢失视觉语言保留渐变终点用 base/dark 资源每个页面自己写if (isDark)不可维护用令牌/资源自动切换直接到处用$r(sys.color...)不一致、难维护用ThemeTokens.ets语义层accent 令牌做填充按钮语义太宽泛用ThemePrimaryButton*专用对状态栏内容色硬编码深色下不可见需要uiAppearance或配置感知十二、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 已经成立但仍有后续工作待办项说明状态栏/导航栏内容色需要uiAppearance或配置感知的窗口栏更新透明 titleBar 值迁移到ThemeTransparentWhiteColor()选择面/菜单组件令牌等设备 review 后、重复使用稳定后提取API 26systemMaterial等本地 SDK 暴露后验证API 26menuSystemMaterial同上API 26uiAppearance同上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 实验。它不是最终产品但已经形成了一个可以让每个新框架自动继承的主题基础设施。如果你正在从零开始做一个鸿蒙应用或者在给现有应用补深色模式可以直接复用这套令牌分层策略和审计方法作为起点。主题适配看似只是换个深色背景背后却涉及令牌分层、资源限定符、语义命名、渐变保留、前景搭档、底部边缘审计和真机验证等多个维度。把这些维度拆清楚你的深色模式就不会是逐页改造的体力活了。