拓冰建站拓冰建站
首页 / 资讯中心 / 正文

跨平台开发突破边界:Android/iOS原生模块双端实战指南

做跨平台开发时间长了你迟早会撞上一面墙JS/TS 层写不出系统级的能力。不管你是用 React Native、Flutter 还是 uni-app凡是涉及 Android/iOS 系统专属接口——读电池电量、识别 NFC 标签、接入硬件扫码枪、调用系统分享面板——纯业务层基本碰不到。这时候Native Modules原生模块就是打破边界的那把锤子。这篇文章我会用一套完整的双端实战代码带你从零写一个原生模块从 Android Kotlin 到 iOS Swift从桥接原理到调试避坑把常见报错和性能红线一并说清楚。适合已经能跑通 Hello World但没正经写过原生模块的跨端开发者。1. 为什么需要原生模块必须打破的四种边界1.1 跨平台框架封不住的场景很多人误以为跨平台框架“什么都能干”真到了生产环境你会发现自己太乐观。我整理了一下这几年实际遇到过的场景凡是必须上原生模块的基本跑不出这四类第一类是系统状态读取。比如业务需要展示电池电量、当前网络类型Wi-Fi 还是蜂窝、设备型号、内存占用。这些数据都在系统底层JS 层没有入口只能通过原生代码拿。第二类是硬件外设交互。蓝牙 BLE 扫码枪、身份证读卡器、POS 收银机、NFC 标签、甚至是接了个串口设备。硬件厂商给的 SDK 大多是 Java/Kotlin 或 Objective-C/Swift 的跨平台框架不可能帮你封装好必须你手动包一层原生模块。第三类是系统级 UI 能力。Android 的 App Shortcuts、iOS 的 Share Sheet、两个平台各有各的桌面小组件这类系统 UI 组件跨平台框架覆盖得很浅尤其是 iOS 的某些系统弹窗只能原生弹。第四类是性能敏感的算法逻辑。比如大图压缩、音视频处理、加解密运算这些在 JS 层跑又慢又容易卡 UI放到原生层做性能差距是肉眼可见的。我做过一个视频拼接需求JS 层实现要 8 秒原生写 1 秒不到就出来了。1.2 三种主流框架的原生模块写法既然确定要打破边界先看看你手头框架的“原生扩展口”长什么样。现在主流的三套跨平台方案底子完全不同框架原生扩展机制语言现状通信方式React NativeNative ModulesTurboModuleJava/Kotlin ObjC/SwiftBridge 消息传递FlutterPlatform ChannelKotlin SwiftBinaryMessenger 二进制消息uni-app原生插件App 端Java/Kotlin ObjC/Swift事件桥接 异步回调光看通信方式就能理解React Native 是“消息桥”思路JS 把方法名和参数打包扔给原生原生执行完再扔回来Flutter 是“通道”思路一条双向管道直接传二进制数据uni-app 则在框架层自己封了一层事件系统。这里我不打算拉踩框架三个我都用过的结论是只要基础概念通了换框架只是换 API 壳子。这篇文章以 React Native 为主演示原因是它的“Native Modules”概念在社区里沉淀最久、资料最多、报错也最经典。看完这套你切换到 Flutter 或 uni-app 时理解成本会低很多。1.3 该不该自研第三方库和自研的选择碰到需要原生能力的需求第一反应是去 npm 搜现成库这没错但要注意两个坑一个坑是维护断档。很多原生库作者更新到一半就不维护了RN 从 0.59 升到 0.63配套的定位库还是用的老 APIAndroid 端在其他机型上闪退你只能 fork 源码自己改。另一个坑是定制困难。你需要的不是“读取电量”这么简单而是“低电量时弹一个自定义样式的提示框”第三方库给不了这个灵活性自己写一个 20 行的原生模块搞定为什么要被别人的 API 限制我的建议很务实底层系统能力优先自己封装原生模块业务功能优先找现成库。这样既保证硬件层的稳健性和可定制性又不重复造轮子。2. 原生模块的桥接原理消息怎么穿过边界2.1 桥接层的核心数据结构在 React Native 里JS 和原生之间没有共享内存双方靠一条异步消息通道通信。这条通道上流通的数据必须是可序列化的这就是为什么你没法把一个 JavaScript 函数直接传给原生也没法把一个原生对象原封不动抛给 JS。桥接消息的基本格式可以理解成一个类似 JSON 的结构{ type: method_call, module: SystemInfoModule, method: getBatteryLevel, args: { avatarBase64: ... }, callbackId: 1024 }原生端收到这条消息后根据 module 和 method 找到对应的方法执行结果再打包成一条类似结构扔回来JS 端通过 callbackId 识别这是哪一次调用的返回。这个机制看起来简单但它决定了几个硬性约束参数必须是简单的 JSON 支持的类型字符串、数字、布尔、数组、对象返回也必须是可序列化的。如果你想把一个 Bitmap 或 C 指针传回 JS对不起必须先转换成 Base64 字符串或二进制数据。2.2 同步与异步别在桥上调 UIRN 支持同步常量和异步方法两种通信模式同步常量constantsToExport是在模块初始化时就打入 JS 端的适合放那种不变或很少变的信息比如 SDK 版本号、设备初始状态。但注意同步方法sync method在新架构里是非常受限的它会阻塞 JS 主线程能不用就不用。异步方法是主力。我在 Android 端用 Promise 返回结果在 iOS 端用 resolve/reject 闭包返回。这两者的本质都一样JS 端调用原生方法后不用傻等原生算完了会把结果推回来JS 端用 then/catch 接住。为什么要设计成异步还是那句话桥是异步消息通道你不可能让 JS thread 卡在那边等原生处理完。再一个原生方法里经常要跑耗时逻辑读文件、扫描蓝牙、网络请求异步天然适合这些场景。提示在原生方法里千万不要直接操作 UI。Android 端如果从模块工作线程更新页面会直接抛 CalledFromWrongThreadExceptioniOS 端虽然不崩但界面会莫名其妙不刷新。正确做法是把 UI 操作切到主线程Android 用reactContext.runOnUiQueueThreadiOS 用DispatchQueue.main.async。2.3 线程模型谁在跑你的原生代码这个我必须单独拎出来说十个人写原生模块八个人在这里栽过跟头。Android 端ReactMethod注解的方法默认执行在 React Native 的 Native Modules 线程池一个后台线程不是主线程。所以你在里面做耗时操作没问题但如果你在里面调了 Toast 或者在方法里用了 SharedPreferences没问题如果你碰了 View或者依赖了主线程才能用的对象就会出问题。iOS 端模块方法默认在自定义串行队列上执行也不在主线程。和 Android 一个道理耗时逻辑可以放这里但 UI 刷新必须丢回主线程。我常用的分工方式是这样的原生模块内部自行维护耗时任务比如网络请求、数据库读写放后台队列跑只把结果以及需要展示数据的时机通过回调抛给 JS 层由 RN 决定如何渲染如果原生代码要弹原生的 UI比如 Android Toast、iOS 原生弹窗必须切主线程。3. Android 端实战Kotlin 手写系统信息模块3.1 环境准备先把 Android Studio 基础工程跑通Java/Kotlin 这边第一步就是把 Android SDK 装好、Android Studio 装好。这里我多说一句那些装到一半发现“SDK 组件选不了、装不了”的情况通常就是两个原因一个是 Android Studio 没给当前项目配置好 SDK 路径另一个是网络问题导致 SDK Manager 拉不下来组件。Android Studio 新版里SDK 路径可以在 Settings - Languages Frameworks - Android SDK 里配置。如果发现 SDK Manager 里某些 API Level 的组件灰色不可勾选先检查你装的是不是 Open JDK 版本过老或者把项目用的 compileSdkVersion 调低一档试试。环境就绪后用 Android Studio 打开 RN 项目根目录下的 android 文件夹等 Gradle 同步完成这一步耗时看网速耐心点多等几分钟。同步完成后在 app/src/main/java 包路径下新建一个NativeModulesPackage.kt和SystemInfoModule.kt这就是我们要写的核心文件。3.2 手写 SystemInfoModule读电池、网络与设备型号我先展示一个完整的 Kotlin 原生模块功能有三个当前设备型号、电池剩余电量、当前网络类型。别嫌这几个功能简单它们涵盖了原生模块最典型的使用姿势——返回 String、返回 Double、回调带参数。package com.example.nativemodules import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.BatteryManager import android.os.Build import com.facebook.react.bridge.* class SystemInfoModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val appContext: Context reactContext.applicationContext override fun getName(): String SystemInfoModule // 同步导出模块加载时自动注入 JS 端 override fun getConstants(): MapString, Any { return mapOf( initialModel to Build.MODEL, moduleVersion to 1.0.0 ) } // 电池电量通过 Promise 异步返回 ReactMethod fun getBatteryLevel(promise: Promise) { try { val batteryManager appContext.getSystemService(Context.BATTERY_SERVICE) as BatteryManager val level if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } else { val intent appContext.registerReceiver( null, IntentFilter(Intent.ACTION_BATTERY_CHANGED) ) val levelRaw intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 val scale intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 if (levelRaw 0 scale 0) levelRaw * 100 / scale else -1 } if (level 0) promise.resolve(level) else promise.reject(NO_BATTERY, 无法获取电量) } catch (e: Exception) { promise.reject(BATTERY_ERROR, e.message, e) } } // 网络类型返回字符串 ReactMethod fun getNetworkType(promise: Promise) { try { val cm appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val networkType if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { cm.getNetworkCapabilities(cm.activeNetwork)?.let { caps - when { caps.hasTransport(NetworkCapabilities.NET_CAPABILITY_WIFI) - wifi caps.hasTransport(NetworkCapabilities.NET_CAPABILITY_CELLULAR) - cellular else - unknown } } ?: none } else { Suppress(DEPRECATION) when (cm.activeNetworkInfo?.type) { ConnectivityManager.TYPE_WIFI - wifi ConnectivityManager.TYPE_MOBILE - cellular else - unknown } } promise.resolve(networkType) } catch (e: Exception) { promise.reject(NETWORK_ERROR, e.message, e) } } }几个细节说一下getName()返回的字符串就是 JS 端NativeModules.SystemInfoModule的命名依据必须和构造函数里的类名对应上。getConstants()对应 React Native 旧版里的constantsToExport在 Kotlin 代码里我们直接覆写这个函数它返回的 map 会被打进 JS 端作为模块的静态属性。getBatteryLevel里我做了 API Level 的判断Lollipop21以上的设备用 BatteryManager 的属性读取老设备回退到检查ACTION_BATTERY_CHANGED广播。生产环境里总有跑了多年的老机型兼容性处理必须写不要只看你自己测试机的表现。3.3 在应用入口注册 Package模块类写好了还不够你得让 RN 运行时知道这个模块存在。新建一个NativeModulesPackage.ktpackage com.example.nativemodules import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager class NativeModulesPackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): ListNativeModule { return listOf(SystemInfoModule(reactContext)) } override fun createViewManagers(reactContext: ReactApplicationContext): ListViewManager*, * { return emptyList() } }然后打开 MainApplication.kt在getPackages()里加上自定义 Packageoverride fun getPackages(): ListReactPackage { val packages PackageList(this).packages packages.add(NativeModulesPackage()) return packages }如果你的项目是 2023 年之后升级到 RN 0.73 的新架构注册 TurboModule 的方式略有不同但旧架构的写法在bridgeless模式下通常也兼容。稳妥起见README 里把两种模式怎么切写清楚方便团队协作时少踩坑。注意改完 MainApplication.kt 之后一定要完全停止 Metro 和 App 进程重新react-native run-android。原生代码不像 JS 有 Hot Reload改完必须重新编译这是新手最常问的“为什么我改了没反应”。3.4 JS 端验证调用模块编译通过、App 正常启动后在 RN 业务代码里就能直接调了import { NativeModules } from react-native; const { SystemInfoModule } NativeModules; async function fetchSystemInfo() { try { console.log(静态常量:, SystemInfoModule.initialModel); const battery await SystemInfoModule.getBatteryLevel(); const network await SystemInfoModule.getNetworkType(); console.log(电量: ${battery}%, 网络: ${network}); } catch (e) { console.error(e); } }如果打印出来的结果符合预期说明你的第一个 Android 原生模块已经跑通了。如果NativeModules.SystemInfoModule是 undefined九成是 Package 没注册成功或者 Metro 缓存太旧执行npx react-native start --reset-cache再看。我习惯在首次联调时故意在原生方法里抛一个异常验证 reject 分支是否生效别只看成功路径。4. iOS 端实战Swift 复刻同一个模块4.1 iOS 原生模块的入口与导出iOS 这边和 Android 最大的区别在于没有“自动注册”机制。你写好一个 NSObject 子类需要在编译时用宏让 RN 能认出它来。Swift 项目里通常还要借助一个-Bridging-Header.h桥接头文件引入 React 的头文件。如果你用的是 Xcode 直接管理项目打开AppDelegate.mmReact Native 0.7x 已经把模块注册的入口整合好了。你只需要新建一个 Swift 文件继承NSObject让它暴露给 Objective-C 运行时即可。iOS 上还有个现实问题新装的 Xcode 工程第一次跑真机总有人卡在“开发者模式没开启”上。iOS 16 之后真机调试必须先在系统设置里打开开发者模式路径是 设置 - 隐私与安全性 - 底部开发者模式开启后手机会重启一次。这个不加说明第一次跑 RN 的 iOS 工程十有八九要懵半天。4.2 Swift 实现系统信息模块下面用 Swift 写一个功能和 Android 端完全对齐的模块import Foundation import React import UIKit objc(SystemInfoModule) class SystemInfoModule: NSObject { // 同步导出常量在 module 初始化时注入 JS 端 override static func requiresMainQueueSetup() - Bool { return false } objc func constantsToExport() - [AnyHashable: Any] { return [ initialModel: UIDevice.current.model, moduleVersion: 1.0.0 ] } // 电池电量异步 Promise 返回 objc func getBatteryLevel(_ resolve: escaping RCTPromiseResolveBlock, reject: escaping RCTPromiseRejectBlock) { UIDevice.current.isBatteryMonitoringEnabled true let level UIDevice.current.batteryLevel if level 0 { resolve(Int(level * 100)) } else { reject(NO_BATTERY, 模拟器或当前设备无法读取电量, nil) } } // 网络类型简单通过 status bar / Network framework 获取 objc func getNetworkType(_ resolve: escaping RCTPromiseResolveBlock, reject: escaping RCTPromiseRejectBlock) { // 生产环境这里建议用 NWPathMonitorDemo 从简 resolve(unknown) } }Swift 写法里有几个容易踩的细节objc注解必须加RN 的桥接层是 Objective-C 运行时的世界没有这个注解方法就进不了 runtime 派发表。RCTPromiseResolveBlock和RCTPromiseRejectBlock要用escaping标注因为 Promise 的回调往往在异步操作结束后才会触发闭包的生命周期比函数调用栈更长。requiresMainQueueSetup返回 false 的意思是这个模块不需要在主线程上初始化。如果你的模块在 init 里就要访问 UIAppearance、注册通知中心这些依赖主线程的东西就必须返回 true但要注意这会增加启动开销能 false 就 false。注意从 iOS 14 开始读取 WiFi SSID 等敏感信息需要额外权限如果只是要用网络类型做业务判断建议用前面 Android 的做法返回 wifi/cellular/unknown 三个值不要试图去拿具体 SSID省掉 Info.plist 里一堆烦人的权限描述。iOS 端的导出如果发现 JS 里NativeModules.SystemInfoModule是 undefined先检查Swift 文件有没有被添加进 Xcode target桥接头文件是否引用了 React类名前的objc名是否与 JS 调用名完全一致。4.3 iOS 权限与配置Info.plist 和 ATSiOS 原生模块如果涉及网络请求、相册、相机、蓝牙都要在 Info.plist 里声明用途描述否则一调用就闪退而闪退日志只给你一句“This app has crashed because it attempted to access privacy-sensitive data without a usage description”很折磨人。一个比较隐蔽的问题是ATSApp Transport Security。如果后台接口是 HTTP 非 HTTPS在开发和公司内网环境里经常被 ATS 拦死。这时候不是让你把 ATS 全关App Store 审查会有风险而是在 Info.plist 里按需添加例外keyNSAppTransportSecurity/key dict keyNSAllowsArbitraryLoads/key false/ keyNSExceptionDomains/key dict keyyour-internal-domain.com/key dict keyNSExceptionAllowsInsecureHTTPLoads/key true/ /dict /dict /dict这种按域名的例外毕业设计或内部工具完全够用同时保住了 App Store 审核的基本体面。跑 iOS 模块的另一个前置条件是真机调试。模拟器上 UIDevice.batteryLevel 常年返回 -1我的演示代码里已经做了容错处理如果你在自己的模块里读取了传感器、相机等模拟器没有的硬件一定要在 JS 层加判断提示避免用户拿模拟器玩半天以为产品出 bug 了。5. 双端调试与问题排查实录5.1 Android 端常见崩溃与解决第一类Method not found。这类报错通常是ReactMethod注解漏写或方法名拼错。注意ReactMethod修饰的方法必须是 public返回值必须是 void结果通过 Promise 或 Callback 回传如果你写了suspend或者返回一个非空值会在编译期或运行期直接报错。第二类This method is not supported。多见于调用了模板中不存在的 API比如在 getName 之外的普通方法里去拿 Activity。记住ReactContextBaseJavaModule 持有的是 ReactApplicationContext它没有 Activity 引用如果需要 Activity 相关操作要继承ActivityEventListener或者走CurrentActivity获取但后者可能为 null必须判空。第三类Gradle 构建内存不足。这个非常常见特别是项目里同时跑多个 module 时。我在android/gradle.properties里加过这些参数实测能明显减少构建崩溃org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m android.useAndroidXtrue android.enableJetifiertrue5.2 iOS 端常见异常与调试iOS 这边比较容易翻车的是link 问题。如果你用 CocoaPods 管理依赖新增一个 Swift 原生模块后需要在 ios 目录执行pod install否则 Xcode 根本不知道你的新文件参与编译。很多新手改了原生代码发现没生效都是因为忘了重新 pod install。第二个坑是RCTBridgeModule 名字冲突。如果你或者同事之前已经定义过同名的模块注册时会产生 warning运行时行为也不可预期。建议在模块名上加上项目前缀比如ZZSystemInfoModule团队协作时极大降低冲突概率。第三个坑是内存泄漏。Swift 闭包捕获 self 时如果 self 是模块实例而模块又被 JS 引用着循环引用就很隐蔽。我在 iOS 侧的做法是所有耗时回调都用 weak selfDispatchQueue.global().async { [weak self] in guard let self self else { return } // ... }5.3 调试工具推荐调试原生模块我一般用三种工具打配合Charles抓包看 JS - 原生 - 后端的完整链路尤其在排查原生模块网络请求是否带上了正确 header 时非常好用。iPhone 上装好证书后在 Wi-Fi 设置里配置 HTTP 代理就可以看到所有走系统的网络请求。Android Studio / Xcode 自带调试器直接打断点、看日志。原生模块的问题你不进原生 IDE 永远只能靠猜。Android 端用 Logcat 过滤 ReactNativeJS 和 System.out 两个 TagiOS 端用 Xcode 的控制台直接搜模块名。Flutter/RN 的 dev menu可以查 JS 层 console 日志和原生层日志适合快速确认是 JS 的问题还是原生的问题。有一次我花了一个下午排查 Android 端某个模块在部分机器上返回值总是少一截最后是在 Android Studio 的 Profiler 里看到是主线程被某个耗时任务阻塞了异步回调被延迟跟模块逻辑本身毫无关系。所以遇到古怪的“偶发 bug”先怀疑线程问题再怀疑代码逻辑这个经验帮我省了无数时间。最后说点我不太会出现在文档里的体会我写原生模块三年最大的感受是难点从来不是语法而是思维方式的切换。JS 开发者习惯了一切都是异步、一切都有垃圾回收、一切异常都能 try/catch而原生世界里你需要自己管理线程、自己关注内存、自己处理系统权限。第一次写你会觉得很麻烦但当你真正理解桥接层怎么工作之后你会在设计模块 API 时潜意识里就开始考虑数据类型、线程安全、错误传递这种思维方式反过来会让你在纯 JS 层的架构设计上也受益。另外一个非常实用的建议别把原生模块做成一个函数堆积的 God 类。按业务域拆分成独立的模块类SystemInfoModule、BleModule、PaymentModule每个类只负责一个领域注册 Package 时按需加载。这样你后续要升级 SDK、修问题、加权限只需要动一个模块文件不会扯出一堆连带问题。跨平台开发的边界不会消失但会不断移动。今天你学会了打破 Android/iOS 原生模块这堵墙明天你面对的不再是“能不能做”而是“怎么做更优雅”。以后有机会我再写写原生 UI 组件Native UI Components的实现——那又是另一种风格的边界突破。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门