☰
React Native on OpenHarmony实战:TodoList深色主题切换全攻略
2026/9/26 14:10:59 网站建设 项目流程

这几年我的跨端技术栈里,React Native(RN)一直占着主力位置,从业务组件到性能优化踩过不少坑。看到 RN for OpenHarmony 社区版本稳定起来之后,我第一时间拿它试了个 TodoList 小项目,并且把深色浅色主题切换这块硬骨头啃了下来。这篇文章就把这个实战过程完整复盘一遍,从工程搭建、列表功能,到系统主题联动、主题状态管理,再到调试时踩到的几个隐蔽问题,全部摊开讲。

这个项目解决的是两个层面的问题:第一,验证 RN 到底能不能在 OpenHarmony 设备上稳定跑业务,开发调试链路是否顺畅;第二,主题切换这种看似简单、实则需要全局配合的功能,在 RN 里到底怎么组织代码才不散、不失控。文章适合准备接 OpenHarmony 生态的 RN 开发者,也适合对 OpenHarmony 感兴趣但还没动手的朋友,毕竟 TodoList 是再经典不过的入门选题,复杂度刚好够你把整套链路走通,又不至于被业务逻辑淹没。

1. 项目缘起:为什么偏要在 OpenHarmony 上跑 RN?

1.1 跨端开发的现实困境

做移动端的人这几年应该都有同一种感觉:平台越来越多,业务越来越复杂,可团队的人并没有变多。以前一套 Android 代码配一套 iOS 代码已经够累了,现在如果每个新平台都要重新写一遍 UI 和交互,光维护成本就能把研发团队拖垮。React Native 的核心价值就是“写一次,跑多处”——业务逻辑和大部分 UI 代码都能复用,只有需要调用系统能力时才去写原生代码。

OpenHarmony 作为一个面向多设备形态的开源操作系统,设备量在快速增加,很多开发者开始思考要不要接入。问题在于,如果为了一个平台单独维护一套 ArkUI 代码,工程量不比维护原生少。而 RN for OpenHarmony 的方案把问题简化了:把 RN 的 JS 引擎、渲染链路、原生模块绑定都适配到了 OpenHarmony 上,JS 侧写的组件和业务逻辑可以直接在 OpenHarmony 设备上跑起来。对一个已经有 RN 技术储备的团队来说,这是切入 OpenHarmony 成本最低的路径。

1.2 新老架构对比:我们要站在哪一边

聊 RN for OpenHarmony 之前,有必要先聊一下 RN 的新老架构。很多已经做了几年 RN 开发的人,对“新架构”这个词又爱又怕。老架构的核心是 Bridge 桥接层,JS 和原生之间所有通信都要经过这个桥,消息要序列化、跨线程传递,虽然在大多数业务场景下性能够用,但只要涉及高频调用、大数据传递,瓶颈就特别明显。

新架构则把 Bridge 换成了 JSI(JavaScript Interface),JS 可以直接拿到 C++ 层、原生层的对象引用,不需要再走序列化转一圈。翻译一下就是:老架构像你找一个中间翻译跟外国人对话,每句话都要经过翻译转述;新架构是你直接把外语学明白了,面对面沟通,省掉转述带来的一切损耗。Fabric 渲染器、TurboModule 都是在这个基础上长出来的,新架构在启动速度、渲染性能、与原生交互效率上都有明显提升。

RN for OpenHarmony 的适配版本就是踩在新架构思路上做的。这意味着这并不是一个“很勉强的移植”,而是从设计上就考虑了现代 RN 的工作方式。你在 OpenHarmony 上写的组件、用的 Hook、调的 API,和你在 Android/iOS 上写 RN 的体验高度一致,学习曲线比想象中平缓得多。

1.3 为什么选 TodoList 做选题

TodoList 是跨端开发里的“Hello World Plus”,看起来不起眼,但五脏俱全:它要有数据模型,要有列表渲染,要有输入交互,要有完成状态的变更,还要有本地持久化。把这些都走通,你就已经掌握了一个业务应用最核心的骨架。

更重要的是,TodoList 特别适合做主题切换的试验田。一个任务列表页里同时有背景色、卡片色、文字色、输入框、按钮、复选框、分割线、占位符,几乎覆盖了主题化要处理的全部视觉元素。如果只在单个页面上切个背景色,你根本测不出主题系统有没有漏洞;但放在 TodoList 里,任何一处颜色没适配都会立刻暴露出来。

我在这个项目里把功能边界定得很清楚:添加任务、删除任务、标记完成/未完成、本地持久化、深色浅色主题切换,外加手动切换和跟随系统两种模式。这样既覆盖了核心链路,又不至于让 CRUD 变成另一个大工程。

