最近在做一个 HarmonyOS 原生应用,内部代号就叫“面试通”,核心场景是刷题、看题解、模拟面试和记录复习记录。需求列表里有一条很不起眼但实际做起来水很深的项:深色模式。第一次看到这条需求时,我也觉得“不就是换套颜色吗”,真正动手才发现,深色模式在 HarmonyOS 上涉及资源组织、主题变量、系统配置监听、状态持久化、启动防闪白、弹窗和状态栏适配等一串环节,每一项都能单独拎出来写一篇文章。
这篇博文就把“面试通”里深色模式主题资源管理与切换功能的完整开发过程拆开讲,包含整体设计思路、资源目录规划、跟随系统与手动切换的实现、切换细节适配,以及我实际踩过的坑和排查方法。适合正在做 HarmonyOS 应用开发、准备适配深色模式,或者打算在应用内做主题切换的开发者参考,无论你用的是 Stage 模型还是旧版 FA 模型,思路都能复用。
1. 深色模式需求分析与整体设计思路
1.1 需求拆解:从“能切”到“切得好”
“面试通”用户画像里有一大批是晚上下班后刷题的用户,手机系统开了深色模式,APP 却还是一片白底黑字,亮度和刺眼程度都让人想直接卸载。表面需求是“支持深色模式”,但把需求拆细之后其实是三层:
第一层,系统切到深色时,APP 能跟着变。这要求应用能感知系统深浅色状态,并且所有用到颜色的地方都必须走资源引用,而不是硬编码。
第二层,应用内部提供手动切换入口,用户可以设置“跟随系统”、“深色”、“浅色”三选一。这一层比单纯跟随系统复杂,因为手动切换意味着应用需要在运行时改变界面主题,而用户的选择还要持久化保存,下次冷启动直接生效。
第三层,切换过程要稳。不能出现页面突然闪白、状态栏图标看不清、弹窗背景一半白一半黑、二级页面不刷新这类低级问题。这一层最容易被忽视,但恰恰是用户感知最强烈的地方。
我把这三层需求拆完以后,直接决定了后面整套技术方案的方向:所有资源必须语义化,所有颜色必须走资源系统,全局显示模式必须用应用级状态管理,切换动作必须带持久化。
1.2 技术方案对比:系统资源限定词 vs 全手动自定义主题
在做技术选型时,我认真对比过两条路线:
方案 A 是基于 HarmonyOS 资源限定词体系,在resources目录下同时维护base和dark两套资源。系统切模式时,框架自动按当前系统环境选择对应资源,应用基本不用写判断逻辑。这是官方推荐的方式,也是 ArkUI 应用适配深色模式的标准做法。
方案 B 是完全自己维护一套主题色表,在代码里用状态变量控制每个组件取哪个颜色。这种方式看起来很灵活,但实现起来工作量非常大。任何一个文本、背景、分割线、图标都需要手动绑定主题变量,稍有不慎就会漏一处。更麻烦的是,一旦漏掉,排查成本极高,因为问题视觉上非常隐蔽。
我当时列了一张对比表:
| 对比维度 | 方案A:资源限定词 | 方案B:自定义主题表 |
|---|---|---|
| 实现成本 | 低,一次性配置资源目录 | 高,每个组件都要绑定主题变量 |
| 维护成本 | 低,改颜色只需改资源文件 | 高,颜色散落在代码各处 |
| 系统模式感知 | 系统自动匹配,无需额外判断 | 需要自己获取配置并分发状态 |
| 扩展性 | 可延伸到深色、浅色之外的多主题 | 灵活,但实现复杂 |
| 踩坑概率 | 较低,主要注意资源命名 | 较高,容易漏适配和状态不同步 |
最终我选了方案 A 作为底座,手动切换则在方案 A 之上叠加一层应用级状态控制。也就是说,资源的加载和匹配仍然交给系统,但应用通过持久化和全局状态来“告知”系统当前该用哪套资源。这套组合既省力又不牺牲灵活性。
提示:不要一上来就填颜色代码。先把资源体系搭好,再写业务功能,顺序反了后期会大量返工。
2. 主题资源管理:从资源目录到颜色变量
2.1 资源目录规划:base 与 dark 双轨并行
HarmonyOS 的资源限定词机制,简明版本是这样的:在resources目录下,系统会根据当前运行环境选择最优匹配的资源文件夹。没有限定词的base目录是兜底,dark目录是深色模式专用覆盖。
“面试通”的资源目录结构如下:
resources/ ├── base/ │ ├── element/ │ │ ├── color.json │ │ ├── string.json │ │ └── float.json │ ├── media/ │ └── profile/ └── dark/ ├── element/ │ └── color.json └── media/注意,dark目录下不用把所有资源都复制一遍,只需要放那些在深色模式下需要变化的资源。没有在dark目录里定义的资源,系统会自动回退到base。这能少维护很多冗余文件。
base/element/color.json示例:
{ "color": [ { "name": "bg_page", "value": "#FFFFFFFF" }, { "name": "text_primary", "value": "#FF1A1A1A" }, { "name": "text_secondary", "value": "#FF666666" }, { "name": "brand_primary", "value": "#FF3E6AE1" } ] }dark/element/color.json示例:
{ "color": [ { "name": "bg_page", "value": "#FF121212" }, { "name": "text_primary", "value": "#FFE6E6E6" }, { "name": "text_secondary", "value": "#FF9E9E9E" }, { "name": "brand_primary", "value": "#FF6F8FF2" } ] }这里有个新手特别容易踩的坑:dark限定词目录的名字必须是小写dark,不能写成night或者Dark。资源限定词的命名规则是有严格约束的,写错一个字母,系统识别不了,深色模式就会完全不生效。
2.2 颜色命名与语义化设计
颜色资源的命名方式,决定了后期维护成本。刚开始我图省事,叫过black、white、blue这种物理色名,后来发现这种命名方式是给自己挖坑。
举个例子,如果某个文字色变量叫black,在亮色模式下它是黑底白字?不对,黑是文字颜色。但到了深色模式下,这个文字颜色应该变成浅色,那black这个名字就完全失效了——它指代的已经不再是“黑色”了。
正确的做法是给颜色取“语义名”,按角色命名。我沉淀了一套适合中小型应用的命名规范:
| 语义命名 | 亮色典型值 | 深色典型值 | 用途 |
|---|---|---|---|
| bg_page | 白色 | 深灰 | 页面整体背景 |
| bg_card | 浅灰 | 深色卡片 | 卡片、列表项背景 |
| text_primary | 近黑色 | 近白色 | 主文本 |
| text_secondary | 中灰色 | 浅灰色 | 次级文本、时间戳 |
| text_hint | 浅灰色 | 暗灰色 | 占位提示文本 |
| divider | 浅灰 | 深灰色 | 分割线 |
| brand_primary | 品牌蓝 | 亮蓝 | 主按钮、强调元素 |
| brand_pressed | 深蓝 | 更亮蓝 | 按压态 |
| error_red | 红色 | 亮红 | 错误提示、删除操作 |
按语义命名之后,改主题就变成了改资源文件里的映射关系。产品经理想换品牌色,我只改两处变量,全应用同步变色。想增加“护眼绿”第三主题,新建一个资源目录再写一组映射就行,业务代码基本不用动。
2.3 图片与图标资源处理
颜色好处理,图片就复杂一点。“面试通”里的图片资源主要分三类:SVG 矢量图标、PNG 插图、网络图片。
SVG 图标在 ArkUI 里用Image组件加载时,可以通过fillColor属性上色。这种图标是“单色图标”,非常适合深色模式适配,因为只要在代码里把fillColor绑定到语义颜色资源上,深浅色自动变化。
Image($r('app.media.ic_question')) .width(24) .height(24) .fillColor($r('app.color.icon_primary'))PNG 插图就比较麻烦了,比如空状态插图、分享海报背景、登录页插画,这一类本身就带颜色,没法用fillColor解决。我的处理方式是准备两套图,放在base/media和dark/media下用同名文件覆盖。
resources/base/media/empty_quiz.png resources/dark/media/empty_quiz.png引用方式不需要变化,代码里永远写$r('app.media.empty_quiz'),系统会自动按照当前模式选择对应文件。这里有两个注意事项:
第一,两套图的尺寸和比例最好一致,否则切换模式时图片区域会跳动。
第二,PNG 里如果本身包含背景色,设计切图时必须把背景处理成透明,否则深色模式下会看到一块白色方块,非常突兀。
网络图片这一块最棘手。“面试通”里有一部分题目配图来自服务端,CDN 上同一张图只缓存一份。深浅色模式下,如果同一 URL 返回的图片视觉差异很大,那就要么在 URL 上带模式参数,要么干脆不在深色模式下加载花哨的亮色图片。我们最后选择了后者:深色模式下对纯装饰性配图降级为不加载,只保留必要的内容图。
3. 深色模式切换功能实现:跟随系统与手动切换
3.1 获取当前系统模式与监听系统变化
方案 A 的优点是系统会自动选资源,但应用在做状态栏适配、上报埋点、自定义弹窗等场景时,仍然需要知道当前到底是深色还是浅色。在 HarmonyOS 中,Configuration 里带colorMode字段。
获取当前模式的常用姿势:
import { ConfigurationConstant } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit'; const context = getContext(this) as common.UIAbilityContext; const config = context.config; const isDark = config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;这里有一个很容易忽略的地方:getContext(this)拿到的 context 类型有很多种,如果是在 Page 组件里直接拿到的可能是common.UIAbilityContext,也可能因为上下文不同导致config为 undefined。稳妥的做法是先用as common.UIAbilityContext断言,再判空处理。
监听系统模式变化,推荐在 UIAbility 的onConfigurationUpdated回调里做:
onConfigurationUpdated(newConfig: Configuration) { const isDark = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; AppStorage.setOrCreate('isSystemDark', isDark); }在这个回调里,通过AppStorage把系统深浅色状态同步到全局。页面里需要感知这个状态的组件,用@StorageProp('isSystemDark')或@StorageLink('isSystemDark')绑定即可。
注意,这个回调只负责“通知”。真正让界面颜色变化的,是资源系统根据系统环境自动重取资源。如果有些组件没有自动刷新,多半是组件内硬编码了颜色,或者状态没有正确传下去。
3.2 应用内手动切换实现方案
“面试通”的产品需求里加了手动切换,这就不是单纯跟随系统了。用户选择存到 Preferences 首选项里,同时还要即时生效。我把这块拆成三步来落地。
第一步,定义显示模式偏好,存储在Preferences。模式枚举可以定义成字符串更清晰:
export enum ThemeMode { System = 'system', Light = 'light', Dark = 'dark' }第二步,封装一个主题管理工具,提供切换和恢复逻辑。核心思路是:应用启动时读取 Preferences 里的theme_mode,如果存的是dark或light,就用setColorMode相关能力设置应用进程内的配色模式;如果是system,就跟随系统配置。这一步要配合应用配置的更新能力。
import { preferences } from '@kit.ArkData'; import { ConfigurationConstant } from '@kit.AbilityKit'; const PREF_NAME = 'interview_app_settings'; const KEY_THEME_MODE = 'theme_mode'; export async function setThemeMode(mode: ThemeMode) { const pref = await preferences.getPreferences(getContext(), PREF_NAME); await pref.put(KEY_THEME_MODE, mode); await pref.flush(); // 根据模式设置应用配色 const config = getContext().config as Configuration; if (mode === ThemeMode.Dark) { // 将应用配置调整为深色 AppStorage.setOrCreate('appThemeMode', ThemeMode.Dark); } else if (mode === ThemeMode.Light) { // 将应用配置调整为浅色 AppStorage.setOrCreate('appThemeMode', ThemeMode.Light); } else { // 跟随系统 AppStorage.setOrCreate('appThemeMode', ThemeMode.System); } }第三步,ArkUI 页面里通过@StorageLink('appThemeMode')拿到当前模式。凡是需要根据模式动态变化的非资源类逻辑,比如某些自定义绘制的颜色过渡、状态栏图标颜色、某些需要重新计算的数据缓存,都在这里响应变化。
这里我解释一下为什么不用一个普通全局变量:ArkUI 的 UI 刷新依赖状态管理机制。普通变量变化时,框架不知道界面需要更新。只有@State、@StorageLink、@Prop这类被观察的状态变了,组件才会重新渲染。手动切换主题如果不走这套机制,页面是不会有任何反应的。
3.3 启动恢复与防闪屏
手动切换方案里最容易翻车的是冷启动恢复。用户上次选了深色模式,结果每次启动都先看到亮色页面闪一下,再跳成深色。这种体验非常廉价。
根因是时序问题:Preferences 是异步读取的,而页面渲染可能在读取完成之前就开始了。解决思路是在 UI 渲染前完成模式注入。
”面试通“的启动流程我是这样编排的:
- 在
EntryAbility的onCreate或onWindowStageCreate阶段,先启动一个异步任务读取Preferences。 - 读取完成后,立即写入
AppStorage。 - 确保根组件里所有依赖模式的状态都从
AppStorage获取,而不是在页面加载时才临时读。
即使这样,还是可能出现首帧前状态没有注入完成的窗口期。更稳的做法是:在拿到真实值前,不要渲染业务主页,先让启动页(SplashScreen)停留。启动页的背景色本身走资源限定词,所以即使系统是深色,启动页背景也是深色,不会闪白。等 Preferences 读取完成后,再切换到主页。
async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> { // 先读取持久化主题模式 const mode = await getStoredThemeMode(); if (mode === ThemeMode.Dark) { // 通知页面使用深色资源 AppStorage.setOrCreate('appThemeMode', ThemeMode.Dark); } else if (mode === ThemeMode.Light) { AppStorage.setOrCreate('appThemeMode', ThemeMode.Light); } else { AppStorage.setOrCreate('appThemeMode', ThemeMode.System); } windowStage.loadContent('pages/Index', (err) => { // 加载主页面 }); }这样处理之后,冷启动时用户会先看到和最终主题一致的启动页,然后主页直接以正确主题出现,基本没有闪白现象。
注意:Preferences 的
getPreferences和get都是异步的,不要在页面aboutToAppear里再读,那是补救而不是预防。模式值必须在页面构建之前就绪。
4. 切换适配细节与视觉一致性
4.1 状态栏与导航栏适配
这是深色模式里最容易翻车的位置。页面内容变成深色了,但系统状态栏的字体和图标还是深色的,直接和深色背景糊在一起,时间、电量全看不清。
HarmonyOS 里状态栏图标颜色的控制,需要拿窗口对象设置系统栏属性。系统切换深色模式后,onConfigurationUpdated被触发,可以在这里同步更新系统栏:
onConfigurationUpdated(newConfig: Configuration) { const isDark = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; const mainWindow = this.windowStage.getMainWindowSync(); mainWindow.setWindowSystemBarProperties({ statusBarContentColor: isDark ? '#FFFFFF' : '#000000', navigationBarContentColor: isDark ? '#FFFFFF' : '#000000', }).catch((err) => { console.error(`设置系统栏失败: ${JSON.stringify(err)}`); }); }这里有个细节:statusBarContentColor设置的是状态栏内容(时间、电量、信号等图标)的颜色。浅色模式下要设置成黑色,深色模式下要设置成白色。如果填反了,会出现“深色模式下状态栏图标消失”的诡异问题。
导航栏(底部手势指示条)同理。另外,如果应用做了沉浸式状态栏,还要注意状态栏背景色要和页面顶部背景色一致,否则会出现一条明显的分界线。
4.2 弹窗、侧滑面板与键盘区域的深色适配
页面主体适配完了,接下来就是那些“浮在页面之上”的东西。这类组件由于层级高,背景色经常被硬编码。
“面试通”里有一个自定义的筛选弹窗,从底部弹出,背景是圆角卡片。刚开始适配时,我检查了列表、页面背景、按钮颜色,唯独漏了这个弹窗的背景,导致深色模式下弹出来一个白色方块,特别扎眼。
排查过程很典型:我在弹窗组件的build里看到背景色写的是Color.White。这里有两个问题:
第一,Color.White是内置枚举,不走资源系统,所以永远都是白色。
第二,自定义组件如果自己设置背景色,必须要用资源引用。
修正后的写法:
build() { Column() { // 弹窗内容 } .backgroundColor($r('app.color.bg_card')) .borderRadius(16) }键盘区域的适配同样不能忽略。“面试通”里有搜索框和答题输入框,键盘弹起时会把底部导航区域遮挡,如果导航区域是浅色而键盘是深色,视觉上也会很突兀。这一块主要是靠系统默认行为,但要保证底部安全区域的背景色也是通过资源引用设置,不要硬编码。
4.3 列表滚动与图片加载的过渡处理
动态切换模式时,列表页面的问题比较隐蔽。当系统或手动切换发生那一瞬间,列表项虽然有状态更新,但如果列表项是ListItem里塞了一堆普通变量,有些已经绘制出来的区域可能不会立刻重绘。
我遇到过的实际场景:答题记录列表在深色模式下滚动时,偶尔能看到几行文字颜色还是深色,背景也是浅色。刷新一次就好了,不刷新就一直残留。
排查后发现,列表项数据模型里缓存了一个颜色值字段,这个字段在初始化时根据当时的模式算好了,模式切换后不会主动更新。解决方式很简单:不要在数据模型里缓存颜色,所有颜色在使用时从资源里实时取。
图片这一块,如果深浅色图片是网络加载的,缓存 key 要注意。同一 URL 在深色模式下加载的是深色图,在浅色模式下加载的是浅色图(如果服务端支持),那么图片缓存必须把模式作为 key 的一部分,否则会串图。
“面试通”目前对网络图片的策略是尽量不区分主题,统一走内容图。真正需要双套的资源图都放在本地资源目录,交给系统选择。
5. 常见问题与排查技巧实录
5.1 资源不生效:限定词目录和引用路径排查
这是频率最高的问题。现象是 dark 目录下明明改了颜色,但深色模式下页面没有任何变化。
按下面顺序排查:
第一,确认dark目录拼写正确,而且位于resources目录下,不是res、resource之类的错别字目录。
第二,确认资源文件内部结构合法。比如color.json里必须是一个color数组,数组中每个对象要有name和value字段。格式错了,编译器可能不会报错,但资源就加载不出来。
第三,确认颜色全部通过$r('app.color.xxx')引用。如果代码里有.backgroundColor(Color.White)、.fontColor(Color.Black)这种硬编码,资源系统再庞大也无能为力。
第四,确认没有同时使用dark限定词和自定义主题把颜色覆盖了。优先级上,自定义代码里显式设置的颜色,永远高于资源系统匹配的颜色。
这里我专门做了一个“颜色硬编码扫描清单”:搜索代码里的Color.前缀、#FF、#00等十六进制色值,逐个排查,能消灭 80% 以上的深色模式适配遗漏。
5.2 切换后页面不刷新
手动切换后,当前页面没有变化,但退出重进又正常。这个现象我在“面试通”的答题结果页里遇到过。
原因很简单:页面刷新的驱动链断了。ArkUI 的渲染更新依赖状态变量。切换动作只是把某个数据写进了本地存储,但没有把变化传递给状态管理框架,页面自然不会有反应。
排查时先确认三件事:
第一,切换入口处是否调用了AppStorage.setOrCreate或通过@StorageLink修改了全局状态。
第二,页面组件是否用@StorageLink('appThemeMode')或@StorageProp('appThemeMode')把这个状态绑定到了 UI 上。
第三,如果页面是通过路由跳转进入的二级页,二级页在路由栈里没有被销毁,它持有的状态变量可能还是旧值。这种情况可以给页面根组件加一个@Watch监听模式变量的变化,或者把模式相关颜色全部交给资源系统,不依赖状态变量。
@StorageLink('appThemeMode') @Watch('onThemeModeChanged') appThemeMode: string = ThemeMode.System; onThemeModeChanged() { // 模式变化时,强制刷新某些自定义绘制区域 }实际开发中,我尽量把“需要根据模式变化而变化”的视觉属性都下沉到资源系统,让系统自动处理,少用状态变量手动判断。状态变量只负责非资源类的逻辑分支,比如切模式后换一套布局、换一段文案。
5.3 启动闪白与持久化竞态
冷启动闪白的场景我在前面讲过,这里再补充一个更隐蔽的问题:手动选择了深色模式,但首次启动闪白后立刻变黑。这个现象说明 Preferences 读取成功且生效了,但生效时机太晚,第一帧已经以浅色模式渲染出来了。
关键在于,Preferences读取是异步的,而loadContent加载页面也是异步的,两者存在竞态。如果读取慢,页面就先加载了。
我的处理方案分两层:
第一层,优化EntryAbility内的时序,先 await 读取 Preferences,再loadContent。这一步能解决大部分问题。
第二层,启动页本身用资源系统适配。把 SplashScreen 的背景色设置为$r('app.color.bg_page'),这样即使还没有读取到用户设置,启动页也会先根据系统当前模式正确显示。深浅色至少不会闪错。
如果极少数情况下读取特别慢,用户会看到启动页多停留一两百毫秒,这比闪白好得多。
5.4 常见问题速查表
我把这次开发过程中遇到的所有问题和对应处理方式整理成一份速查表,方便后面项目排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| dark 目录颜色不生效 | 目录名错误或资源 JSON 格式错误 | 检查resources/dark路径,检查 JSON 结构 |
| 页面出现残留浅色区域 | 组件里硬编码了 Color 值 | 全局搜索Color.和十六进制色值,换成$r引用 |
| 切换后当前页不变 | 状态驱动链断裂 | 用@StorageLink或@StorageProp绑定模式状态 |
| 二级页不刷新 | 二级页未重建,普通变量不驱动渲染 | 根组件加@Watch,或让视觉属性下沉到资源系统 |
| 状态栏图标看不清 | 系统栏内容颜色设置与模式相反 | 根据colorMode动态设置statusBarContentColor |
| 冷启动闪白 | Preferences 读取晚于页面渲染 | await 读取后再loadContent,启动页背景走资源系统 |
| 弹窗背景色不随模式变 | 自定义弹窗硬编码背景色 | 改为$r('app.color.bg_card') |
| 图片缓存串图 | 同一 URL 深浅色图片缓存 key 冲突 | 缓存 key 拼接模式参数,或统一使用本地资源 |
| 切换后 list 部分项颜色异常 | 数据模型里缓存了颜色值 | 删除数据模型中的颜色字段,使用资源实时取色 |
写在最后的实操体会
这次深色模式适配做完,我有一个很深的体会:深色模式表面上是“加一套颜色”,实际上是对整个项目资源管理规范的一次全面体检。很多历史遗留的硬编码颜色、不规范命名、状态管理断链,都会被深色模式这个需求逼着暴露出来。改完以后,不只是深色模式正常了,整个应用的代码结构也更清爽了。
如果在项目一开始就能确定颜色语义规范、资源目录规范,后面接深色模式就是多写一套dark目录的事。但如果等项目跑起来再补,就会发现总有几处颜色改不到,总有几个页面刷新不过来,那种“逐个文件打补丁”的体验非常难受。
最后再分享一个小技巧:给项目里所有涉及颜色的代码统一建一个自查文档,把语义色名、亮色值、深色值、适用场景列清楚。每次新增页面,开发时直接对着这个表写资源引用,不再临时起名,也不再随手画个十六进制色值。这个文档花不了多少时间,但对项目长期维护的帮助是无价的。后面我们还在考虑在此基础上扩展一套“多主题换肤”的能力,骨架就是这次打磨出来的资源体系,到时候再聊。