深色模式这个东西,做不做是一回事,怎么做是另一回事。前阵子我负责把部门里的“面试通”APP真正跑通深色模式,目标很明确:既能在应用内手动切换深色与浅色主题,又要让主题资源管理得干净、可维护,不能像之前那样在代码里到处硬编码颜色值。整个过程踩了不少坑,尤其是“主题资源管理”和“切换功能”这两块,比想象中复杂得多,今天就把整条实现链路掰开揉碎写清楚。
“面试通”的场景很典型:题库列表、搜索栏、答题页、收藏集、个人中心,页面多而且信息密度高。适配深色模式不是简单把背景换成黑色就能交差的,字体颜色、分割线、卡片阴影、图片图标、弹窗背景、状态栏颜色,每一个细节都可能翻车。这篇文章围绕手动切换功能与资源管理的完整实现过程,把方案选型、资源组织、状态存储、页面适配、排查技巧一次性说透。适合正在给HarmonyOS应用做深色模式适配的开发者,也适合想把手动主题切换做得更稳的团队参考。
1. 整体思路与方案选型:为什么我选择“应用内手动切换+资源自动匹配”
1.1 深色配色的基本盘:两套主题还是动态色值
关于深色模式的实现路径,做安卓和iOS的同事可能很熟悉,但HarmonyOS上容易踩一个误区:以为把颜色都写在代码里,根据一个全局变量判断深浅色就行。这种方案看起来简单,实际上问题很大。页面多、组件多的情况下,每个颜色都要做条件判断,代码量直接翻倍,后续换主题颜色时改起来让人崩溃。
HarmonyOS更合理的做法,是把颜色、字号、图片这类资源放进资源目录,用 $r('app.color.xxx') 的方式引用。资源文件按限定词区分,系统会根据当前的资源限定条件自动选择对应资源。浅色模式加载浅色值,深色模式加载深色值,页面代码里不需要写任何 if 判断颜色。这就是“资源自动匹配”的核心逻辑。
那么“手动切换”和“自动匹配”会不会矛盾?不矛盾。HarmonyOS的 colorMode 可以单独设置应用级的色彩模式,而不是只能跟着系统走。设置 colorMode 为深色时,资源系统会按照深色资源来加载;设置为浅色时,加载浅色资源。配合持久化存储保存用户的选择,应用启动时读取并设置,就是一套完整的“手动切换”方案。
1.2 方案对比:系统跟随、代码判断、资源自动匹配
当时我整理了几种候选方案,做了对比。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 跟随系统色彩模式 | 实现简单,系统自动切换,代码量少 | 用户不能在应用内自定义 | 对主题要求极低、以内容为主的工具 |
| 全局变量+代码判断 | 实现直观,适合极少量页面 | 颜色管理混乱,维护成本高,容易遗漏 | 只有两三个页面的小Demo |
| 资源限定词+手动colorMode | 资源统一管理,页面代码干净,支持双模式风格 | 前期资源整理工作量稍大,需要约束团队规范 | 页面多、视觉要求高的正式应用 |
“面试通”页面数量和视觉要求都属于中上水平,毫不犹豫选了第三种。前期的资源整理工作量多花一点,后面改主题、加页面、调细节都会很顺手。
1.3 目标拆解:资源管理、切换链路、页面适配
把需求拆开看,其实就三件事。
第一,资源管理。颜色、字体、图标、页面背景,全部抽到资源目录,设定好命名规范,并建立对应的深色资源文件。这一步是整个方案的地基,地基没打好,后面全是返工。
第二,切换链路。用户在设置页点击“深色”按钮,全局状态更新,界面立即刷新,同时把设置持久化到本地;下次启动时读出来,在应用启动阶段就把色彩模式设好,保证用户看到的第一帧就是他要的主题。
第三,页面适配。列表、搜索、答题、收藏、个人中心,每个页面都要按深色模式重新审视一遍颜色搭配,尤其是卡片阴影、分割线、次级文字这些容易被忽略的细节。
2. 主题资源管理的实操:从资源目录搭建到引用规范
2.1 资源目录结构:base 与 dark 双目录
先说资源目录怎么建。HarmonyOS 项目的 resources 目录下,默认有 base、en_US 这类限定词目录,深色模式要做的就是在 resources 文件夹下增加一个 dark 目录。
resources/ |-- base/ | |-- element/ | | |-- color.json | | |-- string.json | | |-- float.json | |-- media/ |-- dark/ | |-- element/ | | |-- color.json | | |-- string.json | | |-- float.json | |-- media/base 目录放默认的浅色资源,dark 目录放深色资源。两个文件里的 key 必须保持一致,只是 value 不同。资源系统根据当前色彩模式自动选择对应目录下的值。这一步看起来简单,但有个关键细节:如果某个 key 只在 base 里定义,dark 目录里没定义,深色模式下这个资源仍然会用 base 里的值。所以新增颜色资源时,一定要记得在两边同步添加,这是最容易漏的地方。
2.2 color.json 的组织方式与命名规范
颜色文件的组织方式,建议按语义定义 key,而不是按颜色名定义。举个例子,同样是背景色,如果定义成 bg_white、bg_black 这种,引用的时候页面代码还是得搞清楚当前主题下该用哪个,没法体现主题切换的意义。更好的方式是按使用语义定义:
{ "color": [ { "name": "page_bg", "value": "#F5F6FA" }, { "name": "card_bg", "value": "#FFFFFF" }, { "name": "text_primary", "value": "#1A1A1A" }, { "name": "text_secondary", "value": "#666666" }, { "name": "divider_color", "value": "#EBEBEB" }, { "name": "accent_brand", "value": "#2B6DE8" } ] }dark 目录下对应文件把值替换成深色方案:
{ "color": [ { "name": "page_bg", "value": "#0D0D0F" }, { "name": "card_bg", "value": "#1F1F24" }, { "name": "text_primary", "value": "#F2F2F2" }, { "name": "text_secondary", "value": "#A0A0A8" }, { "name": "divider_color", "value": "#2C2C33" }, { "name": "accent_brand", "value": "#6A9CF5" } ] }这样页面里只需要写 $r('app.color.text_primary'),主题怎么切都能保证对比度合理。命名规范上要严格控制,禁止在页面级代码里直接写十六进制颜色值。我一开始没有强制约束,结果几个答题页的局部背景色直接在代码里写死了,深色模式下呈现白底黑字,刺眼极了。后来统一走资源引用,这类问题基本消失。
2.3 图片与图标的深浅色适配思路
图片资源分两类处理。
一类是纯图标类资源,比如底部 Tab 的题库、收藏、我的这几个图标。这类资源不推荐直接放一张固定颜色图片,因为深色背景下深色图标就看不见了。合理的做法是:图标资源放在 media 目录下,base 放深色图标,dark 放浅色图标,资源名保持一致,系统自动切换。如果图标设计上允许,也可以用 SVG 格式配合颜色设置让图标跟随主题变色,但“面试通”这种场景下,直接准备两套图更省心,视觉效果也更可控。
另一类是内容图片,比如面试题里的配图、用户头像类图片,这类资源本质上是数据内容,不应该随主题变化。处理方式是把它们放到 rawfile 或单独的图片目录,不走主题资源。总之,主题资源里只有 UI 相关的图,数据内容的图不要混进来。
关于媒体资源还有一个细节:dark 目录下的 media 与 base 下的 media 文件名必须完全一致。如果在 dark 下新增了 base 没有的文件,或者反过来,编译期虽然不报错,但资源选择时会按照限定词优先级处理,很容易出现“复现不了”的诡异现象。测试时建议把安装包解出来检查一遍文件清单。
3. 切换功能的核心实现:从点击按钮到全局生效的完整链路
3.1 状态存储:为什么用 AppStorage + PersistentStorage
手动切换功能,本质上是把“当前主题选择”这个状态在全局共享,并且要持久化。HarmonyOS 提供的 AppStorage 和 PersistentStorage 组合正好干这件事。
要在 EntryAbility 的 onCreate 阶段先把持久化的主题选择初始化到 AppStorage。大致逻辑是:
PersistentStorage.persistProp('themeMode', 'system'); let storedTheme = AppStorage.get<string>('themeMode') ?? 'system';这里我把 themeMode 的取值设计成三种:system、light、dark。system 表示跟随系统,light 表示强制浅色,dark 表示强制深色。这样既满足了“跟随系统”的默认需求,也支持应用内手动切换。为什么用字符串而不是布尔值?因为三种状态用布尔值表达容易混乱,字符串更直观,扩展状态也方便。
3.2 点击切换:设置 colorMode 与 UI 刷新
设置页的切换开关,核心逻辑是修改 AppStorage 中的 themeMode,然后根据用户选择设置应用的 colorMode。关键代码如下:
function applyTheme(mode: string) { const appContext = getContext(this) as common.UIAbilityContext; if (mode === 'dark') { appContext.getApplicationContext().setColorModeSync(ConfigurationConstant.ColorMode.COLOR_MODE_DARK); } else if (mode === 'light') { appContext.getApplicationContext().setColorModeSync(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT); } else { appContext.getApplicationContext().setColorModeSync(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); } AppStorage.setOrCreate<string>('themeMode', mode); }setColorModeSync 调用之后,资源系统的颜色加载会立即切换到对应模式,所有使用 $r('app.color.xxx') 的页面组件也会随之刷新。这里不用手动通知每个页面,但要确认组件是否真的响应了资源刷新。实测下来,常规的 Text、Column、List 这些基础组件都会自动更新,但如果某个页面在 onPageShow 里做了缓存或者把颜色值复制到了普通变量,刷新就可能滞后。
为什么要把 themeMode 单独存一份而不用 colorMode 反推?因为要把“跟随系统”这种状态保留下来。如果用户当前跟随系统,应用内的 colorMode 是 NOT_SET,但系统深色模式开启后,界面会变成深色;用户手动选择浅色后又变成浅色。反推出不了“系统跟随”语义,所以必须单独存用户的选择。
3.3 启动阶段:让首帧就是正确主题
如果跳过启动阶段的处理,会出现一个很刺眼的闪屏:应用打开时先亮一下,然后才变成深色。这个问题在深色模式适配中特别常见。
处理思路是在 UIAbility 的 onWindowStageCreate 里、应用显示第一帧之前,把持久化的主题读出来,设置好窗口背景颜色。具体逻辑:
onWindowStageCreate(windowStage: window.WindowStage) { const themeMode = AppStorage.get<string>('themeMode') ?? 'system'; const appContext = this.context; if (themeMode === 'dark') { appContext.getApplicationContext().setColorModeSync(ConfigurationConstant.ColorMode.COLOR_MODE_DARK); windowStage.getMainWindowSync().setWindowBackgroundColor('#0D0D0F'); } else if (themeMode === 'light') { appContext.getApplicationContext().setColorModeSync(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT); windowStage.getMainWindowSync().setWindowBackgroundColor('#F5F6FA'); } // 其余逻辑,如 loadContent }窗口背景色的取值要和页面背景色对齐,否则用户在启动页和首页之间能看到一个色差明显的过渡。我一开始没设置窗口背景色,深色模式启动时首页出现前会闪一下浅色,后来在窗口阶段把背景色设置了,这个问题彻底消失。这里还有一个细节:windowStage.getMainWindowSync() 不一定在所有版本上都同步可用,如果 API 版本不支持,就用异步方式获取窗口,总之要在 loadContent 之前把背景色设置好。
3.4 监听系统主题变化:跟随系统模式下的实时刷新
当 themeMode 是 system 时,系统深色模式开启或关闭,应用要能感知到变化。实现方式是在 UIAbility 里重写 onConfigurationUpdate,或者通过系统环境变化的回调来刷新。我采用的方式是监听配置变化:
onConfigurationUpdate(newConfig: Configuration) { const themeMode = AppStorage.get<string>('themeMode') ?? 'system'; if (themeMode === 'system') { // 让资源系统按系统最新状态重新加载 // 这里不需要手动改颜色,资源自动更新 } }其实在 system 模式下,资源系统本身就是跟随系统的,onConfigurationUpdate 并不是必须的。但有一种情况需要注意:如果应用页面中有些不走资源目录的代码,比如手动拼接的颜色值,在系统主题切换时可能不会自动更新。所以最好的办法还是把所有颜色都纳入资源管理,这样系统切换后页面自动刷新,不用写额外处理逻辑。
4. “面试通”核心页面适配实录
4.1 题库列表与搜索栏:深色模式的高频操作区
题库列表是“面试通”的高频页面。这里最容易翻车的不是背景色,而是卡片与分割线的对比度。浅色模式下卡片和背景的区分靠阴影,深色模式下阴影基本失效,必须靠边框或背景色来区分。我的处理是:卡片背景用 card_bg,页面背景用 page_bg,两者色阶拉开,同时给卡片加一条低透明度描边,边框色在深色资源里定义成略亮于卡片背景的颜色。
搜索栏也有讲究。搜索框的背景在浅色模式是浅灰,深色模式如果沿用同样的灰,会显得很脏,需要把输入框背景换成深灰色,并把占位文字颜色调成 text_secondary 的深色值。输入光标颜色也要检查,直接复用 accent_brand 就可以,深浅色模式下都能保证可见。列表的分割线,在深色模式下不要用纯黑,纯黑分割线太抢眼,应该用 #2C2C33 这类偏暗的灰。
特别提醒一下列表滑动边缘的渐变遮罩。有些页面会在列表上下边缘做淡入淡出遮罩,遮罩颜色如果写成浅色半透明,深色模式下会形成一条明显的亮边,非常难看。这类遮罩要跟着主题换成深色半透明,或者干脆去掉。
4.2 答题界面与代码高亮的深浅色处理
答题页的适配经验值得多说两句。答题页有题干、选项、解析区、代码块四个区块。
题干和选项直接复用 text_primary 和 card_bg 就行,主要是要确认选择题选中态、正确态、错误态的颜色在深色模式下不要打架。浅色的正确绿、错误红,在深色背景上饱和度要适当降低,直接沿用浅色的高饱和红绿会刺眼。我在 dark 的 color.json 里单独定义了 success_dark 和 error_dark 两个语义色,分别给深色模式下的正确和错误状态使用。
代码块是另一个重灾区。面试题库里有不少手写算法题,代码区如果沿用浅色模式的白底黑字,深色模式下整个区域亮得像探照灯。我处理的方式是:代码块背景用 code_block_bg,在 dark 目录里配一个接近纯黑但不至于死黑的 #121218,代码文字用浅灰白色。如果代码高亮用了额外的高亮组件,要检查高亮配色是否跟随主题,有些高亮组件的样式是写死的,需要我们根据主题传参切换。
4.3 我的页面与收藏列表:卡片在深色模式下的视觉规则
“我的”页面和收藏列表的适配逻辑可以放在一起说。
个人中心常见的组件是头像、昵称、菜单列表、设置入口。菜单列表的每一项都有图标和文字,图标如果是两套图方案,dark 目录下的图标资源记得用浅色的。设置入口里的“主题设置”,就是手动切换功能的入口,我用了一个三分段控件,系统/浅色/深色。这个控件本身的选中态背景在深色下要亮一些,否则看不清当前选中项。
收藏列表的卡片和题库列表类似,但收藏页有小缩略图或话题标签,标签的背景色在深色模式下需要额外调整。这类小标签的底色,建议不要直接用页面背景或卡片背景,因为标签往往需要与卡片有轻微的区别,否则排列在一起的时候会糊成一片。
另外,页面里如果有大量留白区域,深色模式下要注意不要全部填成同一个黑色。背景用 page_bg,顶部工具栏用 tool_bar_bg,底部安全区域用卡片色或背景色,三个层次稍微拉开,视觉上才不会一坨黑。
5. 踩坑记录与排查技巧
5.1 切换后页面颜色不刷新的排查顺序
我遇到一个比较诡异的问题:点击切换深色后,列表页的标题颜色变了,但图片和图标的颜色没变。排查后发现,那个列表项的图标图片资源不在 dark/media 目录下,只在 base/media 下放了默认图,所以资源系统在深色模式下找不到对应的图标资源,只能继续用 base 的。修复方式就是补一套深色图标资源,文件名保持一致,问题立刻解决。
如果遇到更普遍的颜色不刷新,按这个顺序排查:先看颜色是不是通过资源引用的;再看 dark 资源里是否定义了对应的 key;再看页面里是否有把资源值提前缓存到普通变量的情况。第三种情况最常见,比如在某个页面的 aboutToAppear 里写了 this.bgColor = $r('app.color.page_bg'),这个值只在页面初始化时取了一次,主题切换后就不会刷新。解决办法是不要缓存资源值,直接在组件绑定处写 $r('app.color.page_bg'),或者把资源值放进状态变量并让主题回调重新赋值。
5.2 弹窗、菜单与系统组件的适配隐患
弹窗问题可以说是几乎所有应用深色适配的雷区。弹窗组件的背景默认走系统 UI 的背景,如果弹窗传入的背景色是写死的浅色,深色模式下弹窗会从深色底跳出来。我在“面试通”里的筛选题库弹窗、删除收藏确认框里都吃了亏。后面统一处理:所有自定义弹窗的背景和文本颜色都从资源引用,并且在测试清单里把弹窗列为必测项。
下拉刷新是另一个容易忽略的组件。深色模式下加载指示器的颜色如果默认是深色,在深背景上就看不见。我的做法是刷新指示器颜色绑定 accent_brand,深浅模式下都可见。另外还有选择器、日历这类系统组件,在深色模式下默认适配一般没问题,但要注意系统组件的文字大小和颜色与产品设计稿是否保持一致,如果不一致,就得通过自定义样式覆盖。
5.3 主题切换前的测试清单
深色模式功能的验收,不能只在开发者自己的主力机上测一遍。我建议在项目里整理一份主题适配测试清单,包含以下维度:
- 基础页面:首页、题库列表、搜索页、详情页、答题页、收藏页、个人中心,全部切换到深色后逐页检查。
- 组件细节:卡片、分割线、输入框、按钮、Toast、Dialog、ActionSheet、单选/多选控件。
- 资源维度:图标是否双套、页面背景是否有写死色值、Markdown 与代码高亮是否随主题变化。
- 状态维度:手动切换深色后杀进程重启、手动切换浅色后跟随系统、系统切换与手动切换来回切换,是否出现状态丢失。
- 异常维度:深色模式下弱网加载、图片加载失败、网络错误提示的底色是否正常。
测试过程中不要只看渲染效果,还要检查状态管理。比如用户手动切到深色,杀进程重启后应该还是深色;如果重启又变回浅色,那就是持久化存储的初始化位置或者顺序有问题,大概率是在 EntryAbility 里读取持久化之前,页面组件已经用默认值渲染了。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 深色模式部分页面仍是浅色底 | 页面背景色写死了十六进制值 | 改用 $r('app.color.page_bg') |
| 切主题后图标不变化 | dark/media 缺失对应图标 | 补充同名深色图标资源 |
| 启动时闪屏 | 窗口背景色未设置或与页面背景不一致 | 在 onWindowStageCreate 中提前设置窗口背景 |
| 重启后主题选择丢失 | 持久化初始化顺序不对或 key 不一致 | 检查持久化初始化时机与命名 |
| 弹窗背景不适应主题 | 弹窗背景色写死 | 弹窗背景统一走资源引用 |
| 系统深色切换时页面不刷新 | 页面缓存了资源值到普通变量 | 资源引用直接写在组件绑定处,不缓存 |
| 深色模式下代码块亮得刺眼 | 代码块背景未做深色资源 | dark 下补充 code_block_bg |
| 分割线太深或太亮 | 深色模式分割线用了纯黑或浅色半透明 | 深色分割线使用 #2C2C33 等低对比色 |
踩过的坑越多,越觉得主题适配不是纯技术问题,而是工程规范问题。只要资源管理规范到位,切换功能的链路清晰,剩下的页面适配就是执行层面的事。尤其是团队协作的项目,代码评审里遇到硬编码颜色的提交,我都会直接打回去,让改成资源引用,从源头杜绝后续隐患。这套方案做完之后,“面试通”的深色模式上线小半年,用户反馈里的主题相关 bug 几乎没有,我再回头看看当时为了几个颜色文件做的那些约束和排查,确实是值当的。如果你也在做 HarmonyOS 应用的深色模式,建议先把资源规范和状态链路定清楚,别急着铺页面,前期多花一天,后面能少加一个月的班。