2. 工程搭建与关键配置

2.1 环境准备和工程初始化

RN for OpenHarmony 的工程搭建和常规 RN 工程大同小异,但有几个前置条件需要注意。首先是 Node.js 环境,一般建议用 20 以上的 LTS 版本,RN 工具链对 Node 版本还是比较敏感的。其次是 OpenHarmony 的 IDE——DevEco Studio,以及对应的 SDK,这两个是跑 OpenHarmony 原生工程和模拟器的基础,类似你在 Android 开发里要装 Android Studio 和 SDK 一样。

工程初始化方面,你可以直接基于 react-native-ohos 社区的适配模板来建项目。我那会儿是先创建一个标准的 RN 工程,然后把 OpenHarmony 侧的原生工程目录、配置脚本、依赖声明加进去。看起来有点折腾,但社区适配包的 README 里一般会有明确的步骤,按着走就行。

需要提醒一句:OpenHarmony 侧的构建是用 hvigor 来执行 Gradle 之外的另一套构建逻辑,所以第一次构建的时候等待时间会偏长。不要以为自己配错了,耐心等它把依赖拉完就好。如果你之前配过原生 Android 环境,对这类“初始化慢、后续快”的流程应该很熟悉。

2.2 目录结构与依赖清单

工程结构上,RN for OpenHarmony 项目比普通 RN 多了一整个 OpenHarmony 原生部分。简单来说,工程分为三层:

目录职责说明
App.tsx等 JS/TS 代码业务逻辑和 UI日常开发主要在这层
harmony/OpenHarmony 原生工程包含 entry、配置、原生能力注册
oh_modules/OpenHarmony 依赖模块类似 node_modules,但给原生侧用

实际开发时,绝大多数时间你只需要写 TS/TSX,原生部分只在需要新增系统能力调用时才会动。依赖方面,除了 react、react-native 本体,还需要安装 OpenHarmony 适配包,它负责把 RN 核心模块映射到 OpenHarmony 的能力上。想调用本地存储、振动、Toast 这类系统能力时,再去社区仓库里找对应的封装库,和 npm 上找第三方库的思路完全一样。

2.3 调试链路搭建

调试链路是我觉得这个项目最有意思的部分之一。RN 在 OpenHarmony 上的调试方式和传统 RN 类似,核心链路是 Metro Bundler 打包 JS,设备再加载这个 Bundle。

我的调试套路是这样的:先在电脑上启动 Metro,它会监听 JS 代码变化并增量打包;然后启动 OpenHarmony 模拟器或连上真机,让设备加载开发服务器上的 Bundle。模拟器通常是直接访问 localhost,真机则需要把 Metro 的 host 指向开发机的局域网 IP,这一步在社区的调试文档里有明确说明。

踩过几次坑之后,我的建议是开两个终端:一个专职跑 Metro,另一个用来做构建和安装操作。Metro 的日志一定要看,尤其是出现红色报错的时候,它给的堆栈信息往往能直接定位到是 JS 侧的问题还是原生侧的问题。另外,改完 OpenHarmony 原生代码后一定要重新构建,单纯刷新 JS 是看不到原生变更的,这是很多第一次接触这个项目的人最容易懵的地方。

3. TodoList 本体:数据、状态与交互

3.1 数据模型与本地持久化方案

TodoList 的数据模型看起来简单,但设计得仔细一点,后面能省很多事。我给 Task 定了四个字段:id用来唯一标识,text存任务内容,completed标记完成状态,createdAt记录创建时间。

interface Task { id: string; text: string; completed: boolean; createdAt: number; }

createdAt是最容易被新手忽略的字段。没有它,你想按创建时间排序、按天分组、或者以后扩展提醒功能,都得回头改数据模型。加一个时间戳字段成本极低,收益却很高。

持久化方案我选的是基于异步存储的封装库,用法上类似 web 端的 localStorage,只是 API 是异步的。每次增删改之后,把整个任务列表序列化存进去;应用启动时,先读本地存储,没有值就用空数组兜底。这里有一个关键点:启动时读取是异步操作,需要在数据加载完成后再渲染列表,否则界面会出现一闪而过的空状态。

3.2 列表渲染与增删改的核心逻辑

列表渲染直接用 FlatList,它自带虚拟列表能力,在大数据量下不会卡 UI。但 TodoList 这种体量的项目,用 FlatList 最大的好处反而不是性能,而是它强制你走“数据驱动视图”的思维方式——列表 UI 只负责渲染tasks数组,所有数据变更都通过更新这个数组来实现。

