做React Native混合开发,Android原生和React Native相互调用和传参是最绕不开的主线。我最早在混合应用上折腾时,原生侧负责扫码、蓝牙打印和定位,RN侧负责业务页面。两端不仅要互相触发对方的方法,还得把结果来回携带,一开始以为只是在JS里import {NativeModules}就够了,真正写起来才发现,方法注册、类型映射、线程切换、生命周期,任何一环出问题,调用就莫名其妙失效。这篇文章把我实际验证过的双向通信用法、参数传递边界和踩坑过程完整记录下来,适合正在做RN混合开发、需要让Android原生和RN页面联调的人。
先说一个结论性问题:在Android平台上,原生代码和React Native之间的通信不是直接函数调用,而是通过桥接层做消息传递。不要试图在两端传递对象引用,能传的只有可序列化数据。理解这一点,后面遇到的很多“怪问题”都能解释清楚。
1. 先理清“四个方向”:不只是互相调用,还得考虑数据从哪边出发
1.1 为什么需要双向通信,而不是单向调用
有些刚接触RN混合开发的同学会以为,调用关系只存在于RN侧调用原生侧。比如点击RN页面按钮去跳转原生Activity,或者用原生Toast提示。但在真实业务里,原生侧主动找RN也很常见。典型场景有两个:
- 原生扫码页面拿到了二维码内容,需要把结果送回当前RN页面;
- RN页面请求原生打开蓝牙,蓝牙连接成功、断开、扫描到设备这些状态,原生需要持续通知RN。
所以通信方向其实是四个:RN调用原生、原生调用RN、RN传参给原生、原生传参给RN。前两个是“方法的触发方向”,后两个是“数据的流向”。虽然两两一组,但实现机制并不一样。RN侧调用原生,靠的是自定义原生模块;原生侧调用RN,靠的是事件发射或者直接执行JS模块方法。数据传递的方向决定了你该选择哪种API。
1.2 通信背后的“翻译层”:为什么不能直接传对象
RN的JavaScript运行在JS引擎里,Android原生侧是ART虚拟机,两套运行时、两套内存空间,对象没法直接跨边界共享。这就像一个团队里两个小组各说各的语言,中间必须有个翻译把内容转成双方都能理解的文本。在RN框架里这个翻译就是Bridge。
经过Bridge的数据会被序列化成JSON兼容结构,所以在Java端你收到的只能是ReadableMap、ReadableArray,而不是自定义JavaBean;JS端拿到的永远是普通对象和数组。如果你试图传一个自定义对象、文件流、Binder对象,抱歉,这条路走不通。即便在React Native新架构下,底层的JSI互操作能力变强了,但上层给开发者的调用方式仍然保留这套模型。先建立这个前提,后面分析类型映射和报错就好理解了。
1.3 本文讨论的范围界定
下面的代码示例基于 React Native 0.72/0.73 版本,Android侧以Java为主。Kotlin写法逻辑一样,只是语法稍有不同。旧架构和新架构在API层基本兼容,遇到差异我会单独点出来。需要说明的是,这些方法不是“唯一真理”,而是我在实际工程里反复验证过、稳定可靠的写法。
2. 原生侧主动发起调用:事件发射和直接执行JS的几种写法
2.1 用DeviceEventEmitter做全局事件广播
要从Android原生发送数据给RN侧,最常见的做法是通过ReactContext拿到DeviceEventManagerModule.RCTDeviceEventEmitter,然后调用emit发射事件。看代码:
// 获取ReactContext,一般在Activity/Fragment/自定义原生模块中 ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); if (reactContext != null) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit("onNativeEvent", Arguments.createMap()); }这里emit的第二个参数可以是String、Double、Boolean,也可以是ReadableMap或ReadableArray。实际项目里我更推荐用Map,因为可以携带多个字段,扩展性更好。构造Map用Arguments.createMap()返回一个WritableMap,往里putString("key", value)就能把数据带过去。
JS侧监听同样简单:
import { DeviceEventEmitter } from 'react-native'; useEffect(() => { const subscription = DeviceEventEmitter.addListener('onNativeEvent', (data) => { console.log('native event data:', data); }); return () => subscription.remove(); }, []);为什么是“事件”而不是直接调用JS函数?原因很简单:原生侧拿不到JS函数的引用,只能通过事件名做全局广播,让订阅了这个名字的JS回调处理。事件名不要随便写,尽量带上业务前缀,比如"ScanModule:onScanResult",避免和第三方库重名后互相覆盖。
2.2 用NativeEventEmitter做实例化封装,避免全局污染
如果你不想让所有页面都收到同一个全局事件,可以在原生模块上包装一个NativeEventEmitter。RN侧需要原生模块配合注册监听方法。
在自定义原生模块里加这两个方法:
@ReactMethod public void addListener(String eventName) { // 原生侧可以在这里统计订阅者数量 } @ReactMethod public void removeListeners(Integer count) { // 原生侧可以感知取消订阅 }JS侧使用:
import { NativeEventEmitter, NativeModules } from 'react-native'; const scannerEmitter = new NativeEventEmitter(NativeModules.ScanModule); const subscription = scannerEmitter.addListener('onScanResult', handleScan); // 组件卸载时 subscription.remove();这个方案比直接使用DeviceEventEmitter更可控,事件只在当前emitter实例的监听集合里分发,不会广播给所有页面。我在做原生蓝牙模块时特别偏好这种方式,因为连接状态事件只应该由当前操作页响应,全局广播很容易让其他页面也弹进度提示。注意:如果原生模块没有实现addListener和removeListeners,RN侧注册监听时通常会有警告,所以这两个空方法也别忘了。
2.3 什么样的场景不适合用事件发射
事件好用,但别滥用。对于“一次性初始数据”,最好通过根视图的initialProperties传参,而不是事件。原因是事件时机不可控:RN页面还没加载完成时,原生已经把事件发出去了,这个事件就彻底丢了。initialProperties会在RN根组件创建时作为props传入,数据不会丢。
另外,高频事件要谨慎。如果原生侧每隔几十毫秒发射一次位置坐标、蓝牙信号强度,桥接层会有序列化开销,RN页面很容易卡顿。后面的章节会专门讲怎么处理这种高频数据。
3. RN侧发起调用:原生模块怎么导出,结果怎么带回来
3.1 自定义原生模块的基本骨架
RN要调用Android原生方法,需要先创建一个继承ReactContextBaseJavaModule的类,重写getName,然后用@ReactMethod标注需要暴露给JS的方法。
public class ScanModule extends ReactContextBaseJavaModule { public ScanModule(ReactApplicationContext reactContext) { super(reactContext); } @Override public String getName() { return "ScanModule"; } @ReactMethod public void scan(String type, Promise promise) { // 这里处理扫码逻辑,最后通过promise回传结果 } }有一个很重要的细节:@ReactMethod标注的方法返回值必须是void,不能像普通Java方法一样直接返回String或对象。因为桥接调用是异步的,JS调用之后立刻拿到的是Promise或者Callback,真正的结果会在稍后通过异步回调带回来。
注册模块也不容忽略。你要实现一个ReactPackage,把模块加入createNativeModules列表:
public class AppPackage implements ReactPackage { @Override public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) { List<NativeModule> modules = new ArrayList<>(); modules.add(new ScanModule(reactContext)); return modules; } @Override public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) { return Collections.emptyList(); } }然后把这个package加到MainApplication的getPackages()里。很多人改了模块却发现JS调不到,多半是package没加或者重新build没有生效。
3.2 用Callback拿一次性结果
早期RN版本里,原生模块向JS回传结果通常用Callback。你可以在方法参数里声明Callback,在结果准备好后调用invoke。
@ReactMethod public void getBatteryLevel(String deviceId, Callback success, Callback failure) { try { int level = getBatteryLevelInternal(deviceId); WritableMap result = Arguments.createMap(); result.putInt("level", level); success.invoke(result); } catch (Exception e) { failure.invoke(e.getMessage()); } }JS侧调用:
import { NativeModules } from 'react-native'; NativeModules.BatteryModule.getBatteryLevel( 'dev01', (res) => { console.log('level:', res.level); }, (err) => { console.warn(err); } );Callback在桥接层本质是JS函数的引用。有一个坑:同一个Callback不要调用两次。如果原生方法在多次回调、异步重试时反复invoke同一个Callback,第一次成功之后,后面的调用可能不再触发,甚至产生警告。所以方法内部要加状态保护,确保结果只回一次。
3.3 用Promise,让异步代码更符合现代JS习惯
Promise并不是什么新机制,它只是桥接层把成功、失败两个Callback封装了一层。但使用体验好很多,尤其是配合async/await写业务时,代码清晰得多。
@ReactMethod public void generateCode(String prefix, Promise promise) { try { String code = prefix + (int) (Math.random() * 9000 + 1000); WritableMap result = Arguments.createMap(); result.putString("code", code); promise.resolve(result); } catch (Exception e) { promise.reject("CODE_ERROR", e.getMessage(), e); } }JS侧:
const result = await NativeModules.CodeModule.generateCode('RN-'); setCode(result.code);Promise的reject要带错误码、错误消息和异常对象,这样RN侧能拿到结构化错误,排查问题会快很多。新写的模块方法我统一用Promise,只有极少数需要多次回调的场景才用Callback或事件。
3.4 方法名、包名、大小写:RN和原生对齐的三个关键点
JS里调用NativeModules.ScanModule.scan('qr', promise)时,ScanModule必须和原生getName()返回值一致,scan必须和@ReactMethod方法名一致,大小写敏感。如果方法声明了3个参数,JS必须传3个参数,不能多也不能少。参数数量不匹配时,不会像普通函数那样自动忽略,而是直接调用失败。
另外,原生模块中getReactApplicationContext()拿到的Context和当前Activity的Context不一样。需要宿主Activity时可以调用getCurrentActivity(),但这个方法可能返回null,使用前一定要判空。
4. 传参最容易被坑的三件事:类型、线程、生命周期
4.1 Java和JS数据类型映射对照表
原生模块方法的参数类型不能随便写,只能是桥接层支持的那几个类型。我整理了一张对照表:
| JS侧类型 | Java侧接收类型 | 说明 |
|---|---|---|
| string | String | 最常用的参数 |
| number | Double | 推荐用Double,因为JS Number本质是浮点数 |
| boolean | Boolean | 包装类型,避免自动拆箱空指针 |
| object | ReadableMap | 可读Map,不能直接修改 |
| array | ReadableArray | 可读数组 |
| function | Callback / Promise | 由原生侧声明类型 |
| null | 包装类型、Map、Array均可 | 某些情况下会解析为空 |
很多人喜欢在Java方法里直接写int接收JS的整数,实测中偶尔能跑,但遇到JS侧传来的是小数或者超过Integer范围时,转换就会出问题。最稳的写法是用Double接收,再手动转成int:
@ReactMethod public void setStepCount(Double count) { int step = count.intValue(); }从原生返回给JS时,推荐构造WritableMap、WritableArray或直接返回基础类型。自定义Java对象必须先转成Map,因为桥接层不认识你的对象。
4.2 原生方法跑在哪个线程:不要默认它是Android主线程
RN调用原生模块后,@ReactMethod方法执行在哪个线程?很多人默认“Java方法就在主线程跑”,这是非常危险的误解。实测中,它往往执行在桥接层的工作线程,而不是Android主线程。你如果在里面直接操作View、弹Dialog、更新UI,可能不是每次都闪退,但一定会在某些机型上出现异常。
正确的做法是切回主线程再更新UI:
@ReactMethod public void showFromNative(String message) { android.os.Handler handler = new android.os.Handler(Looper.getMainLooper()); handler.post(() -> { Toast.makeText(getReactApplicationContext(), message, Toast.LENGTH_SHORT).show(); }); }反过来,如果原生方法在后台线程执行耗时操作,比如网络请求、蓝牙扫描,完成后直接在后台线程调用Promise.resolve或Callback.invoke是可以的,桥接层会把结果自动投递到JS线程。这一点比传统的Android Handler跨线程要方便得多。
4.3 ReactContext为空,以及模块持有Activity的泄漏问题
在原生侧主动往JS发事件时,经常有人写:
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();问题是,RN可能还没有初始化完成,getCurrentReactContext()返回null,于是emit就空指针了。这个坑特别常见。怎么处理?在业务代码里通过ReactContextLifecycleEventListener监听ReactContext初始化完成:
reactInstanceManager.addReactContextLifecycleEventListener(new ReactContextLifecycleEventListener() { @Override public void onReactContextInitialized(ReactContext context) { // 此时可以安全emit事件 } @Override public void onHostDestroy() { // 清理自定义模块的持有资源 } });另一个常踩的坑是内存泄漏。ReactContextBaseJavaModule构造时传入的是ReactApplicationContext,它是全局单例,不会导致Activity泄漏。但如果你在模块里直接getCurrentActivity()拿Activity引用,存进成员变量,页面关闭后这个Activity就不会被回收。我见过一个项目因为长期持有Activity引用,反复进出扫码页后内存疯涨。解决方式:不要长期保存getCurrentActivity()的结果,谁需要谁临时获取。
5. 一个完整Demo:原生页面嵌入RN,验证码在两端来回传递
5.1 场景设定
下面用我实际验证过的例子,把双向通信串起来。场景是:原生Activity加载一个RN根视图,RN页面里展示验证码,点击刷新按钮时由RN调用原生生成验证码;同时原生页面里还有一个原生按钮,点击后由原生侧主动推一个新验证码给RN。这刚好覆盖RN到原生、原生到RN两个方向的调用和传参。
5.2 在原生Activity里加载RN根视图并传初始参数
public class MixedActivity extends AppCompatActivity { private ReactRootView reactRootView; private ReactInstanceManager reactInstanceManager; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); reactInstanceManager = ((MyApplication) getApplication()).getReactInstanceManager(); reactRootView = new ReactRootView(this); Bundle initialProperties = new Bundle(); initialProperties.putString("fromNative", "initial-params"); // 根视图必须和JS注册的AppRegistry.registerComponent名字一致 reactRootView.startReactApplication(reactInstanceManager, "MixedApp", initialProperties); setContentView(reactRootView); } @Override protected void onResume() { super.onResume(); reactInstanceManager.onHostResume(this, this); } @Override protected void onPause() { super.onPause(); reactInstanceManager.onHostPause(this); } @Override protected void onDestroy() { super.onDestroy(); reactRootView.unmountReactApplication(); reactInstanceManager.onHostDestroy(this); reactRootView = null; } }JS侧注册根组件:
import { AppRegistry } from 'react-native'; function App(props) { const [fromNative, setFromNative] = useState(props.fromNative); return <Text>来自原生初始参数:{fromNative}</Text>; } AppRegistry.registerComponent('MixedApp', () => App);initialProperties只在首次加载时生效。如果RN页面已经运行,原生想修改这些初始参数,只能走事件通信,所以不要把“更新初始参数”的希望寄托在重新startReactApplication上,那个方法不能对同一个ReactRootView重复调用。
5.3 RN调用原生生成验证码
原生模块:
@ReactMethod public void generateCode(String prefix, Promise promise) { try { String code = prefix + (int) (Math.random() * 9000 + 1000); WritableMap result = Arguments.createMap(); result.putString("code", code); result.putString("source", "native"); promise.resolve(result); } catch (Exception e) { promise.reject("GENERATE_ERROR", e.getMessage(), e); } }RN页面里调用:
const refreshFromRN = async () => { try { const result = await NativeModules.CodeModule.generateCode('RN-'); setCode(result.code); } catch (e) { console.warn(e); } };这里要特别注意generateCode方法名和模块名CodeModule的大小写。一旦写错,JS会提示NativeModule is null或者方法找不到。
5.4 原生主动把最新验证码推给RN
原生页面的按钮点击后,通过事件方式推送新验证码到RN:
public void onNativeButtonClicked() { ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); if (reactContext == null) { return; } WritableMap params = Arguments.createMap(); params.putString("code", "NATIVE-" + (int) (Math.random() * 9000 + 1000)); reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit("onCodeChanged", params); }RN侧订阅:
useEffect(() => { const subscription = DeviceEventEmitter.addListener('onCodeChanged', (data) => { setCode(data.code); }); return () => subscription.remove(); }, []);这一步就是对第2章内容的实际应用。注意getCurrentReactContext()返回空时要直接return,不要继续emit,否则会空指针。
5.5 跑通Demo时的几个运行细节
这个Demo看起来简单,实际上有几个细节很影响成败:
ReactInstanceManager必须是全局单例,不要在每次进入页面时重新构造。- Activity的
onResume、onPause、onDestroy要正确回调给ReactInstanceManager,否则RN页面的生命周期会异常,可能出现定时器不停止、ReactRootView无法销毁。 - 修改原生模块后,需要完整重新build APK,而不是只reload JS。RN的Metro可以热更新JS,但原生代码改动必须走Android构建。
我把这个Demo跑了很多遍,验证了两件事:Promise能稳定把Map数据从原生带回RN;事件发射能在RN页面运行期间把新数据推送给指定监听者。这个结构可以直接套到扫码、蓝牙、位置刷新等真实场景里。
6. 真机调试里那些“莫名其妙”的问题:踩坑复盘
6.1 模块方法找不到,先查名字、注册和构建
最常见的问题是JS侧报NativeModule is null或者方法undefined。遇到这种问题,按以下顺序排查:
getName()返回的名字是否和NativeModules.XXX中的XXX一致,注意大小写。@ReactMethod方法是否public void,方法名是否和JS调用拼写一致。- 自定义package是否被加到了
MainApplication.getPackages()列表里。 - 原生代码是否重新build安装到了手机上,而不是只reload JS。
我见过有人把getName()写成了ScanModule.class.getSimpleName(),结果方法名正常但模块名带上了奇怪前缀,JS怎么都找不到。所以模块名尽量硬编码成普通字符串,避免动态推导。
6.2 回调不触发,通常是异常被吞或调用了两次
Promise不resolve、Callback不执行,最典型的原因是异常被吞掉。比如原生方法里用了try-finally但没有catch,异常抛出后桥接层静默失败,JS侧等到天荒地老也等不到结果。
所以原生方法里要写完整的错误分支:
try { // 业务逻辑 promise.resolve(result); } catch (Exception e) { promise.reject("MODULE_ERROR", e.getMessage(), e); }另一种情况是Callback先触发了成功,后面代码里又触发了失败或再次成功。桥接层对同一个Callback不会无限容忍,第二次调用可能直接不生效。我在编写耗时任务时,会用一个AtomicBoolean标记结果是否已经返回:
AtomicBoolean replied = new AtomicBoolean(false); void onTaskFinish(boolean success, String data, Callback callback) { if (replied.compareAndSet(false, true)) { if (success) { callback.invoke(data); } else { callback.invoke("task failed"); } } }6.3 高频事件会导致掉帧,得做聚合和降频
桥接层不是免费的。每发射一个事件,数据都要经过序列化、跨线程传递、JS侧解析。如果原生每秒发送几十个事件,比如蓝牙扫描或位置更新,RN页面很容易掉帧,低端机器会更明显。
我踩过最狠的一次是蓝牙模块每秒回调20多次设备列表增量,页面直接卡到无法滑动。后来改成原生侧先聚合数据,每200毫秒批量发送一次,只发增量变化,不做全量广播,问题立刻消失了。所以凡是高频数据,请记住三个词:聚合、降频、增量。
6.4 订阅事件忘记删除,页面关闭后回调还在执行
DeviceEventEmitter.addListener如果只加不减,页面关闭后监听器依然存在。当原生再次发射事件,已经销毁的页面回调仍然会被执行,轻则打印一堆无意义日志,重则因为操作已销毁的View而崩溃。
正确写法是在useEffect的清理函数里调用subscription.remove(),或者在componentWillUnmount里移除。我给团队定了一个规矩:所有事件订阅必须配对出现,有addListener就一定在同一处写remove。
6.5 新架构下的行为差异,别指望无缝切换
React Native新架构里的TurboModule采用JSI,参数类型校验更严格,方法调用不再完全依赖旧桥的序列化。大多数上层API写法保持一致,但自定义模块的参数类型、错误码可能会暴露更多细节。老项目升级时,我建议先把所有自定义原生模块的调用和回调查一遍,确认能通再动业务代码。否则原生方法在新架构下解析失败,JS侧拿到的是一个完全陌生的异常信息,排查成本会比旧架构高很多。
最后分享一个小技巧:每次改完原生方法,除了正确构建之外,我会先跑一遍最小复现Demo,只测一个简单参数的回传,确认没问题再接入复杂业务。很多人问为什么调用失败,我第一反应就是先检查模块名和方法名,第二反应是检查参数类型,第三反应才是看线程。这三点覆盖了九成以上的通信问题。能把类型映射表放进项目README里,团队其他人改原生模块时也能少走很多弯路。