有没有遇到过这种画面:用户刚把 OpenHarmony 设备的系统切到深色模式,桌面、设置、系统应用全都变暗了,结果一点进你基于 React Native 开发的应用,整个页面瞬间亮得像个探照灯。用户嘴上不说,心里已经把应用归到了“没品味”那一类——而实际上,你的配色方案用的还是默认白底。
深色模式适配这件事,在 Android 和 iOS 上已经被聊烂了,但在 OpenHarmony + React Native 这个组合下,还真不是简单抄一套 useColorScheme 就能完事的。系统层的深浅色资源、RN 桥接层的配置同步、JS 侧的动态取色、原生容器的窗口背景,四个环节只要断了一个,页面就可能在切换的一瞬间变得不对劲。
这篇就基于我实际在 OpenHarmony 设备上给 RN 应用做深色适配的经历,把容易翻车的点和能直接落地的做法从头到尾捋一遍。适合正在做 OpenHarmony 应用迁移、或者打算在 rk3568 / rk3588 这类开发板上跑 RN 应用的开发者参考。
1. 切了深色模式页面却没反应:先把出问题的链路摸清楚
1.1 从系统切换键到 RN 页面重新渲染,中间到底发生了什么
很多人以为深色模式就是系统给应用发一个“变暗”的信号,应用收到信号后换个背景色就完事了。但实际上,在 OpenHarmony 设备上,深浅色切换是一个从系统配置逐层传递的过程。
OpenHarmony 系统的配置管理服务会维护一个全局 configuration,里面包含颜色模式、字体缩放、语言区域等信息。当用户在设置里把显示模式从浅色切成深色,系统会通知当前前台应用所在的 UIAbility 生命周期回调,同时原生侧的资源加载框架会根据限定词自动切换资源目录——比如应用资源里放了 base/element/color.json 和 dark/element/color.json,系统就能自动取到深色值。
而这个 UIAbility 承载的界面如果是一个 React Native 页面,情况就复杂了。RN 页面真正的内容绘制并不在原生 ArkUI 组件树里,而是在 RN 自带的渲染引擎里,通过适配层把原生组件映射到 OpenHarmony 的组件体系上。所以系统配置更新通知到达 UIAbility 后,还要由 RN 的适配层把这次配置变化转换成 RN 框架里的 Appearance 变更事件,再通过 JS Bridge 通知到 JS 执行上下文。JS 侧调用 useColorScheme 监听到变化,触发重新渲染,页面才会真正变过去。
这条链路里有任何一个环节断掉,用户看到的就是“系统都变暗了,应用还亮堂堂”的尴尬结果。
1.2 实际操作中真正会让适配失败的几个环节
我在适配过程中排查过一类很典型的 case:系统深浅色切换后,日志里能明显看到原生容器收到了配置更新,但 RN 页面纹丝不动。最后定位下来是适配层没有把系统的颜色模式字段同步给 RN 框架的 AppearanceManager,JS 侧拿到的永远是 light。这类问题属于桥接层的支持度问题,换版本或者打补丁才能解决。
另一类 case 则怪自己:JS 侧 useColorScheme 工作正常,深色模式也监听到了,但页面上大量背景色是直接写死在 StyleSheet.create 里的十六进制常量,压根没有参与主题切换。这种属于应用层代码的组织问题,代码写得越多,返工成本越高。
还有一类隐藏比较深的坑在原生壳层。RN 应用在 OpenHarmony 上跑,入口界面通常还是 Stage 模型里的一个 UIAbility 或 Window,RN 页面加载完成之前,窗口里显示的其实是原生容器的背景。如果这个原生容器的窗口背景被硬编码成了纯白,那么即便 JS 侧适配得再好,冷启动、bundle 加载、页面刷新这几个阶段都会出现让人不适的白底闪烁。深色模式一开,这个问题尤其刺眼。
1.3 动手改代码之前,建议先确认的三件事
别急着改 JS 侧的颜色代码,先在目标设备上核实三个基础条件,能帮你避开大量“无用功”。
第一,确认系统版本是否支持深色模式切换。OpenHarmony 的标准系统版本和分支众多,并不是所有设备镜像都在设置里提供深色模式入口。尤其自己编译移植的系统,如果只带了基础组件,设置里找不到深浅色切换也不奇怪。先手动切一次,确认系统级应用能跟着变色,再来谈 RN 适配。
第二,确认 RN 侧能不能感知到系统颜色模式。用一个小 Demo 页面,在 useEffect 里监听 useColorScheme 的变化,把当前值直接 console.log 出来并显示在页面上。如果这个值切换系统深浅色后纹丝不动,说明适配层还没打通,先去解决框架版本的兼容问题;如果能感知到,再深入做 UI 层适配。
第三,找到入口 UIAbility 的窗口背景配置。RN 页面显示前,原生窗口背景用什么颜色,在哪个资源文件里定义,这个必须提前知道。它决定了启动白屏、切后台回来这些场景下,用户看到的是和谐过渡还是一个突兀的白色闪框。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从硬编码颜色到动态主题:RN 侧颜色管理不能靠手写两套样式
2.1 两套完整 StyleSheet 复制粘贴,是成本最高也最不好维护的方案
不少开发者的第一反应是:把每个页面的 StyleSheet 复制一份,改成深色用,根据系统模式动态选择不就行了?
页面少、业务简单的时候,这个方案确实能跑通。但只要页面数量超过十个,维护成本就会指数级上升。每次产品改一个主色调,你要同步改浅色和深色两套文件里的同一个色值;每次新增一个页面,你得记得把两套样式都写完,少一套就等着出 Bug。更要命的是,两套 StyleSheet 如果结构不一致,后期任何样式调整都容易只改了一边,另一边留下旧值,排查起来非常折磨人。
我踩过这个坑之后,换成了一种更省心也更符合视觉体系的做法:颜色不再散落在各个 StyleSheet 里,而是抽成一套语义化的颜色 Token,样式表只引用 Token 名,页面根据系统模式从浅色 Token 集和深色 Token 集里取一份。
2.2 用 useColorScheme 加 ThemeContext 把主题注入组件树
React Native 官方从 0.62 左右开始就提供了 useColorScheme Hook,配合 Appearance.addChangeListener 还可以处理更精细的监听需求。推荐做法是在应用顶层包一个 ThemeProvider,把颜色 Token 通过 Context 下发,页面组件直接用自定义 Hook 取颜色。
先定义颜色 Token。这里的关键是不要按照“这个按钮是蓝色”来命名,而是按照它在 UI 里的语义角色命名,比如背景、前景、分隔线、主色、成功、警告。这样在做深色模式的时候,只需要换语义背后的色值,不需要去改引用方的代码逻辑。
typescript复制export interface AppColors {
backgroundPrimary: string;
backgroundSecondary: string;
backgroundElevated: string;
textPrimary: string;
textSecondary: string;
textTertiary: string;
textInverse: string;
accent: string;
accentPressed: string;
divider: string;
surfaceOverlay: string;
success: string;
warning: string;
error: string;
}
export const lightColors: AppColors = {
backgroundPrimary: '#FFFFFF',
backgroundSecondary: '#F2F3F5',
backgroundElevated: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#61666D',
textTertiary: '#969BA3',
textInverse: '#FFFFFF',
accent: '#0A59F7',
accentPressed: '#0843BA',
divider: '#E4E5E7',
surfaceOverlay: 'rgba(0, 0, 0, 0.55)',
success: '#00A862',
warning: '#F7A500',
error: '#E62B2B',
};
export const darkColors: AppColors = {
backgroundPrimary: '#0E0E10',
backgroundSecondary: '#18181B',
backgroundElevated: '#232326',
textPrimary: '#EBECED',
textSecondary: '#A2A6AB',
textTertiary: '#6B7076',
textInverse: '#1A1A1A',
accent: '#3C7DFF',
accentPressed: '#6699FF',
divider: '#2C2C30',
surfaceOverlay: 'rgba(0, 0, 0, 0.75)',
success: '#26C281',
warning: '#FFB936',
error: '#F2473D',
};
接着建一个 ThemeProvider,把颜色模式和颜色对象一起下发。
tsx复制import React, { createContext, useContext, useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { lightColors, darkColors, AppColors } from './colors';
interface ThemeContextValue {
scheme: 'light' | 'dark';
colors: AppColors;
}
const ThemeContext = createContext<ThemeContextValue>({
scheme: 'light',
colors: lightColors,
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme();
const scheme = systemScheme ?? 'light';
const value = useMemo<ThemeContextValue>(
() => ({
scheme,
colors: scheme === 'dark' ? darkColors : lightColors,
}),
[scheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}
页面里使用的时候,样式结构保持不变,但颜色全部从 colors 里取:
tsx复制function HomeScreen() {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.backgroundPrimary }]}>
<Text style={[styles.title, { color: colors.textPrimary }]}>首页</Text>
<Text style={[styles.desc, { color: colors.textSecondary }]}>
当前主题色会自动跟随系统
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
},
title: {
fontSize: 20,
fontWeight: '600',
},
desc: {
fontSize: 14,
marginTop: 6,
},
});
这里的取舍是:布局样式继续放在 StyleSheet 里,因为它们不随主题变化;颜色样式因为要动态取值,只能以内联 style 的方式挂上去。实测下来对性能几乎没有可感知的影响,RN 会做 style 合并,重渲染时也只有颜色相关的属性会更新。
2.3 颜色 Token 要能覆盖组件状态,不能只做表面功夫
很多应用做深色适配时背景色和文字色都改了,但按下按钮时的高亮、输入框聚焦边框、列表选中态这类交互反馈没跟上。浅色模式下按钮按一下颜色变深一点,深色模式下如果还沿用同一套 hover/pressed 色值,要么暗到看不清边界,要么亮得刺眼。
我这边习惯是给每个可交互组件都准备一套状态色。按钮的主色之外必须有 pressed 色,列表点击反馈单独用一个透明蒙层而不是直接压黑,输入框的边框颜色要区分默认态和聚焦态。上面对色板的定义只是起步,真正生产级的 Token 表还应该加上:
pressOverlay:用于列表项、卡片点击时叠加的半透明黑色或白色蒙层,深浅色模式下透明度不同inputBorder和inputBorderFocus:输入框默认与聚焦边框tabActive和tabInactive:底部分栏或顶部标签的选中与未选中态skeletonBase和skeletonHighlight:骨架屏加载闪烁的底色与高光色
这些状态色在深浅色模式下都必须成对出现,缺一个就可能在某个模式下出现“点了不知道点没点”的体验问题。
同时,文字层级也别只留一个最重色和一个最浅色。深色模式下若只有黑白两档,页面会显得非常平,没有层次。我这边至少保留三级文字色,再辅以一个反色文字用于彩色按钮上的文本,才能让页面在暗色背景下既有对比度又有层次感。
3. 只改页面背景远远不够:导航栏、图片、启动白屏才是翻车重灾区
3.1 自定义导航栏和页面背景脱节的割裂感
React Native 应用在 OpenHarmony 上跑,导航方案通常会选自定义的顶部标题栏或者第三方的导航容器。如果导航栏的背景色是写死的浅色,页面主体已经换成深色,顶部就是一条很显眼的亮色横杠,视觉上非常割裂。
适配的时候要特别注意三点。第一,导航栏背景色必须跟着页面背景走,而不是跟着页面里的内容区域走;第二,标题文字颜色要跟随深浅色切换;第三,导航栏底部分隔线在浅色背景下有时会用浅灰,深色背景下这条线的颜色必须调深,不然看起来就是一根发亮的细线横在屏幕上方。
如果你用的是自定义 header,直接把背景色和文字色接入 Theme 对象即可。如果你把 header 放到了原生壳层,那这个颜色就得通过桥接事件同步给原生侧,或者在原生侧直接按系统颜色模式的资源限定符取色,两边做法都能选,关键要尽早统一,别这边一套那边一套。
3.2 深色模式下图片资源可能会彻底翻车
图片资源的深色适配,比颜色适配更容易被遗忘。尤其两类最典型:一是带了白色背景的装饰图,比如某些活动图、插画,浅色下融入页面天衣无缝,深色下直接露出一个白色方块;二是灰蒙蒙的占位图,暗色背景下对比度不够,信息都看不清。
我在实际项目里会做三层处理。首先,纯装饰性、可替换的资源尽量用矢量图或者内联 SVG/iconfont,不要用带白底的位图,矢量资源可以通过当前主题色动态染色。其次,有些实在换不掉的位置图、插画,运营侧准备两套资源,JS 侧根据系统模式选 xxx_dark.png。再次,对无法准备双份渲染图的场景,至少给图片容器加一个与当前主题匹配的背景色,让图片加载中或加载失败时不会产生突兀的白块。
如果是自定义的颜色类图标,可以直接把图标组件读取主题色并作为 fill 或 tint 传入。这样深色模式下图标自动变成浅色,不需要额外维护双份图标文件。
3.3 启动白屏阶段,RN 加载期间的颜色承接问题
React Native 页面在 OpenHarmony 上加载时,JS bundle 需要解释执行、首帧组件需要渲染,这期间会有或长或短的空白窗口期。默认情况下,承载 RN 页面的原生窗口背景是白色的,深色模式下就会表现为:点开应用先白屏一闪,然后页面才变成深色。
这个体验在深色模式下比浅色模式明显得多,因为白亮的闪框中文字还没出来,屏幕先亮了一下。要处理好这个问题,必须在原生侧解决,JS 侧代码在这个阶段还没执行,改不了什么。
我记得有一个搜索热词叫“react native 启动白屏”,其实很多场景就是因为这个原因。OpenHarmony 上处理思路和其他端一致:把入口窗口的背景色指向一个能自动跟随系统深浅色切换的资源颜色,而不是硬编码白色。开发者在工程资源配置里用限定词分桶的方式放两个颜色,浅色模式取白,深色模式取深色。这样即使 JS 侧还没接管渲染,窗口底色也已经和系统模式保持一致,从启动到首帧的过渡就自然了。
需要提醒的是,RN 页面加载阶段显示原生窗口背景这个行为,如果你的应用入口在某个特定框架版本中被强制设了白底,那还是得回到适配层的源码配置里去把背景色改为动态资源。不要试图用“加一个启动闪屏页”掩盖这个坑,治标不治本。
3.4 状态栏文字图标颜色和底部安全区也别漏掉
深色模式下如果状态栏的文字和图标仍然是黑色,就会在深色背景上看不清;浅色模式下如果状态栏是白色图标,放在白色背景上同样灾难。React Native 里调整状态栏样式通常用 StatusBar 组件的 barStyle,需要根据当前主题在 light-content 和 dark-content 之间切换。这里的命名比较反直觉:dark-content 表示状态栏上是深色图标,适合浅色背景;light-content 表示浅色图标,适合深色背景。
如果应用里用了系统提供的侧边返回手势区域、底部导航条、刘海屏安全区,也要注意这些系统 UI 区域的背景和图标颜色。OpenHarmony 系统在深色模式下会把系统导航条自动调整成深色,但如果应用没有把内容区域扩展进去或者扩展后没有正确设置沉浸式配置,可能出现底部一条亮色或者内容嵌到系统栏里的情况。实测中这块通常在 rk3568 这种带物理下巴或虚拟导航键的设备上更容易暴露问题,验证的时候要特意看一眼。
4. 在 rk3568 真机上验证,以及适配完成后我还会多做的几件事
4.1 为什么一定建议在 rk3568 这种 OpenHarmony 真机设备上验证
如果你手头正好有 RK3566、RK3568 或 RK3588 的开发板,千万别只依赖模拟器验证深色模式效果。RN 适配层的部分特性在模拟器上可能表现正常,但到真机上会因为设备镜像的图形渲染、系统能力裁剪、窗口配置差异出现各种奇怪现象。
举一个我实际遇到的例子:模拟器上深浅色切换后状态栏颜色能正确跟随,但同一套代码烧到 rk3568 开发板上,状态栏颜色死活不变,后来发现是设备镜像里默认启用的系统 UI 版本和应用声明的不一致,导致沉浸式配置没生效。这类问题脱离了真机很难排查。
如果你是刚从网上下载/自己编译了一个 rk3568 的标准系统镜像,又对着一堆设备树文件不知道选哪个,我只能说这部分内容展开讲又是一篇长文,简单建议是先找板卡厂商提供的出厂镜像对应的设备树配置文件,对照开发板的型号、屏幕规格、内存颗粒去选,别盲目拿一个通用配置硬启动,稳定起步之后再做深浅色适配验证才有意义。
4.2 深色模式完整验证清单,照着过一遍基本不会漏
适配做完不等于结束,验证阶段建议在真机上把下面这些项目逐条过一遍。这些项看着都挺基础,但每一项我都在不同客户现场见过翻车案例。
| 验证项 | 操作方式 | 通过标准 |
|---|---|---|
| 页面主背景 | 系统切换深浅色 | 页面背景即时跟随,无跳变 |
| 文字层级 | 检查正文、说明、辅助文字 | 三级文字色全部可读,对比度正常 |
| 可交互组件 | 点击按钮、列表项 | 按下态有明确的深/浅色反馈 |
| 输入框 | 聚焦一个 TextInput | 边框/光标颜色清晰可见,占位符可读 |
| 图片资源 | 浏览包含位图、图标、插画的页面 | 没有白色色块、图标和文字可视 |
| 导航栏 | 检查顶部标题栏和 header 分隔线 | 与页面背景协调,标题可读 |
| 状态栏 | 在首页和二级页各看一眼 | 状态栏图标颜色与页面背景不糊 |
| 弹窗/Tips | 触发 Toast、Dialog、ActionSheet | 浮层与页面背景有明显层级区分 |
| 启动与回切 | 冷启动应用、从后台切回 | 没有白底闪框,窗口背景与主题一致 |
| 键盘场景 | 弹起输入法再收起 | 页面背景没有出现局部白块残留 |
这条清单里最容易遗漏的是最后两项。很多人验证完静态页面就收工了,结果应用冷启动那一刻破功,或者输入法弹起时因为窗口软键盘模式配置不当,输入区域下方直接露出一块不跟主题的底色。
4.3 顺手把字体缩放、护眼模式这些系统能力一起测了
深色模式适配往往不只是“换颜色”这一件事。OpenHarmony 系统会提供字体大小调节能力,如果应用里固定了字号而不是用相对单位,用户在系统里调大字体后,你的 RN 页面可能出现文字截断、按钮文字溢出、一行文本折成两行导致布局被顶出屏幕。
在验证深色模式的同时,建议把系统的字体缩放档位调到最大和最小各看一遍。颜色适配和字体适配都属于同一个配置变更生命周期,如果应用在处理系统配置变更时的逻辑没写好,这两类问题会同时出现,影响的就是“这个应用在系统层面的基础体验”了。
4.4 都测完以后,再留个纯 JS 侧的主题调试入口更省心
最后分享一个我实际应用里用得很顺手的小技巧。真机测试深色模式时,每次都要跑到系统设置里去切换深浅色,尤其是在 rk3568 这种通过 HDMI 接显示器的环境下,操作链路较长,反复切换特别浪费时间。
我在应用内部放了一个调试用的小入口——隐藏的开发者菜单里加一个“切换主题预览模式”的开关。开启之后,页面上的主题不再直接读系统模式,而是读 Store 里的一个手动覆盖字段。这样测试人员可以直接在应用里快速切换深浅两个模式做对比,不需要反复退出应用到系统设置里切换。等正式发版时,把这个入口用构建标识隐藏掉就行。
不过必须强调,这个手动覆盖字段只做测试预览用,不要默认开启。真正上线时的逻辑必须是跟随系统模式,除非产品明确要求应用内提供独立的深浅色设置覆盖系统配置——那是另一个设计话题,但基础实现上,有了 ThemeProvider 这层控制,后续想支持“不跟随系统、应用内自选深浅色”也非常方便,只需要把 ThemeProvider 里读取 useColorScheme() 的地方换成读取用户手动选择的值即可。
从 Bridge 层同步到 JS 侧取色,从 Token 设计到状态色覆盖,再到启动白屏和导航栏这些小细节补漏,RN 应用在 OpenHarmony 上的深色模式适配,其实并没有多高深的技术门槛,真正的考验在于这条链路太长,每个环节又都容易默认别人已经处理好了。按部就班排查一遍,在真机上多切几次系统模式,深色适配的质量自然就稳了。