const addTask = (text: string) => { if (!text.trim()) return; setTasks(prev => [...prev, { id: `${Date.now()}-${Math.random()}`, text, completed: false, createdAt: Date.now(), }]); }; const toggleTask = (id: string) => { setTasks(prev => prev.map(task => task.id === id ? { ...task, completed: !task.completed } : task )); }; const deleteTask = (id: string) => { setTasks(prev => prev.filter(task => task.id !== id)); };

这三个函数是 TodoList 的灵魂。注意我全程都在用展开运算符创建新数组,而不是直接push、pop修改原数组。React 要靠“状态引用变了”来判断是否需要重新渲染,如果你直接改原数组,React 拿到的还是同一个引用,列表不会刷新,这是新手最容易踩的深坑。

3.3 状态管理的取舍:useState 还是全局 Store

TodoList 这个体量,要不要上 Redux 或者 Zustand?我的答案是:不需要。引入全局状态库意味着增加依赖、样板代码和概念负担,对于这个项目来说是杀鸡用牛刀。我用的是useState加useReducer的组合,数据流清晰,代码量少。

真正需要全局状态方案的是主题。因为主题状态要被页面、列表项、输入框、状态栏多处共享,如果用 props 层层传递,组件一多就变成灾难。这两个需求合在一起,我最后选的是 Context + Hook 的组合:任务数据用useState管理,主题状态放进ThemeContext,再封装一个useThemehook 给业务组件消费。

Context 方案的核心逻辑是:提供者维护主题状态,所有消费这个 Context 的组件共享同一份数据。当主题变化时,React 会自动触发依赖该 Context 的组件重新渲染,不需要手动通知。这对主题切换这种全局性更新来说是恰到好处的设计。

4. 深色浅色主题切换的技术解剖

4.1 主题切换在 RN 端的三种常规做法

主题切换看着简单,真做起来水很深。RN 生态里主流做法有三类,我挨个说下它们的适用场景。

第一种是手动切换。应用内放一个设置项,用户选“浅色”或“深色”,选择结果存到本地,代码里根据这个值决定用哪套颜色。这种方案的好处是完全可控,不受系统影响;缺点是你得自己处理所有颜色变化。

第二种是跟随系统。用 React Native 自带的useColorScheme()拿到系统当前是深色还是浅色模式,界面颜色跟着系统走。好处是用户不用在每个应用里单独设置,系统切深色,应用自动跟着切;缺点是用户没有选择权,想在应用里用反色也被迫跟着系统走。

第三种是两种结合:默认跟随系统,同时提供“浅色 / 深色 / 跟随系统”三个选项给用户选。这是体验最好的方案,因为键盘侠段位越高,对个性化需求越强。我这个项目做的就是第三种。

方案用户控制力实现复杂度体验
纯手动高低不够智能
纯跟随系统无最低无法个性化
手动 + 跟随系统高中最好

4.2 系统级联动:useColorScheme 与 Appearance

React Native 里有两个关键 API 处理系统外观:useColorScheme()这个 Hook 能拿到'light'、'dark'或null,适合在函数组件里直接用;Appearance.addChangeListener则适合在需要监听变化的场景里注册回调。

在 OpenHarmony 上,这两个 API 同样能用,原因是 RN 适配层已经把 OpenHarmony 的系统外观配置映射到了 RN 的标准接口上。你在模拟器或真机上切换深色模式,useColorScheme()的返回值会相应变化,体验和 Android/iOS 上没有区别。

你可以把系统深浅色模式理解成“天气预报”,useColorScheme()是挂在窗外的温度计,应用是出门前看天气决定穿什么衣服的人。温度计告诉你今天冷,你就换上厚外套(深色配色);温度计告诉你今天热,你就换薄衣服(浅色配色)。如果应用里有人工切换,就相当于这个人不仅看天气预报,还可以自己决定“我今天就是想穿厚的”。

4.3 主题变量的组织方式:一份配置两套色板

主题切换最容易犯的错误,是在组件里直接写死颜色值,比如把背景色写成#FFFFFF,把文字颜色写成#333333。写成这样,换肤时你就要满项目找这些颜色,找到一处改一处,改完还可能漏,体验极其酸爽。

正确的做法是建立一套语义化颜色变量。所谓语义化,就是颜色名不叫“白色”“灰色”,而是叫“背景色”“文字色”“边框色”“主色调”。组件的代码里永远只引用语义化变量,不关心具体色值;具体色值在哪套主题里定义,由主题系统去管。

我把两套色板放在两个对象里,结构完全一致,只是具体颜色不同:

