做 RN 鸿蒙化适配的团队,大概率都会遇到同一个画面:系统已经切成深色模式了,状态栏、桌面、原生页面都跟着变暗,结果自己的 RN 页面还是明晃晃的白底。检查代码,useColorScheme 也写了,主题也判断了,但就是没反应。更头疼的是,好不容易让页面能跟着系统变色了,又发现应用冷启动的一瞬间还是会先弹一帧白屏,在夜间环境下特别刺眼。
这篇文章我不想只摘录一遍官方文档。我想把 RN 鸿蒙场景下,深色模式从“原生系统设置”到“JS 层主题刷新”这条链路完整讲透,再给出一套可以直接复用的自定义主题容器方案。核心会用 useColorScheme 作为入口,但真正解决的是它在鸿蒙端拿不到值、收不到事件、以及拿到值之后如何设计主题配色才能少踩坑的问题。适合正在做 React Native 鸿蒙跨平台适配的客户端工程师,也适合从零开始接鸿蒙版、但不想等到提测阶段才补深色模式的同学。
1. 先确认鸿蒙适配层把“系统深浅色事件”送到了 JS 侧
1.1 useColorScheme 的底层数据链并不复杂
useColorScheme 在 React Native 里并不是什么神奇的系统能力。它本质上是从 React Native 的 Appearance 模块里读取“当前系统外观”的一个 Hook。在 iOS 上,它读取的是 traitCollection.userInterfaceStyle;在 Android 上,它读取的是 uiMode 里的夜间模式标志。两端都会在系统外观变化时,通过原生事件通知 JS 层,JS 层再触发组件重渲染。
到了鸿蒙端,这套机制天然也成立,但实现方式需要鸿蒙适配层做对应桥接。正常的数据链路是:
- 系统设置里切换“深色模式”。
- 鸿蒙 UIAbility 或 Window 收到的
Configuration发生变化。 - 适配层把新的颜色模式转成
light或dark。 - 通过 RN 的
Appearance.addChangeListener事件推送给 JS。 useColorScheme内部订阅了这条事件,JS 侧组件重渲染。
这段链路里最容易出问题的是第 4 步。很多自研的鸿蒙桥接模块只实现了“初始化时读取一次”,没有实现“运行时监听系统设置变化”。那结果就是:App 冷启动时系统是深色,JS 侧第一次渲染能拿到 dark;但用户把 App 切到后台,去系统设置改成深色,再切回 App,RN 页面纹丝不动。
1.2 切换系统主题后页面不刷新,大概率不是前端的问题
我在实际排查中看到过很多前端同学在 JS 侧做各种“补救”:加 AppState 监听、用定时器轮询、把页面强制重建,甚至把配色方案改成从接口拉取。这些方案并没有真正解决问题,因为根因在原生桥接层。
鸿蒙端要支持系统深色模式切换,至少要在 UIAbility 里主动感知系统配置更新,并把事件推给 RN。你可以先看一下鸿蒙工程的 Ability 里有没有类似这段逻辑:
typescript复制onConfigurationUpdated(config: Configuration) {
const isDark = config.colorMode === 2; // 不同 SDK 版本枚举值会有差异,以实际为准
// 将该事件桥接到 RN 的 Appearance 模块
this.rnDelegate?.sendEvent('appearanceChanged', { colorScheme: isDark ? 'dark' : 'light' });
}
如果你们用的是社区维护的 RN 鸿蒙适配层,这一步通常已经被实现。如果是内部自研桥接,或者改过定制 ROM 相关的底层逻辑,就要重点关注。
1.3 动手前先用这段代码验证基座能力
在开始写主题容器之前,我建议先做一次最基础的探针测试。用一个干净的页面执行下面的逻辑:
tsx复制import { useEffect } from 'react';
import { Appearance, useColorScheme } from 'react-native';
export function SystemModeProbe() {
const colorScheme = useColorScheme();
useEffect(() => {
console.log('current color scheme from hook:', colorScheme);
const subscription = Appearance.addChangeListener(({ colorScheme: next }) => {
console.log('system color scheme changed to:', next);
});
return () => subscription.remove();
}, [colorScheme]);
return null;
}
把这段代码跑在鸿蒙真机上,然后反复在系统设置里切换深浅色。如果你的日志里只有第一条,没有“changed to”的日志,那说明适配层根本没有把事件送过来。这时候后端原生同学需要先处理事件桥接,前端写再多主题逻辑都白搭。
还有一种情况也要注意:如果你的 useColorScheme() 始终返回 light,哪怕系统已经是深色,那大概率是适配层在初始化时读错了系统配置,或者把默认值写死了。不要急着用 Platform.OS === 'harmony' 去写死一个 dark,先确认基座能力是通的,否则后面所有逻辑都会建立在错误前提上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 在设计主题配色前,先用语义 Token 把颜色边界划清楚
2.1 深色模式不是把颜色取反,也不是只改背景和文字
很多项目的深色模式是从“浅色主题复制一份,把背景改黑、文字改白”开始的。小 Demo 没问题,但随着页面增多,问题会迅速暴露:同一层级的信息卡片在深色下应该用比背景更亮的表面色,输入框禁用态应该用透明度更低的灰色,错误反馈要保证暗色背景下依然有足够对比度。这些都不是“反色”能解决的。
我的建议是:不要在组件里写 isDark ? '#000000' : '#FFFFFF' 这种逻辑。颜色值散布在业务组件里,后面稍微调一版深色配色,就必须全文搜索替换。正确做法是建立一套语义 Token,让组件依赖“名字”而不是依赖“具体色值”。
2.2 用一张 Token 表统一两套色板
先从业务场景拆出最小语义集合。每个团队可以不同,但下面这些 Token 在深色模式适配里基本都会用到:
| Token 名称 | Light 场景 | Dark 场景 | 控制元素 |
|---|---|---|---|
background |
#F5F6FA |
#121212 |
页面根背景 |
surface |
#FFFFFF |
#1E1E1E |
卡片、列表行 |
surfaceVariant |
#EDEFF2 |
#2C2C2C |
输入框、次级容器 |
textPrimary |
#1A1A1A |
#E6E6E6 |
主标题、正文 |
textSecondary |
#6B7280 |
#9CA3AF |
辅助说明文字 |
border |
#E2E5EA |
#3A3A3A |
描边、分割线 |
primary |
#2A6FF3 |
#4E8FFF |
品牌主色、按钮底色 |
onPrimary |
#FFFFFF |
#0F1620 |
主色上的文字与图标 |
danger |
#DC2626 |
#F87171 |
错误提示、破坏性操作 |
overlay |
rgba(0,0,0,0.4) |
rgba(0,0,0,0.6) |
弹窗蒙层 |
这套表里最重要的设计原则,是让深色模式的每一个 Token 都和浅色模式“语义互相对应”。卡片在浅色模式下用 surface,在深色模式下也是 surface,只不过具体色值不同。组件不需要知道自己到底是白是黑,只需要说自己要使用“表层色”。
落成 TypeScript 类型定义大概是这样的:
typescript复制export type ThemeMode = 'light' | 'dark';
export interface AppThemeColors {
background: string;
surface: string;
surfaceVariant: string;
textPrimary: string;
textSecondary: string;
border: string;
primary: string;
onPrimary: string;
danger: string;
overlay: string;
}
export interface AppTheme {
mode: ThemeMode;
colors: AppThemeColors;
spacing: {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
};
radii: {
sm: number;
md: number;
lg: number;
};
}
注意,这里不只是把颜色抽出来,还把间距和圆角也纳入了主题对象。因为深色模式下如果某个页面的圆角、留白设计有明显调整,或者某家公司想针对夜间模式做低亮度视觉降噪,这套结构可以直接扩展。
2.3 主题色板对象建议单独收敛
真正维护的时候,我会把 light 和 dark 两个主题对象放在单独的 palette.ts 文件里,避免和业务代码混在一起:
typescript复制import { AppTheme, ThemeMode } from './types';
const lightTheme: AppTheme = {
mode: 'light',
colors: {
background: '#F5F6FA',
surface: '#FFFFFF',
surfaceVariant: '#EDEFF2',
textPrimary: '#1A1A1A',
textSecondary: '#6B7280',
border: '#E2E5EA',
primary: '#2A6FF3',
onPrimary: '#FFFFFF',
danger: '#DC2626',
overlay: 'rgba(0, 0, 0, 0.4)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 6, md: 10, lg: 16 },
};
const darkTheme: AppTheme = {
mode: 'dark',
colors: {
background: '#121212',
surface: '#1E1E1E',
surfaceVariant: '#2C2C2C',
textPrimary: '#E6E6E6',
textSecondary: '#9CA3AF',
border: '#3A3A3A',
primary: '#4E8FFF',
onPrimary: '#0F1620',
danger: '#F87171',
overlay: 'rgba(0, 0, 0, 0.6)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 6, md: 10, lg: 16 },
};
export const palette: Record<ThemeMode, AppTheme> = {
light: lightTheme,
dark: darkTheme,
};
把配色收敛到一个文件之后,后续调色只改这里,业务组件完全不用动。这也是我后面自定义主题容器设计的前提。
3. ThemeProvider + useAppTheme:用一套可维护的方式把模式传下去
3.1 Provider 核心实现
在鸿蒙端实践下来,我最推荐的是用 React Context 把 useColorScheme 转换成“主题容器”。因为直接用 useColorScheme() 返回的 'light' | 'dark' 有一个尴尬之处:这个值只告诉你系统外观,但不包含你的色板、间距、圆角等主题配置。每个页面都自己写 const isDark = useColorScheme() === 'dark',然后去查色板,重复代码太多。
干脆把“系统模式判断”和“主题拼装”都放进 ThemeProvider。组件层只通过 useAppTheme() 拿到当前主题对象和模式。
tsx复制import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { Appearance, useColorScheme } from 'react-native';
import { palette } from './palette';
import type { AppTheme, ThemeMode } from './types';
type OverrideMode = ThemeMode | 'system';
interface ThemeContextValue {
mode: ThemeMode;
theme: AppTheme;
setMode: (mode: OverrideMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getSystemMode(): ThemeMode {
// 某些鸿蒙适配层返回 null,这里统一回落到 light
return useColorScheme() === 'dark' ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
// 如果后期有“App 内手动切深浅色”的需求,
// 就把 mode 标记为 non-system,否则始终跟随系统。
const [overrideMode, setOverrideMode] = useState<OverrideMode>('system');
const systemMode = useColorScheme() === 'dark' ? 'dark' : 'light';
const mode = overrideMode === 'system' ? systemMode : overrideMode;
const theme = useMemo(() => palette[mode], [mode]);
// 保持系统外观变化时组件能更新。
// 鸿蒙端如果 useColorScheme 不稳定,可在这里补一层原生事件订阅。
useEffect(() => {
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
// 这里什么都不用做,useColorScheme 的返回值已经足够触发 React 重渲染。
// 保留订阅的目的是显式确认当前运行环境的事件通道是通的。
setOverrideMode((prev) => prev);
});
return () => subscription.remove();
}, []);
const setMode = useCallback((next: OverrideMode) => {
setOverrideMode(next);
}, []);
const contextValue = useMemo(
() => ({ mode, theme, setMode }),
[mode, theme, setMode],
);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
export function useAppTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useAppTheme must be used within ThemeProvider');
}
return ctx;
}
这里有个细节要说清楚:Appearance.addChangeListener 里如果什么都不做,可能给人一种多余的感觉。但我在鸿蒙端调试时发现,很多适配层的 useColorScheme 并不可靠,尤其是 App 在后台切换系统外观后再回前台,事件触发时机和 JS 重新渲染时机是错开的。保留订阅,至少可以在事件回来时强制触达组件树。如果你们的适配层没有任何事件通道,这步也不会生效,那就必须回到第 1 节,让原生侧先把事件桥接出来。
3.2 业务组件消费主题的新姿势
Provider 搭好之后,业务组件不要任何地方写 useColorScheme()。例如:
tsx复制import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useAppTheme } from '../theme/ThemeProvider';
export function PrimaryButton({ title }: { title: string }) {
const { theme, mode } = useAppTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
return (
<View style={styles.container}>
<Pressable
style={({ pressed }) => [
styles.button,
pressed && { opacity: 0.8 },
]}
>
<Text style={styles.text}>{title}</Text>
</Pressable>
<Text style={styles.modeLabel}>当前模式:{mode}</Text>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
backgroundColor: theme.colors.background,
},
button: {
backgroundColor: theme.colors.primary,
borderRadius: theme.radii.md,
padding: theme.spacing.md,
},
text: {
color: theme.colors.onPrimary,
},
modeLabel: {
color: theme.colors.textSecondary,
fontSize: 12,
marginTop: 4,
},
});
}
这个版本看起来简单,但比一开始写的 const isDark = useColorScheme() === 'dark' 扩展性好太多了。组件完全不需要知道深色模式下 background 到底是什么颜色,它只说“我要用背景色”“我要用主色”“我要用次要文字色”。
3.3 组件级 StyleSheet 必须在主题变化后重建
上面代码里比较重要的一步是:useMemo(() => createStyles(theme), [theme])。这个写法新手很容易漏掉。
React Native 的 StyleSheet.create 本身只是做一次 id 映射,它并不会自动感知外部变量变化。如果直接在模块顶部写:
tsx复制// 反例
const styles = StyleSheet.create({
container: {
backgroundColor: Theme.colors.background, // Theme 无法在模块顶层变化
},
});
这根本走不通,因为模块顶层拿不到动态 Theme。就算你用一个全局变量保存当前主题,当系统切换深色时,全局变量变化了,但 StyleSheet.create 里的旧色值已经缓存,组件不会重绘。
所以正确姿势永远是:先通过 useAppTheme() 拿到 theme,再用 useMemo 基于 theme 生成样式。只有 theme 对象变化时,样式才会重建。这个方法在页面多了以后优势极其明显,因为每个页面都会以最小代价完成更新。
4. Navigation、状态栏和原生弹窗的主题同步不能漏
4.1 NavigationContainer 主题联动
很多 RN 应用都会用 React Navigation。如果你只给业务页面做了自定义主题,但 NavigationContainer 还停留在默认的浅色主题,那页面切换时的转场背景、Tab Bar、header 背景都会是白底的,在深色模式下非常割裂。
建议在容器层做一个映射主题:
tsx复制import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
function AppNavigator() {
const { mode, theme } = useAppTheme();
const navigationTheme = useMemo(() => {
const base = mode === 'dark' ? DarkTheme : DefaultTheme;
return {
...base,
colors: {
...base.colors,
primary: theme.colors.primary,
background: theme.colors.background,
card: theme.colors.surface,
text: theme.colors.textPrimary,
border: theme.colors.border,
notification: theme.colors.danger,
},
};
}, [mode, theme]);
return (
<NavigationContainer theme={navigationTheme}>
{/* 页面栈 */}
</NavigationContainer>
);
}
这里不是我故意把 NavigationContainer 的颜色也写一遍。React Navigation 内部有很多页面转场、header 背景、底部手势区域都会读取 theme.colors。如果不设置,你只会在“某个角落”发现一块白,而且很难定位。
4.2 状态栏和导航栏的亮色内容
鸿蒙有没有类似 iOS 的状态栏前景色
