ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

React Native跨端开发OpenHarmony实战:TodoList与主题切换踩坑指南

React Native跨端开发OpenHarmony实战:TodoList与主题切换踩坑指南 接手一个要跑在 OpenHarmony 设备上的 React Native 项目时我心里其实没底。RN 在 Android 和 iOS 上的生态再成熟到了鸿蒙这边也得像刚毕业那年重新啃文档。团队的目标很明确要交付一个能覆盖手机、平板等多种终端形态、又必须跑在鸿蒙设备上的内部工具。为了控制风险我把项目压到最小可验证范围——一个 TodoList顺带把深色浅色主题切换做了进去。真做完才发现这个看起来人畜无害的小项目几乎把 RN 在 OpenHarmony 上要踩的坑都踩了一遍也把跨端方案落地鸿蒙时最值得记的那些细节全暴露了出来。这篇东西不是官方文档的复述是我作为从业者从项目立项到跑通整个流程的记录适合正准备做 RN for OpenHarmony、或者已经在做但卡在主题切换这块的开发者参考。1. 为什么拿 TodoList 做 RN for OpenHarmony 的切入项目1.1 跨端技术栈进入 OpenHarmony 的三条路线OpenHarmony 原生开发的主流语言是 ArkTS基于 ArkUI 声明式范式写的。如果团队从零开始、只做鸿蒙一个平台直接上 ArkTS 确实最舒服——组件体系完备、状态管理有内置方案、系统能力调用最直接。但现实里大部分开发团队的处境不是这样我们已经有一套 React Native 的代码库有熟悉的组件生态有沉淀多年的业务逻辑层。这个时候再为鸿蒙单独维护一套 ArkTS 代码等于每个需求都要做双倍开发、双倍测试、双倍排期长期看是扛不住的。我当时的判断是跨端复用的收益大于单端原生体验的损失。在这个前提下可行路线无非三种。第一种是 H5 套壳把 Web 页面直接包进 OpenHarmony 应用里开发最快但交互体验和系统能力调用经常卡在桥接层做工具类应用勉强能接受做偏 C 端体验的应用就很难交代了。第二种是用 OpenHarmony 自带的跨端框架比如基于 TS 的那套方案学习成本低但生态还在早期。第三种就是 RN for OpenHarmony也就是把 React Native 的运行时和渲染层迁移到 OpenHarmony 的 ArkUI 之上让 JS 业务代码继续跑 React 生态底层 UI 由 ArkUI 承接。我最终选了第三条理由是团队现有的 RN 基建、组件库、状态管理方案都能直接搬过来业务层几乎不用动只需要处理平台差异和适配层的那些细节。1.2 TodoList 为什么是性价比最高的练兵场选定技术路线之后第一个项目做什么很关键。我当时刻意没有选团队里那个功能繁杂的真实业务而是从头写了一个 TodoList。原因很朴素TodoList 体量小但覆盖面一点都不小。它有列表渲染、有输入交互、有状态管理、有数据持久化、有增删改操作这已经是绝大多数业务应用的公共地基了。把 TodoList 在 RN for OpenHarmony 上跑通等于把列表页、表单页、状态管理、本地存储这几条最常用的链路全部验证了一遍。主题切换是我主动加进去的。最开始团队里有人反对觉得一个内部工具做成浅色就够了搞深色纯属给自己加活。但我坚持要加原因和体验无关而是因为主题切换这个功能非常考验一个应用的架构水平它要抽象出一套可复用的设计变量、要处理系统级主题变更的事件监听、要管理手动偏好和系统偏好之间的优先级、还要解决持久化恢复和切换时界面重渲染的性能问题。这一套做下来比十个 CRUD 页面加起来都更能检验工程质量。后来的实际开发也证明了这个判断主题切换踩的坑比 TodoList 本身的业务逻辑多得多。1.3 主题切换需求的五层拆解很多人听到深色浅色主题切换的第一反应是不就是换两个背景色和文字颜色吗真做起来远不是这么简单。我在项目启动前把需求拆成了五层后面逐层落地。第一层是设计变量体系不能把颜色写死在每个组件里得先抽象出 tokens把背景色、文字色、主色、边框色、间距、圆角统一成一套可切换的变量。第二层是应用内状态需要一个能在全局共享当前主题的机制让任何组件都能拿到当前主题的 tokens。第三层是系统跟随OpenHarmony 系统切到深色模式时应用要能感知到并自动切换。第四层是手动偏好覆盖用户手动选了某个主题后要优先生效不能被系统切换顶掉。第五层是持久化应用冷启动后要恢复用户上一次的偏好不能每次启动都打回默认浅色。这五层每一层单独拎出来都不算难但串在一起就涉及主题上下文、系统监听、存储异步恢复、渲染性能优化等多个环节的协同。做完这五层再回头看你会发现一个看似简单的换色功能背后其实是整个应用架构的一次体检。这也是为什么我强烈建议所有做 RN for OpenHarmony 的人哪怕你的业务完全不需要深色模式也值得专门用一个小项目把主题切换做一遍。2. 环境选型与工程初始化RNOH 适配版本的坑比想象中多2.1 RN 新老架构对 OpenHarmony 适配的影响我最初犯的错误是把 RN 版本直接拉到当时的最新版想着反正都是 React Native版本越新越稳。结果在 OpenHarmony 这边完全不是这个逻辑。RN for OpenHarmony 不是 React Native 官方直接维护的而是社区基于 ArkUI 做的适配层它必须跟着 RN 版本逐个对齐适配进度天然滞后于 RN 官方版本。这里就牵出RN 新老架构对比这个大话题。RN 的新架构Fabric 渲染器 TurboModule JSI在 Android 和 iOS 上已经逐步成为默认路线但在 OpenHarmony 适配层里新架构的接入进度和稳定性各个版本差异非常大。我当时查了一圈社区反馈发现很多新架构相关的渲染问题还没有完全收敛旧架构Paper在 OpenHarmony 上反而因为适配时间长而更稳定。我的处理方式是保守版本策略RN 主版本和 RNOH 适配版本严格按官方模板仓库的对应关系来不追最新选一个社区里被验证过、issue 处理得比较积极的组合。具体版本号我不在这里给了因为更新太快直接说结论选版本的时候少看 RN 官方更新日志多看 RNOH 的 release notes 和已关闭的 issue 列表确认你需要的功能比如 FlatList、AsyncStorage、Appearance API在对应版本里是正常工作的。这一步看似只是版本选择其实决定了后面所有开发流程的稳定性值得花半天时间做足功课。2.2 工程结构原生壳加 JS bundle 的双层架构RN for OpenHarmony 的工程结构和普通 RN 工程不一样它不是直接用 react-native init 建一个纯 JS 工程就完事而是需要从 OpenHarmony 原生工程出发在里面集成 RN 运行环境。我当时的工程目录大概是这样的外层是一个 DevEco Studio 工程里面有 entry 模块、ohos 相关的配置、以及通过依赖引入的 RN 框架包。JS 部分的代码在单独一个目录里维护用 Metro 打包成 bundle.js然后放进原生工程的资源目录里。这个双层结构意味着你在日常开发里其实是同时开着两个世界JS 侧写业务ArkTS 侧写壳和原生能力。调试的时候有两种模式开发模式走 Metro 热更新发布模式走离线 bundle。这个结构第一次接触会觉得绕但理解了就清楚它其实就是把 RN 在 Android/iOS 上原生工程 JS 包的经典结构复制到了 OpenHarmony 上。项目里我用了react-native-oh-tpl作用域下的适配版本包而不是 npm 上标准的 react-native。这一点新手很容易踩坑——直接用标准 react-native 包在 OpenHarmony 工程里编译会报一堆原生模块缺失的错误。正确的做法是在 package.json 里显式依赖适配版本的 RN 核心包其他生态库如果是纯 JS 实现的比如很多状态管理库、日期处理库基本可以保持原样使用但凡是涉及原生模块的库都得逐个确认是否支持 OpenHarmony。2.3 工程初始化阶段就要想好的三件事初始化工作区之前我建议先把三件事想清楚因为它们决定后续开发的舒适度。第一是确认 Metro 的 bundle 输出路径和原生工程的资源加载路径一致不要等写好代码才发现 bundle 根本加载不进去。第二是把开发模式和发布模式的切换逻辑提前配好调试用 Metro 服务正式包打离线 bundle这个配置晚做一步后面每次打包都要多花十几分钟。第三是提前建一个最小可运行的 Demo不写任何业务代码只渲染一个带文字的页面把JS 代码成功跑在 OpenHarmony 设备上这件事先确认掉再开始写 TodoList。我当时跳过第三点直接上来写业务结果花了整整一个下午排查一个诡异的白屏问题最后发现是 bundle 加载路径配错了。如果当时先做最小 Demo这个时间完全可以省下来。这个教训我后来写进了团队的项目启动 checkist 里跨端项目尤其是适配层不成熟的技术栈永远先把最小路径跑通放在业务功能开发前面。3. TodoList 核心功能与状态管理用最小成本搭出完整骨架3.1 数据模型与状态方案选型TodoList 的业务模型非常简单一个 todo 项有 id、text、completed、createdAt 四个字段支持新增、切换完成状态、删除三个操作外加从本地存储恢复数据。这里我刻意没用 Redux Toolkit也没用 zustand而是用了 React 自带的 useReducer。原因很务实项目只有一个页面、一类业务数据引入外部状态管理库增加的间接层在 RNOH 这种适配还不算完全成熟的环境里意味着多一重排查问题的成本。useReducer 在组件树里通过 Context 传递已经足够支撑这个体量。如果你后续要把 TodoList 扩展成多页面、多模块共享状态的项目再升级到 zustand 或者 Redux Toolkit 也不迟那一步的价值在复杂场景才会显现。reducer 的设计我尽量保持了纯函数风格每个 action 都返回一个新数组不做原地修改。这样配合 React 的渲染机制列表更新行为是可预期的。新增时我用时间戳做 id虽然并发场景下不够严谨但单机单用户的 TodoList 完全够用。值得注意的是完成状态切换我用了 map 返回新对象而非直接改原对象这个细节在 FlatList 的 item 优化里很重要后面会细说。interface Todo { id: string; text: string; completed: boolean; createdAt: number; } type TodoAction | { type: add; text: string } | { type: toggle; id: string } | { type: remove; id: string } | { type: restore; todos: Todo[] }; function todoReducer(state: Todo[], action: TodoAction): Todo[] { switch (action.type) { case add: return [ { id: Date.now().toString(), text: action.text.trim(), completed: false, createdAt: Date.now(), }, ...state, ]; case toggle: return state.map((item) item.id action.id ? { ...item, completed: !item.completed } : item, ); case remove: return state.filter((item) item.id ! action.id); case restore: return action.todos; default: return state; } }3.2 FlatList 渲染与交互实现的三个注意点列表是 TodoList 的核心界面。RN 生态里列表首选 FlatList这点在 OpenHarmony 上也不例外但实际使用中有三个细节需要特别注意。第一个是不要在 FlatList 里直接嵌套另一个 VirtualizedListRN 会警告并且滚动行为会异常。我为了做一个头部统计栏一开始用了 ListHeaderComponent后来想再加一个横向滚动条目就顺手在里面放了一个横向 FlatList结果在 OpenHarmony 设备上出现了滚动冲突。正确做法是横向列表用 ScrollView 或者把横向区域整体提升到 FlatList 外部。第二个是 renderItem 函数的引用稳定性。如果每次渲染都新建函数FlatList 的优化机制就失效了。我习惯用 useCallback 包住 renderItem让 item 变化时才重新渲染对应行。第三个是 item 组件要配合 React.memo 做浅比较尤其是当每个 item 要读取主题 tokens 的时候如果 tokens 引用没有变化memo 能避免整表在主题切换时全量重渲染的卡顿。const renderItem useCallback( ({ item }: { item: Todo }) ( TodoRow todo{item} onToggle{toggleTodo} onRemove{removeTodo} / ), [toggleTodo, removeTodo], );TodoRow 内部用 React.memo 包裹这样在主题切换时只要父组件传入的 props 引用没变已渲染的行就不会白做一次 diff。这一点在列表条目多、主题切换频繁的场景下体感差异是很明显的。3.3 数据持久化的时机与恢复策略TodoList 的持久化我用了 AsyncStorage这个库在 RNOH 适配版本里有对应的实现可以直接用。存储逻辑不复杂每次 todos 变化后把整个数组序列化写入本地App 启动时读出来恢复。关键在于时机。我测试时发现如果只在触发增删改时存一次App 被系统杀掉后最后一次操作可能来不及落盘。更稳的做法是订阅 todos 的变化用 useEffect 监听 todos 引用变化在 effect 里异步写入。副作用是写入频率变高了但 TodoList 的数据量极小性能压力可以忽略。启动恢复时要注意一个时序问题AsyncStorage 读取是异步的如果根组件还没等数据回来就渲染列表会先显示空列表再突然跳出历史数据视觉上非常突兀。我的方案是维护一个 isHydrated 状态数据恢复完成前显示一个加载态恢复后再渲染列表主体。useEffect(() { AsyncStorage.setItem(TODOS_KEY, JSON.stringify(todos)); }, [todos]); useEffect(() { AsyncStorage.getItem(TODOS_KEY).then((raw) { if (raw) { dispatch({ type: restore, todos: JSON.parse(raw) }); } setIsHydrated(true); }); }, []);这段代码虽然简单但先恢复再渲染这个顺序是很多初版实现容易忽略的。主题偏好恢复也是同一个思路后面讲主题切换时你会看到一模一样的模式。4. 深色浅色主题切换的完整实现从 token 到上下文再到系统联动4.1 主题 token 体系先定色板再刷墙主题切换最容易犯的错误是直接在组件里写死backgroundColor: #fff真到要切深色的时候发现要改几十个文件。我在项目里用 token 体系来处理这个问题。所谓 token就是把所有会跟着主题变化的视觉属性抽象成变量组件里只引用变量不引用具体值。前端生态里 Element Plus 这类组件库做主题切换也是同一个路子——先定义一组语义化的设计变量再在不同主题下给这组变量赋值组件消费变量即可。我在这套体系里定义了 colors、spacing、radius 三组 token。colors 里拆了 background、card、textPrimary、textSecondary、primary、border、danger 这几个语义颜色spacing 和 radius 因为深浅色主题下通常保持不变我这里也统一抽出来方便以后做品牌换肤或尺寸适配时统一调整。export type ThemeMode light | dark; export interface ThemeTokens { colors: { background: string; card: string; textPrimary: string; textSecondary: string; primary: string; border: string; danger: string; }; spacing: { sm: number; md: number; lg: number }; radius: { sm: number; md: number; lg: number }; } export const lightTokens: ThemeTokens { colors: { background: #F5F6FA, card: #FFFFFF, textPrimary: #1B1B1F, textSecondary: #8A8A8E, primary: #4A6CF7, border: #E6E6EB, danger: #E5484D, }, spacing: { sm: 8, md: 16, lg: 24 }, radius: { sm: 6, md: 10, lg: 14 }, }; export const darkTokens: ThemeTokens { colors: { background: #121216, card: #1E1E24, textPrimary: #ECECF0, textSecondary: #8E8E93, primary: #6D8BFF, border: #2A2A32, danger: #FF6467, }, spacing: { sm: 8, md: 16, lg: 24 }, radius: { sm: 6, md: 10, lg: 14 }, };这里有个配色上的实操心得深色模式下不要简单地把浅色值取反而是要让背景纯度降下来、文字亮度提上去、边框和背景的对比度保持柔和。我最初直接用了黑白反色看起来非常刺眼后来改成这种带一点蓝灰底的暗色方案视觉上舒服很多。另一个心得是主色在深色模式下要适当调亮因为暗底上的同色系颜色看起来会偏暗#4A6CF7在浅色下没问题到深色下就有点糊了我特意在 darkTokens 里调成了#6D8BFF。4.2 ThemeProvider 与 useTheme全局状态的最小实现token 定义好了接下来要让它能在任意组件里被读到。我在 theme 目录下建了 ThemeContext用一个 Provider 包住整个应用。Context 里承载三样东西当前生效的主题模式、对应的 tokens、以及切换主题的方法。这里要重点说一个设计我把主题模式分成了手动偏好和系统主题两个维度。用户没有手动选择过时应用跟随系统用户手动选择了某个主题后手动偏好优先。实现上我维护了两个 statesystemMode 监听系统变化manualMode 记录用户选择真正生效的 activeMode 是manualMode ?? systemMode。这个优先级逻辑是主题切换最核心的部分很多初版实现只做了一个手动切换 state系统一切换就把用户选择顶掉了体验很糟糕。const [systemMode, setSystemMode] useStateThemeMode(light); const [manualMode, setManualMode] useStateThemeMode | null(null); const activeMode manualMode ?? systemMode; useEffect(() { AsyncStorage.getItem(STORAGE_KEY).then((stored) { if (stored dark || stored light) { setManualMode(stored); } }); }, []);setMode 方法里同时做两件事更新 manualMode 状态并把选择写入 AsyncStorage。toggleMode 则是根据 activeMode 取反。Context 的 value 用 useMemo 缓存依赖 activeMode保证只有主题真正变化时才触发消费组件重渲染。这里要特别提醒一个和 useCallback 配合的坑setMode 和 toggleMode 如果每次 render 都重新创建会导致所有消费 useTheme 的组件依赖变化即使主题没变也会重渲染。所以这两个方法也要用 useCallback 包起来依赖项分别是空数组和 activeMode。这个细节我一开始忽略了主题切换没问题但切换完整个列表卡了大约三四百毫秒排查半天才发现是 context value 不稳导致所有 memo 组件全部失效。4.3 跟随系统主题Appearance API 与原生侧兜底跟随系统主题这块我在 RNOH 上的经验是Appearance API 不一定完全可靠。React Native 标准的做法是Appearance.addChangeListener监听 colorScheme 变化但我实测发现在某些 RNOH 版本里从系统设置切深色模式监听事件未必能及时触发或者说触发的时机比系统 UI 慢了一拍。我当时的解决方案是双保险。JS 侧照常挂 Appearance 监听能触发就走 JS 事件如果发现某个版本的 RNOH 适配不完整就在原生侧兜底。OpenHarmony 原生工程里可以拿到系统的 configuration 对象里面有 colorMode 字段通过桥接事件把系统主题变化主动通知给 JS 侧JS 侧收到后更新 systemMode。兜底方案的接入成本不算高但需要改动原生代码所以我的建议是先跑一个最小 Demo 验证 Appearance API 在你选定的 RNOH 版本里是否正常如果正常就不用上原生兜底如果不正常再考虑桥接方案别一上来就做原生侧。useEffect(() { const sub Appearance.addChangeListener(({ colorScheme }) { setSystemMode(colorScheme dark ? dark : light); }); return () sub.remove(); }, []);这个监听的清理一定要做不然组件卸载后回调还在容易引起内存泄漏或者状态更新报错。另外系统主题变化时如果用户已经设置了手动偏好要记得 activeMode 的计算逻辑要优先取 manualMode别让系统事件把用户的选择覆盖掉。4.4 主题切换的渲染性能与视觉过渡主题切换最直观的性能挑战是切换瞬间整个页面的重渲染。TodoList 如果只有一二十条数据全量重渲染其实没啥感觉但我还是按列表可能增长到几百条的标准来优化了。核心就两条context value 稳定、列表 item 记忆化。context value 稳定上面已经说了useMemo 依赖 activeMode没变就不重建 value。列表 item 这边TodoRow 用 React.memo 包裹父组件传入的 onToggle 和 onRemove 用 useCallback 稳定引用这样切换主题时 FlatList 的 data 没有变化只是 context 里 tokens 变了消费 tokens 的容器组件重新渲染而每个 TodoRow 如果自己没有直接读 tokens就完全不需要重渲染。我这里 TodoRow 的文字颜色是通过 props 传入的切换主题后父组件更新 propsTodoRow 正常重渲染一次也是合理的最小重渲染范围。视觉过渡方面我做了个很简单但效果很好的处理主题切换时页面根容器做一个 200ms 的透明度过渡。因为背景色和文字色的变化是瞬时的突然切换会有点闪。用 Animated 给根容器加一个从 0.5 到 1 的 opacity 动画视觉上就像跨了一个淡入淡出的转场。这个方案比给每个列表项单独做颜色动画开销小得多观感也够自然。还有一个容易忽略的状态栏问题深色主题下状态栏的文字颜色不会自动变。如果系统状态栏还是深色文字配深色背景就会看不清时间信号。在 RNOH 上我通过原生侧窗口配置来处理状态栏风格跟随主题JS 侧在 theme 切换时同时更新状态栏样式。这个细节不懂的人基本想不到但真遇到了会非常影响观感。5. 实战中的问题排查与独家避坑记录5.1 常见问题速查表整个项目做下来我记录了一批典型问题和对应的解决方案整理成一张速查表基本覆盖了主题切换场景下最容易踩的坑。问题现象可能原因解决方案启动时先闪一下浅色随后才变成深色AsyncStorage 读取完成前根组件已经按默认浅色渲染增加 hydrated 状态持久化恢复前不渲染业务界面切换主题后部分列表项颜色没变TodoRow 没有正确消费新 tokens或 memo 浅比较拿到了旧 props确保切换逻辑更新 context value列表项通过 props 接过新 tokens状态栏文字在深色模式下看不清状态栏样式没有跟随主题更新原生侧配置状态栏文字颜色跟随 colorMode切换时同步更新系统切深色后应用没有反应Appearance 监听未触发或监听未挂载验证当前 RNOH 版本 Appearance 支持情况必要时走原生桥接事件主题切换时列表明显卡顿context value 引用不稳定memo 机制全部失效setMode/toggleMode 用 useCallback 包住value 用 useMemo 依赖 activeMode深色模式下主色按钮颜色发糊深色底色上使用了和浅色模式相同的品牌色深色 token 里单独采一套提亮后的主色值5.2 一次文字颜色死活不变的排查实录这里想单独讲一次排查过程因为它最有代表性。有个版本里我做完了主题切换背景能变卡片能变但列表里的文字颜色始终是浅色模式的值。我一开始以为是 tokens 传递断链了打了半天日志发现 tokens 确实更新了组件也确实重新渲染了但渲染出来的文字颜色就是不对。后面我注意到一个细节文字颜色发生变化的那部分组件用的不是 useTheme 里的 tokens而是在组件内部自己定义了一个常量颜色。这是我早期为了快速开发写死的写法后来改造时改漏了一处。这个问题的教训不是组件没响应主题而是任何不属于 token 体系的硬编码颜色都会成为主题切换的漏网之鱼。排查方法也很直接全局搜索所有十六进制颜色值看哪些没有走 tokens 引用。这类问题在主题切换项目里几乎一定会出现尤其是项目不是从一开始就用 token 体系而是中途改造的时候。我做 TodoList 是全新的所以硬编码不多如果你们是拿已有的业务项目改造我建议先全局搜颜色值把硬编码全部清掉再开始接主题上下文。5.3 针对 OpenHarmony 平台的三个独家建议最后说三个只针对 OpenHarmony 平台、在 Android/iOS 上根本不用操心的点。第一RNOH 的版本适配是动态的你在网上搜到的很多教程可能已经过时。不要完全照抄某个版本的操作步骤一定要以你实际安装的适配版本为准遇到功能异常先去翻这个版本的 issue 列表。第二网络相关的原生库在 OpenHarmony 上的实现差异很大如果项目后续要接网络请求、上传下载、图片裁剪这类原生能力一定要先验证对应库在 RNOH 上的可用性不要想当然。TodoList 不涉及这些所以很轻松但一旦扩展就全是全新的坑。第三数据持久化用 AsyncStorage 时注意它的底层实现在 OpenHarmony 上可能和 Android 不完全一致大文件、高频写入的场景需要额外测试我当时实际测试了连续写入几百条数据后才确认其稳定可靠。做完这个 TodoList 项目我最深的体会是跨端框架到一个新平台的路上真正的门槛从来不是语法而是那些文档不会写、只有实测才会暴露的适配差异。主题切换这项功能看上去简单却是逼你把组件设计、状态管理、持久化、平台联动都梳理清楚的最好抓手。如果你也正打算在 OpenHarmony 上引入 React Native我的建议是不要急着铺业务先用一个 TodoList 把这条路走通顺便把主题切换做了你得到的不只是一个 Demo而是对这个平台适配特性的完整认知。最后再分享一个小技巧这套主题 token 设计不只在 RNOH 里能用将来如果业务要扩展到 ArkTS 原生的模块直接把 tokens 翻译成 ArkTS 的常量结构体方案思路完全复用一次投入两头受益。
返回列表