export const lightTheme = { colors: { background: '#F5F5F5', card: '#FFFFFF', text: '#1A1A1A', placeholder: '#9E9E9E', primary: '#4C6FFF', border: '#E0E0E0', completedText: '#999999', }, }; export const darkTheme = { colors: { background: '#121212', card: '#1E1E1E', text: '#E0E0E0', placeholder: '#666666', primary: '#7B9CFF', border: '#333333', completedText: '#555555', }, };

这样做有个巨大的好处:业务组件完全不关心自己在深色还是浅色模式下,只关心“我现在该用什么语义的颜色”。主题切换时,你只需要换掉主题对象本身,整个页面会像变魔术一样自动完成换肤。

4.4 关键代码实现:切换、缓存与无缝刷新

现在到了整篇最核心的部分:把主题切换做成一个可以无缝刷新的全局能力。我用 Context + Hook 组合的方案。

首先是定义主题状态的类型和 Context:

type ThemeMode = 'light' | 'dark' | 'system'; interface ThemeContextType { mode: ThemeMode; isDark: boolean; theme: typeof lightTheme; setMode: (mode: ThemeMode) => void; } const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

然后是 ThemeProvider 的核心逻辑。这里有一个关键设计:mode === 'system'时,实际主题由系统决定;mode手动指定时,手动值优先。同时用useMemo缓存计算结果,避免每次渲染都重新生成 theme 对象。

export const ThemeProvider = ({ children }) => { const systemScheme = useColorScheme(); const [mode, setMode] = useState<ThemeMode>('system'); useEffect(() => { loadStoredMode().then(saved => { if (saved) setMode(saved); }); }, []); const { isDark, theme } = useMemo(() => { const resolvedDark = mode === 'system' ? systemScheme === 'dark' : mode === 'dark'; return { isDark: resolvedDark, theme: resolvedDark ? darkTheme : lightTheme, }; }, [mode, systemScheme]); const changeMode = useCallback((next: ThemeMode) => { setMode(next); saveMode(next); }, []); return ( <ThemeContext.Provider value={{ mode, isDark, theme, setMode: changeMode }}> {children} </ThemeContext.Provider> ); };

这套设计的关键在于“解析”过程:不管用户选了什么,最终都会先解析出一个布尔值isDark,再根据isDark决定用哪套色板。这样上层组件拿到的是一个确定的主题对象,不需要再逐层判断当前是什么模式。

持久化的部分也很重要。用户手动切换主题后,我希望下次启动应用时还能记住用户选择,所以我用异步存储把mode存起来。启动时先默认用'system',再从本地读取保存的选择,读到就立刻更新状态。这里有个小细节:持久化的恢复过程是异步的,可能会在首帧渲染之后才完成,所以会出现短暂的主题跳动。我的处理方式是读取期间不渲染主界面,或者用一个延迟加载的骨架屏来兜底。

业务组件里,用法就变得非常简单了:

const { theme } = useTheme(); return ( <View style={{ backgroundColor: theme.colors.background }}> <Text style={{ color: theme.colors.text }}>我的任务</Text> </View> );

任何组件只要接上useTheme(),就自动获得换肤能力。代码零侵入、逻辑清晰、扩展方便,这就是语义化主题系统带来的体验提升。

4.5 被很多人忽略的细节:状态栏、输入框和列表分割线

主题切换光换页面背景色远远不够。我做完第一版以后,在深色模式下截图一看,状态栏上的字还是黑色的,在深色背景上完全看不清。所以主题切换必须覆盖这些容易被忽略的边角:

状态栏要跟着主题调整前景色。浅色模式下状态栏文字应该是黑色,深色模式下应该是白色,用StatusBar的barStyle属性控制。

输入框在深色模式下,光标颜色、占位符颜色、键盘外观都要重新适配。RN 的TextInput有keyboardAppearance属性,可以设置键盘是深色还是浅色。占位符颜色从主题里取浅色模式的placeholder色值,否则深色背景下浅灰占位符能看到,但深灰占位符就看不见了。

列表里的分割线、卡片的阴影、完成任务的降级文字色,这些都是一眼看不见、但凑近看很难受的细节。阴影在浅色模式下是优雅的层次感,在深色模式下因为背景色太深而直接消失,所以深色模式下要改成用边框色来区隔卡片和背景。

对比度是另一个值得花心思的点。同一个#999999,在浅色背景上看还挺清楚,放到深色背景上几乎隐身。所以两套主题不能只是背景色对调,文字色、辅助色都要单独调过,配色不是简单的“反色”关系。

5. 踩坑记录与排查技巧实录

