ARTICLE DETAIL

资讯详情

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

React Native鸿蒙跨平台开发实战:模拟加油站项目解析

React Native鸿蒙跨平台开发实战:模拟加油站项目解析 React Native 开发最绕不开的一个话题就是“跨平台”。这词儿听起来高大上但落到实处无非是“写一次到处跑”还是“写一次调试到吐血”的问题。最近鸿蒙生态起来了React Native 也被华为官方纳入鸿蒙的跨平台开发方案很多团队都在摩拳擦掌。但网上的资料要么太玄乎一上来就是源码分析要么就是写个 Hello World 就完事根本看不出门道。这篇我用一个具体的实战项目——模拟一个加油站——把 React Native 在鸿蒙上做跨平台开发的基础逻辑都过一遍。这个项目不算复杂但足够典型有设备状态油枪、有实时数据单价、加油量、金额、有动态交互点击加油、弹窗支付还涉及了列表渲染加油记录。把这套东西跑通你对 RN 在鸿蒙上的开发流程、组件生命周期、状态管理、原生模块调用这些核心概念就能建立起一个相对完整的地图。1. 为什么用“模拟加油站”来入门鸿蒙跨平台开发选这个项目不是没来由的拍脑袋。1.1 项目周期短但覆盖了 90% 的核心 API一个加油站的核心业务拆解下来就几个动作选油枪 - 看单价 - 开始加油 - 实时计算金额 - 支付收台 - 记录流水。这套流程映射到 React Native 开发几乎覆盖了日常业务开发所有的高频操作UI 布局油枪的摆放、价格标签、数字跳动涉及 View 的嵌套、 Flexbox 布局、绝对定位。状态管理哪个油枪正在工作、当前加油量是多少、金额怎么实时变化这需要你理解 React 的useState和useEffect这是 RN 开发的核心中的核心。列表渲染加油记录的滚动展示考验FlatList的使用以及如何处理好 key 的取值。交互反馈点击油枪按钮、弹窗确认支付、操作成功的提示这“点击-反馈”的闭环是所有 App 的命根子。定时器模拟加油量增长用setInterval这是业务开发中做轮询、做动画的基础。把这些环节用鸿蒙的react-native-harmony框架跑通你就明白了从“Web 前端视角”到“原生移动应用视角”的转变。1.2 场景接地气方便验证“跨平台”的成色我见过太多人学跨平台用某个公司内部的工单系统或者抽象的数据展示页来练手结果写完在 Android 上调试完就扔一边了根本感受不到“跨平台”带来的便利。加油站这个场景自带“硬件感”和“实时感”非常适合跨平台验证。你在代码里写死一个setInterval每 100 毫秒增加 0.01 升油量在 Android 上跑一下再在鸿蒙的 DevEco Studio 模拟器上跑一下两边 UI 的刷新频率、动画的流畅度肉眼可见地进行对比。这种直观的体验比读十篇架构分析都管用。而且这套代码逻辑将来你要是想做个智能硬件的配套 App比如蓝牙控制加油桩底层的状态控制思路是通用的。1.3 鸿蒙适配是现在的核心难点也是亮点目前做 RN 跨平台开发主流的还是 Android 和 iOS。鸿蒙的接入让很多团队既兴奋又头疼。兴奋在于这是一个万亿级的新市场头疼在于环境配置、原生模块桥接总会碰到各种“坑”。这就像你开惯了自动挡的车突然给你一辆手动挡虽然操作逻辑大差不差但离合器原生桥接的火候把握不好车子就会一顿一顿的出现白屏、卡死。这篇文章沉淀的实操流程重点就是让你在配置鸿蒙开发环境时少走弯路提前知道哪些依赖装不上、哪些 Gradle 配置需要额外处理。把这些前置条件解决了后面的业务代码其实和普通 RN 开发没有本质区别。2. 环境准备鸿蒙上跑 RN 的第一步最容易踩坑环境搭建这块我没资格讲得太细因为每个版本的工具链都在变今天写的路径明天可能就失效了。我只能把我验证过的、当前版本下最稳妥的思路分享出来。2.1 开发工具全家桶我装了这些Node.js: 必须用 18.x 或以上版本我用的是 18.20太老的版本不可行。安装后记得检查 npm 源国内环境建议设置淘宝镜像不然后面装依赖会让人怀疑人生。DevEco Studio: 鸿蒙开发的官方 IDE我当前用的是 5.0.x 版本。这个 IDE 内置了鸿蒙的 SDK 和模拟器是跑通 RN 鸿蒙版的关键。下载时选标准版就行记得把 SDK 组件勾选完整。Android Studio (可选): 如果你想做全平台对比可以装一个。但本文的重点是鸿蒙所以没有 Android Studio 也能完成。2.2 初始化项目的两种选择用官方脚手架: 在 DevEco Studio 里勾选 React Native 模板创建。这是最省事的方式官方已经帮你把 native 工程和 rn 工程结构都配置好了。手动集成: 对于老项目需要手动添加 HarmonyOS 的工程模块。这里就要用到react-native-harmony这个包它适配鸿蒙的 ArkTS 运行时。手动集成的坑比较多依赖版本匹配很容易出问题尤其是react-native 的版本要和 react-native-oh-tmp/cli 的版本对齐这步建议直接看官方文档不要凭感觉在 package.json 里乱填号。启动项目前有两个关键操作不能省安装依赖: 进入项目根目录执行npm install。如果安装过程中报错十有八九是网络问题或者镜像源没配置对。同步 Gradle: 使用 DevEco Studio 打开harmony目录等待 Gradle 同步完成。这一步会下载很多依赖第一次可能耗时半小时以上。务必保证网络通畅不然很容易出现依赖找不到而失败的情况。2.3 启动时白屏先别慌鸿蒙上跑 RN 项目最崩溃的不是编译失败而是编译成功、模拟器也启动了但屏幕一片白。这种情况大概率是 JS Bundle 没加载进来。你需要看看 Metro Bundler 的终端窗口是不是还停留在info状态。如果是点一下终端里的r键重新加载或者直接在 DevEco Studio 的终端里用hdc命令检查一下设备的网络连接状态。我自己在这边卡过一次后来发现是电脑的防火墙把 Node 进程拦截了导致模拟器访问不到 Metro 服务。解决办法把 Node.js 加入防火墙白名单或者暂时关闭防火墙后重试。小提示初次启动恨容易在“加载资源”这一步耗时过长看上去像死机了。耐心等待只要 Metro 终端没有报错堆栈就不用干预。3. 先画界面油枪列表和单价展示的组件设计环境好了开始写代码。先别急着写业务逻辑我们把 UI 搭出来。这里的核心是让界面长得像一个加油站同时能布置下来后续要操作的状态。3.1 规划 UI 层级Flexbox 是唯一解吗一个典型的加油站 UI 至少有这几层顶部状态栏显示当前加油站的名称和营业状态。主体功能区摆放 4-6 个加油机/油枪。底部信息面板展示当前选中油枪的单价、当前加油量、应付金额以及支付按钮。用 Flexbox 布局来实现时我通常把整个页面切成三段式结构View style{styles.container} {/* 顶部状态 */} View style{styles.header} Text style{styles.headerText}XX 加油站 · 营业中/Text /View {/* 油枪区域可横向滚动 */} ScrollView horizontal{true} showsHorizontalScrollIndicator{false} style{styles.pumpArea} {/* 油枪卡片们 */} /ScrollView {/* 底部结算面板固定高度 */} View style{styles.payPanel} {/* 单价、加油量、金额、按钮 */} /View /View在 RN 里横向滚动交互很适合用ScrollView里面再去循环渲染“油枪卡片”。3.2 油枪卡片的动态效果状态决定颜色加油站里最直观的“状态”就是油枪是否在使用中。这可以通过一个代表isActive的布尔值来控制样式。一个“油枪卡片”我把它抽象成这样的组件const PumpCard ({ pumpId, price, isActive, onPress }) ( Pressable onPress{onPress} style{[styles.pumpCard, isActive styles.pumpCardActive]} Text style{styles.pumpId}{pumpId}/Text Text style{styles.pumpPrice}¥ {price}/L/Text View style{[styles.pumpIcon, isActive ? styles.pumpIconWorking : styles.pumpIconIdle]} / /Pressable );我把Pressable作为根节点因为后续需要在整个卡片上做点击事件。这个组件设计上有几个点需要注意styles.pumpCardActive和styles.pumpCardIdle的区分通过动态数组[style, condition style]的方式这样代码量少而且当组件重新渲染时React 会自动做 diff 更新。这比用计算属性去拼接字符串要更符合 React 的习惯。UI 指示点用一个小的色块来代表油枪的“待机/工作中”比改整个卡片的底色更直观也更接近真实硬件的 LED 灯效果。3.3 数值信息的展示粒度不要直接甩一个大数字底部面板的信息展示也很讲究。比如价格是固定两位小数加油量可能是一位或两位小数金额是时时变化的。我会把展示区域分开View style{styles.infoRow} View style{styles.infoItem} Text style{styles.infoLabel}单价/Text Text style{styles.infoValue}{currentPrice.toFixed(2)}/Text /View View style{styles.infoItem} Text style{styles.infoLabel}加油量/Text Text style{styles.infoValue}{volume.toFixed(1)} L/Text /View View style{styles.infoItem} Text style{styles.infoLabel}应付金额/Text Text style{styles.infoValue}¥ {amount.toFixed(2)}/Text /View /View这里有个非常基础但容易犯错的点toFixed()返回的是字符串所以如果你想在这个数字后面再累加一个单位“L”或者“¥”要注意中间的空格和拼接。在 UI 层做格式化而不是在状态层做这是为了保持状态数据的“纯洁性”方便后续扩展业务逻辑比如打折、四舍五入策略不至于因为格式化的 String 导致计算错误。4. 状态管理让油枪“动”起来界面上所有的“动”和“变”核心都在状态。4.1 useState模拟“加油”的驱动源模拟加油本质是定时器驱动计数器的累加。首先定义状态const [pumps, setPumps] useState([ { id: A01, price: 7.89, isActive: false, volume: 0 }, { id: A02, price: 8.01, isActive: false, volume: 0 }, { id: B01, price: 7.56, isActive: false, volume: 0 }, ]); const [activePumpId, setActivePumpId] useState(null);这个数据结构里我故意引入了两个状态一个是pumps数组本身一个是当前激活的activePumpId。为什么不直接在数据里改isActive因为业务逻辑上加油站同一时刻只能有一个油枪工作。如果我把isActive作为数据放在数组里那么“开始加油”和“停止加油”的操作就需要遍历数组去寻找之前的 active 项比较繁琐。而抽离出activePumpId这个“索引值”在逻辑处理上会直观很多const startPump (id) { // 先关掉之前正在加的如果这次点击的不是同一把枪 setPumps(prev prev.map(p ({ ...p, isActive: p.id id ? !p.isActive : false, }))); setActivePumpId((prevId) { if (prevId id) { // 如果点击的是同一把枪视为“停止” return null; } // 否则启动新的 return id; }); };这套“数据驱动视图”的写法看起来绕但因为用了map返回新数组能保证 React 正确追踪到数组内容的变化从而刷新 UI。如果你直接修改旧 state比如pumps[0].isActive trueRN 的界面是不会有任何变化的。4.2 useEffect定时器的生命周期管理定时器怎么用放在useEffect里但必须清理。useEffect(() { if (!activePumpId) return; const interval setInterval(() { setPumps(prev prev.map(p p.id activePumpId ? { ...p, volume: parseFloat((p.volume 0.01).toFixed(2)) } : p ) ); }, 100); // 清理函数组件卸载或 effect 重新运行时触发 return () clearInterval(interval); }, [activePumpId]);这个return里的clearInterval是 React 开发者最容易会忘掉的细节。如果不清理当用户切换油枪时之前那个油枪的定时器可能还在跑后台就会偷偷累加减速你会看到 UI 上的数值跳来跳去最后导致严重的数据错乱。这里的0.01是模拟加油的“速率”你可以把它改成0.05或者0.1来体验数值跳动的感觉。注意我用parseFloat(...toFixed(2))处理浮点数精度问题因为 0.1 0.2 在 JavaScript 里并不等于 0.3直接加会有精度问题。4.3 金额的计算由“状态”推导而非存储金额你不要单独用一个useState去维护。因为它是price * volume的派生值如果单独存一个 state那么每次更新 volume 时你还得同步更新 amount容易被忽略导致数据不一致。正确做法是在渲染时直接计算const activePump pumps.find(p p.id activePumpId) || {}; const amount activePump.price ? activePump.volume * activePump.price : 0;这样保证了一个很核心的开发原则单一数据源。UI 展示的永远是根据当前 pumps 状态计算出来的最新值逻辑不容易乱。5. 列表与弹窗加油记录功能的完善5.1 用 FlatList 渲染历史记录当“加油”结束后用户应该能在下方看到一个“今日记录”的列表。React Native 里长列表的渲染几乎都推荐用FlatList。因为模拟器的性能有限如果用ScrollView里map几十条数据可能感觉还行但一旦数据上百条每次页面更新都要渲染整个视图就会卡顿。实现逻辑很简单用一个数组存储历史记录const [history, setHistory] useState([]); const completePump (pump) { // 把当前油枪写入历史 const record { id: Date.now().toString(), pumpId: pump.id, volume: pump.volume.toFixed(2), amount: (pump.volume * pump.price).toFixed(2), time: new Date().toLocaleTimeString(), }; setHistory(prev [record, ...prev]); // 新的放最前面 };然后渲染FlatList data{history} keyExtractor{(item) item.id} renderItem{({ item }) ( View style{styles.recordRow} Text style{styles.recordPumpId}{item.pumpId}/Text Text style{styles.recordVolume}{item.volume} L/Text Text style{styles.recordAmount}¥ {item.amount}/Text Text style{styles.recordTime}{item.time}/Text /View )} /这里有个keyExtractor的坑要特意提醒一下千万不要用数组的index作为 key。因为当你的列表是“倒序插入”新的加在最前面时如果用 index 作为 keyReact 会把第一个元素当成以前的第一个元素来复用导致 UI 显示错乱比如本来应该是新记录结果显示了旧记录的值。所以每一条记录都需要一个唯一的 id我在这里用时间戳来生成。5.2 支付弹窗RN 的 Modal 组件用法支付环节我用一个Modal来模拟弹出框。const [modalVisible, setModalVisible] useState(false); Modal animationTypeslide transparent{true} visible{modalVisible} onRequestClose{() setModalVisible(false)} View style{styles.modalOverlay} View style{styles.modalContent} Text style{styles.modalTitle}确认支付 ¥{amount.toFixed(2)}/Text View Text油枪编号: {activePump.id}/Text Text加油量: {activePump.volume.toFixed(2)} L/Text /View View style{styles.modalButtonRow} Pressable style{styles.cancelButton} onPress{() setModalVisible(false)} Text取消/Text /Pressable Pressable style{styles.confirmButton} onPress{() { // 调用完成加油的方法 completePump({ ...activePump, volume: activePump.volume, price: activePump.price, }); // 重置状态 setPumps(prev prev.map(p ({ ...p, isActive: false, volume: 0 }))); setActivePumpId(null); setModalVisible(false); Alert.alert(支付成功, 欢迎下次光临); }} Text style{styles.confirmButtonText}确认支付/Text /Pressable /View /View /View /Modal这个弹窗组件的应用让我在鸿蒙上踩到一个和 Android 不一样的点鸿蒙的 Modal 在 ‘transparent’ 属性上的表现。在 Android 上半透明遮罩层能直接盖在内容上面在鸿蒙模拟器上如果背景色没有显式设置半透明有时会表现为白色全屏。解决方式是给 Modal 内部的根 View 加上一个backgroundColor: rgba(0,0,0,0.5)的背景色确保遮罩层生效。5.3 Alert 的跨端差异上面代码我用了Alert.alert。这个 API 在 Android 和 iOS 上都通用但在 HarmonyOS 上目前至少在我验证的版本上的弹窗样式和平常规的 Alert 样式不太一样。所以如果追求完美 UI建议在鸿蒙上尽量少用系统级 Alert可以用 Modal 代替或者把提示做成一个Snackbar样式的自定义组件就是底部弹出的小条几秒后自动消失。这不是不能用只是视觉质感可能和你预期的不太一致。6. 跑通之后的鸿蒙适配细节与避坑心得到这里一个完整的“模拟加油站”就已经可以运行了。但跨平台开发的乐趣和痛苦在于“适配”。我把在鸿蒙模拟器上跑通的体验以及当时的适配笔记整理出来。6.1 启动白屏问题的完整排查链路启动白屏也就是热词里的“react native 启动白屏”是入门者最高频的问题。我把自己排查过程的思路拉一遍区分是编译白屏还是运行白屏如果 DevEco 直接安装 APK 后打开 App 白屏而 Metro 终端有请求日志通常是包加载慢或加载失败的问题。检查 Metro 日志看是否出现Unable to load script。如果出现说明模拟器无法访问电脑的 8081 端口。在鸿蒙模拟器上我遇到的情况是需要用adb reverse类似的命令鸿蒙用hdc reverse将模拟器端口映射到电脑端口。hdc reverse tcp:8081 tcp:8081如果是网络隔离问题确保模拟器和电脑处于同一局域网。DevEco 自带的模拟器一般天然桥接不用特殊配置但真机调试时手机必须和电脑连同一个 Wi-Fi并且在 JS 侧的访问地址要改成你电脑的局域网 IP不是在项目代码里写死而是通过打包时的 Bundle URL 指定。依赖包检查如果白屏时日志停在某个模块的require上比如console.error大概率是某些三方库没装完整。常见的是react-native-screens或react-native-safe-area-context在鸿蒙上还没完全适配良好如果项目里没有显式用到可以考虑移除或降级。6.2 热更新在鸿蒙模拟器上的 “失灵” 现象我在 DevEco 模拟器上按R键重载经常碰到“界面刷新了但状态全乱了”的情况。当时我很费解以为是代码问题。后来意识到这是 Metro 的热更新机制在鸿蒙的 ArkTS 运行时上表现不一致所致。经验结论在鸿蒙端调试不要过度依赖热更新。改完 JS 代码后如果发现 UI 异常不要犹豫直接停掉 App 进程重新启动或在 Metro 终端按R之后等 5 秒再操作。热更新成功的话代码逻辑应立即生效但如果出现状态错乱比如之前记录的油枪历史突然消失了多半是热更新了部分模块内存里的 State 没被正确代理直接冷启动一下最稳妥。6.3 原生端ArkTS与 JS 端的数据交互边界当你日后要真正用于业务一定会遇到 JS 主动调用原生方法的需求比如“扫一扫”或“读取设备号”。在鸿蒙里React Native 框架采用了一个名为RNInstance的机制来管理原生模块。这就牵扯到一个基础概念——TurboModule。你用import { NativeModules } from react-native拿到的模块在鸿蒙上需要经过 ArkTS 侧的装饰器注册。也就是说你在 JS 里调用的NativeModules.YourModule.doSomething()实际上是桥接到 ArkTS 侧的YourModule类去执行。给入门者的实操建议在初始 RN 鸿蒙项目里先不要动这个部分。你只需要把“模拟加油站”项目跑通慢慢感受UI 层JS与逻辑层JS State的交互即可。等你熟练理解了 JS 侧的状态流再去碰原生侧桥接就不会一头雾水了。6.4 性能优化的初步认知为什么加了动画会卡在模拟器上做了几轮动画后比如油枪状态的旋转 loading 效果多少能感觉到在鸿蒙模拟器上的性能比真机差。这不完全是 RN 的锅模拟器本身就消耗性能。如果你在项目中遇到了“交互卡顿”第一步排查是否在渲染层做了过重的计算。例如你每次setPumps时都会对整个数组执行map对于 4 个油枪来说无所谓但如果应用到几百个元器件上则需要考虑用React.memo对子组件进行缓存。const PumpCard React.memo(({ pumpId, price, isActive, onPress }) { // 只有传入的 props 变化时才重新渲染 ... });这个小改动虽然简单但能明显优化复杂界面的流畅度。我习惯把它作为设计组件时的默认选项而不是性能出现问题时再去补救的“补丁”。7. 从模拟到真用这个 Demo 还能扩展到 3 个方向三分钟热度把 Demo 跑通是容易的难的是想清楚下一步怎么走。这个“加油站”模板其实还能往三个方向延伸顺便拓宽你对跨平台开发的认知。7.1 联动真实硬件蓝牙控枪如果你手上刚好有一块蓝牙模块或者智能继电器完全可以模拟“物理油枪”。把“开始加油”这个动作和蓝牙指令对接按下按钮通过蓝牙给硬件发命令打开阀门JS 侧再用定时器模拟流量计数。这就成了一个“软硬结合”的跨平台项目逼着你去学习 Bluetooth 桥接和串口通信这才是跨平台开发最有价值的地方——一肩挑起“硬件-系统-应用”三方逻辑。7.2 对接真实支付 SDK 的钱包虽然我们用 Modal 模拟了支付弹窗但在真实业务中支付通常是跳转到一个独立的 RN 页面或者调起系统原生支付组件微信支付/支付宝等。这时候你就需要去了解鸿蒙端的三方支付 SDP 接入流程。这背后牵涉到“签约”、“加密”、“回跳”等一整套业务逻辑而且这些逻辑很多是用 ArkTS 原生写的你需要把支付结果通过事件传回 JS 层。这是一个有价值的进阶方向。7.3 地图定位与“附近加油站”功能最后你可以引入地图模块结合地图的 NAPI 获取当前定位把加油站的地理位置渲染到地图上。这项能力如果要在多种系统上复用通常会用定制的原生MapView组件嵌入到 RN 的视图层级里。这就会让你理解到在跨平台开发中“原生核心视图组件”和“纯计算逻辑模块”是两种完全不同的集成难点。8. 写在最后一个实用建议关于“报错”的心态最后闲聊一下“报错”。很多人入门跨平台开发心态特别好地“害怕报错”一看到红色堆栈就下意识地想要去网上搜“React Native 鸿蒙 报错怎么解决”。其实你仔细观察报错信息会发现大部分错误都可以归结为三类第一类类型错误。比如undefined is not an object这种通常是你访问了未定义对象的属性。这时检查一下pump数组是不是为空或者在给Modal传数据时activePump是不是undefined。第二类依赖缺失。直接看代码里那个Module not found的模块名依次npm install安装即可。第三类生命周期或状态错乱。比如界面数据跳变先检查setInterval有没有被正确清理。这类错误最隐蔽但调试的本质上是“理清楚状态更新的路径”。“模拟加油站”这个项目往前 5 步是跨平台入门往后 10 步是真正的商业项目、智能硬件和系统级开发。我在实际操作中的感受是鸿蒙的适配固然有一些小脾气但 RN 的 JS 逻辑和 React 心智模型是一点都不偏科的。把这一套基础打扎实你换到任何平台剩下来要做的不过是“对号入座”罢了。
返回列表