5.1 主题切换后列表条目不刷新的深坑

我在做完主题切换后遇到第一个诡异问题:页面背景和标题都跟着主题变了,但列表里的任务条目还是旧的配色,一个都没变。排查了半天,最后发现是 FlatList 的锅。

FlatList 为了性能做了 PureComponent 级别的优化,它的子项在 props 没有变化时不会重新渲染。我虽然把整个页面的背景色换成了 theme 里的值,但列表项组件接收的itemprop 是原来的数据对象,引用没有变化,FlatList 就认为不需要重新渲染,于是列表项保留了旧颜色。

解决方法是给 FlatList 传extraData属性:

<FlatList data={tasks} extraData={theme} renderItem={({ item }) => <TaskItem task={item} />} />

extraData的作用就是告诉 FlatList:“除了 data 之外,这些数据的变化也要触发重新渲染。”把 theme 传进去之后,主题一换,列表项就跟着刷新了。

5.2 模拟器上色板不变化的坑

第二个坑出在模拟器上。我在模拟器里手动切换深色模式,页面纹丝不动。第一反应是自己代码写错了,对着 useColorScheme 的文档翻了好几遍,确认用法没问题。后来才发现是模拟器本身的问题:模拟器系统设置里的深色模式切换后,useColorScheme()并不会像真机那样及时返回新值,需要重新加载 Bundle 才能感知到系统外观变化。

这类问题的排查思路很重要:先把“代码逻辑”和“运行环境”分开来排查。我的办法是在组件里临时渲染一行Text显示当前的systemScheme值,这样能立刻确认 API 返回的到底是什么。如果系统切了深色但 API 返回还是light,那就是环境问题;如果 API 返回已经变成dark但页面没变色,那才是代码问题。这条排查思路能帮你快速定位一大半的主题切换 Bug。

5.3 常见问题速查表

把项目过程中遇到的其他问题整理成一张表,方便你直接对着排查:

现象可能原因排查方法
主题切换后部分按钮颜色不变样式里硬编码了颜色全局搜索#颜色值,改成 theme 引用
系统深色模式不生效mode没有设置为system,或持久化恢复逻辑覆盖了选择检查初始 state 和 read storage 的时机
切换主题时界面闪烁theme 对象每次渲染都新建了引用用useMemo稳定 theme 对象
深色模式下状态栏看不清没设置barStyle根据isDark动态设置
任务完成文字颜色太淡降级色在深色下对比度不足单独调 deep 色板,别复制 light 的值
DevEco 构建报错依赖版本和 SDK 版本不匹配锁定版本号,与官方文档核对
Metro 连不上真机网络或 host 配置问题设置--host指向开发机局域网 IP

5.4 我额外补充的几条独家经验

再说几个规则文档里不会写、但我实际跑项目攒下的经验。

第一,主题切换最好从项目一开始就做,而不是做到一半再补。如果先写死颜色再做主题化,你要像考古一样在项目里挖出所有硬编码颜色,工作量直接翻倍。这个 TodoList 项目我一开始就规划了主题层,后加功能时只需要引用主题变量,几乎零重构成本。

第二,截图对比测试很重要。我习惯在浅色和深色模式下分别截图,然后放在一起对比。很多色值问题在模拟器上肉眼看不出来,但两张截图放一起,对比度、辨识度、层次感的差异一眼就能分辨。

第三,useMemo别乱用,但在主题这种场景里尽量要用。每次组件树重渲染时如果 theme 对象是新对象,所有消费主题的组件都会重渲染,可能导致不必要的性能损耗。用useMemo缓存 theme 对象,只有真正变化时才生成新引用,性能更稳。

写在最后的实际操作体会

把 TodoList 跑起来其实只花了我一个下午,真正花时间的是把主题切换做得“不露馅”。我的体会是,主题切换拼的根本不是 API 用得熟不熟,而是你有没有把所有颜色入口都收拢到同一份配置里。写代码的时候图省事,每个页面里顺手写几个#FFFFFF,等做切换主题的时候就会想穿越回去把这些颜色都挖出来改掉。所以哪怕项目再小,一开始就做好语义化颜色变量,后面会省下大量时间。

如果你也想在 OpenHarmony 上试试 RN,我的建议是从这个 TodoList 加主题切换的选题开始。它足够小,能让你完整走通环境搭建、调试链路、状态管理、持久化、主题系统这几条核心链路;它又足够完整,做完以后你对 RN for OpenHarmony 的开发体验会有一个非常扎实的判断。后续你还可以把这里的主题方案抽成独立 npm 包,或者加上多语言、图片适配,玩法还很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询