Flutter/RN原生模块开发全攻略:从桥接原理到Android/iOS实战

发布时间:2026/9/15 5:52:45
Flutter/RN原生模块开发全攻略:从桥接原理到Android/iOS实战 平时正常开发里最烦听到的一句话就是这个功能在 App 里调用一下系统能力就行了。结果打开代码一看Dart 层和 JS 层压根没暴露这个接口。做跨平台项目越深入越能感受到框架帮你挡住的那层糖衣背后原生能力永远绕不开。这篇就聊透 Android/iOS 原生模块Native Modules到底怎么落地从原理到实操从坑到习惯一篇走完。如果你现在正用 Flutter、React Native 这类跨平台框架做 App碰到 DBL 层调不到的系统能力、第三方 SDK、硬件接口又不想因此整套用原生重写那这篇就是给你准备的。我用 Flutter 当例子讲但你只要理解了桥接思路换到 RN 或 UniApp 也一样用——它们只是通道名和方法签名不一样核心机制是同一套。1. 原生模块的本质你这APP的“后门通道”1.1 跨平台框架为什么绕不开原生层先想清楚一个问题Flutter 把 UI 画到自己引擎里React Native 把 JS 映射成原生组件看起来都不需要碰原生代码为什么还要有原生模块因为操作系统层级的 API大部分不住在 UI 框架层。比如读取设备电量、获取当前 Wi-Fi 名称、调用系统分享面板、注册指纹解锁、连接蓝牙外设这些能力只有原生的 Android SDK 和 iOS SDK 里有Dart 和 JS 的运行时被沙箱隔离在外面碰不到那些底层接口。有的同学问不是有现成插件吗是的Pub.dev 和 npm 上插件很多但实际项目里总有找不到合适插件的时候。可能插件维护断更了可能插件只支持 Android 没适配 iOS可能公司买了一个特定的硬件扫码枪SDK 只发 Java/C 版本那这时候你没法等别人出插件只能自己写。我自己的分界线是能用现成插件就不自己写但一旦决定写就把它当正式组件来维护而不是写完了扔进项目里不管。1.2 通道机制到底是怎么工作的原生模块的本质是两边语言之间搭一条消息通道。以 Flutter 为例它提供了 MethodChannel、EventChannel、BasicMessageChannel 三种通道分别解决“调用一次拿结果”、“持续监听事件流”、“双向收发消息”三类问题。其中 MethodChannel 最常用写法也最直观。来看一条消息从 Dart 到原生端的流动路径Dart 侧通过 MethodChannel.invokeMethod(getBatteryLevel) 发起调用平台通道会把这个方法名和参数编码成二进制消息经过 Flutter 引擎交给 Android/iOS 侧注册了相同通道名的原生对象。原生对象处理完逻辑后再把结果走同一路径回传 Dart 侧Dart 侧通过 Future 拿到结果。通道名是两边约定的字符串必须完全一致类似一个路由地址。方法名也是字符串你传 “getBatteryLevel” 到原生那边原生就执行对应的分支逻辑。整个模型非常简单但也正因为简单很多工程坑都出在“约定”上——通道名拼错、参数类型不匹配、返回值格式不对都会让你在调试时抓狂。1.3 什么时候该自己写原生模块我踩过几年坑后总结的决策逻辑很简单三层判断递进第一层先把现有插件翻一遍。Flutter 直接看 pub.dev 的官方插件社区插件的 stars、issue 回复速度、最近发布时间基本能看出能不能用。RN 就看 npm 的社区生态。第二层插件对平台的适配度。有些插件 Android 做得很好iOS 是一个空壳实现甚至直接 throw。这种情况你可以 fork 它的源码自己补上 iOS 那边的逻辑比从头写要快。第三层如果系统能力不复杂自己写二三十行原生代码就能搞定那干脆别等插件了。自己写原生模块可控性最高调试效率也不差。还有一个很多人忽略的评估点你的团队里有没有会原生开发的人。如果整个团队只会写 Dart/JS建议优先找插件否则维护成本会压到你怀疑人生。2. Android 端原生模块实战从零写一个设备信息模块2.1 先理清 Android 端工程结构写 Flutter 原生模块不需要单独建立一个 Android 工程。你的 Flutter 工程目录下的 android/ 文件夹本身就是完整的 Android 工程可以用 Android Studio 打开直接用 Gradle 构建调试。打开 android/app/src/main/java/com/你的包名/ 目录里面会有一个 MainActivity 或 MainActivity.kt。传统做法是注册插件时直接在 configureFlutterEngine 里去拿 MethodChannel但代码一多MainActivity 会膨胀得非常难看。我自己习惯把每个业务模块单独建一个类比如 DeviceInfoPlugin然后统一在 MainActivity 里注册这样以后插件多了好管理。Android 端的 Kotlin 代码结构大概是class DeviceInfoPlugin(private val context: Context) : MethodChannel.MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { getDeviceModel - result.success(getDeviceModel()) getSystemVersion - result.success(getSystemVersion()) getScreenSize - result.success(getScreenSize()) else - result.notImplemented() } } }2.2 在 MainActivity 中注册通道注册通道的代码写在 configureFlutterEngine 里注意 channel name 要和你 Dart 侧保持一致我用的是 com.example.device_infoclass MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, com.example.device_info ).setMethodCallHandler(DeviceInfoPlugin(this)) } }这里有个关键点这个通道的生命周期绑定在 FlutterEngine 上。如果你的 App 有多个 FlutterEngine每个引擎都要各自注册一遍通道。很多同学在使用混合栈方案时遇到“Dart 端调用原生没反应”十有八九就是注册在别的 engine 上。2.3 实现具体原生功能以一个典型案例来演示获取设备型号、系统版本、屏幕分辨率。Android 的 Build 类里直接有这些字段class DeviceInfoPlugin(private val context: Context) : MethodChannel.MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { getDeviceModel - { val model Build.MODEL result.success(model) } getSystemVersion - { val version Build.VERSION.RELEASE result.success(version) } getScreenSize - { val displayMetrics context.resources.displayMetrics val width displayMetrics.widthPixels val height displayMetrics.heightPixels result.success(${width}x$height) } else - result.notImplemented() } } }这个例子虽然简单但已经把原生模块最基本的范式展示清楚了接收方法名、匹配分支、通过 Result 返回数据。如果想演示更完整的“调起系统能力”的感觉可以加一个震动功能。Android 端的震动在 API 26 之后改了写法老代码 Vibrator.vibrate(long) 在上面会报错新写法是vibrate - { val vibrator context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { val effect VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE) vibrator.vibrate(effect) } else { Suppress(DEPRECATION) vibrator.vibrate(500) } result.success(true) }这里要注意 Android 13API 33之后普通的 VIBRATE 权限已经不够了需要在 AndroidManifest.xml 里加 并且运行时不需要向用户申请但清单里必须声明。我遇到过好多次同事写完代码真机上震动没反应排查半天才发现是清单里忘加了权限声明。2.4 小心 Android 的异步回调上面示例都是同步返回result.success 紧接着调用没问题。但实际业务里很多原生接口是回调式的比如定位回调、蓝牙扫描回调、读取文件结果回调。这种情况下你不能在 onMethodCall 里同步返回必须持有 result 对象等异步回调触发后再调 result.success。一个常见的错误是这样调用了某个 SDK 的异步方法然后在 onMethodCall 的末尾直接 result.success(null)结果 SDK 真正的回调回来后再调 result.success(data)这时 Flutter 侧已经报 MissingPluginException 或者“result already sent”异常。正确写法应该是把 result 保存在类的成员变量或方法局部捕获等异步回调里再返回getLocation - { locationManager.requestLocationUpdates(...) { location - result.success(${location.latitude},${location.longitude}) } // 这里不要调 result.success }这个坑在接入第三方 SDK比如高德、百度定位 SDK时极其常见我建议你在刚接触原生模块时就把这个习惯刻进 DNA所有“不能立即返回”的场景都先检查你的 result 到底是在主线程回调还是子线程回调因为 Flutter 的 MethodChannel 在 Android 上对线程有要求——默认需要在主线程调用 result。2.5 Android 端线程模型不搞清楚会踩大坑MethodChannel 的 onMethodCall 跑在平台主线程也就是 UI 线程。如果你在 onMethodCall 里执行了耗时操作比如访问网络、读取大文件直接把主线程卡住App 列表都会掉帧甚至弹 ANR。我见过一个真实案例有人在原生模块里写了一个循环去解析一个几百 MB 的日志文件解析完才返回结果。Dart 侧只看到页面卡了十几秒然后手机系统弹了“应用无响应”的提示非常尴尬。正确姿势是耗时操作丢到子线程做完后再回到主线程调 result。在 Kotlin 里可以用很朴素的线程池parseLargeFile - { Thread { val resultData doHeavyWork() // 回到主线程调用 result runOnUiThread { result.success(resultData) } }.start() }为什么还要回主线程因为 Flutter 引擎对 MethodChannel 的响应有要求官方文档里写的是“必须从平台线程调用 result”也就是 Android 的 UI 线程。不同版本的 Flutter 引擎对异步线程的校验严格度并不一样为了兼容性和稳定性我统一遵守“耗时操作去子线程返回结果回到主线程”的原则。3. iOS 端原生模块实战一样的思路不一样的姿势3.1 iOS 端工程结构和语言选择iOS 端的原生模块本质也是在 Flutter 的 iOS 工程里注册一个对象并处理 MethodChannel 传来的消息。和 Android 唯一的差别是iOS 没有 Gradle 自动管理依赖很多工程需要用到 CocoaPods 来集成 Flutter 模块不过你直接用 Xcode 打开 ios/Runner.xcworkspace 就可以不需要额外配 CocoaPods 环境。语言选择上新项目默认 Swift老项目很多还是 Objective-C。这里我不建议你因为“Swift 更现代”就强迫自己用 Swift——在 RN 和 Flutter 的老版本工程里OC 的兼容性始终更省心。如果你接手的是老工程直接用 OC 写别两头折腾。3.2 Swift 实现设备信息模块在 Xcode 里新建一个 Swift 文件命名为 DeviceInfoPlugin.swift实现 FlutterPlugin 协议import Flutter import UIKit public class DeviceInfoPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel FlutterMethodChannel( name: com.example.device_info, binaryMessenger: registrar.messenger() ) let instance DeviceInfoPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: escaping FlutterResult) { switch call.method { case getDeviceModel: result(UIDevice.current.model) case getSystemVersion: result(UIDevice.current.systemVersion) case getScreenSize: let screen UIScreen.main.bounds result(\(Int(screen.width))x\(Int(screen.height))) case vibrate: AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) result(true) default: result(FlutterMethodNotImplemented) } } }然后在 AppDelegate.swift 的 didFinishLaunchingWithOptions 里注册import Flutter import UIKit main objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) - Bool { GeneratedPluginRegistrant.register(with: self) // 注册自定义插件 DeviceInfoPlugin.register(with: registrar(forPlugin: DeviceInfoPlugin)!) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } }这里我遇到的一个实际问题是register 调用的时机必须确保 Flutter 引擎已经初始化完成。如果你在 AppDelegate 里过早调用 registrar(forPlugin:)拿到的可能是 nil后面就崩了。稳定性更好的注册方式是在 AppDelegate 的 application(_:didFinishLaunchingWithOptions:) 里晚几步注册或者在 FlutterViewController 创建之后注册。还有个细节iOS 模拟器上没有真实的震动硬件调用 AudioServicesPlaySystemSound 没反应很正常。你别以为代码写错了先在真机上验证。3.3 Objective-C 版本老工程照样能用如果你的 iOS 工程是 Objective-C 写的参照同一个 FlutterPlugin 协议改个语法就行#import Flutter/Flutter.h #import UIKit/UIKit.h interface DeviceInfoPlugin : NSObject FlutterPlugin end implementation DeviceInfoPlugin (void)registerWithRegistrar:(NSObjectFlutterPluginRegistrar *)registrar { FlutterMethodChannel *channel [FlutterMethodChannel methodChannelWithName:com.example.device_info binaryMessenger:[registrar messenger]]; DeviceInfoPlugin *instance [[DeviceInfoPlugin alloc] init]; [registrar addMethodCallDelegate:instance channel:channel]; } - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { if ([call.method isEqualToString:getDeviceModel]) { result([[UIDevice currentDevice] model]); } else if ([call.method isEqualToString:getSystemVersion]) { result([[UIDevice currentDevice] systemVersion]); } else { result(FlutterMethodNotImplemented); } } endOC 的写法在类型安全上没 Swift 舒服但老工程里更常用。我自己的建议是新模块用 Swift 写老模块如果是在 OC 工程里移植直接写 OC 反而省得混编。3.4 iOS 权限声明Info.plist 是个总闸门写 iOS 原生模块遇到最多的坑其实不是代码而是权限弹窗和系统权限声明。iOS 对用户隐私要求非常严格你在代码里调用相册、相机、定位、日历、通讯录这些系统能力之前必须先在 Info.plist 里配上对应的 usage description 字符串否则调用的时候 App 直接闪退甚至不会给你任何日志输出。我举几个常见的定位NSLocationWhenInUseUsageDescription相册NSPhotoLibraryUsageDescription相机NSCameraUsageDescription麦克风NSMicrophoneUsageDescription后面接的字符串是弹窗里展示给用户看的文案比如“为了打卡功能需要访问你的位置信息”。这个文案不是随便填的App Store 审核如果发现你申请权限但功能里用不到会被打回。所以我在工程里统一维护了一处权限说明文档每个权限对应什么功能都在文档里写清楚避免审核阶段来回扯皮。还有一个容易忽略的点iOS 14 之后访问“选中的照片”需要额外的 PHPicker 权限描述否则你调用相册选取照片时系统直接拒绝。这个坑特别隐蔽因为老代码在 iOS 14 以下跑得好好的一升级系统就崩。4. 三端联调把 Dart、Android、iOS 串起来4.1 Dart 侧调用代码怎么写原生模块写好了Dart 侧需要创建一个 MethodChannel 实例通道名必须和原生侧一致。我的习惯是在一个独立的 dart 文件里封装好所有通道调用方便被测和复用import package:flutter/services.dart; class DeviceInfoService { static const MethodChannel _channel MethodChannel(com.example.device_info); static FutureString? getDeviceModel() async { return await _channel.invokeMethodString(getDeviceModel); } static FutureString? getSystemVersion() async { return await _channel.invokeMethodString(getSystemVersion); } static FutureString? getScreenSize() async { return await _channel.invokeMethodString(getScreenSize); } static Futurevoid vibrate() async { await _channel.invokeMethod(vibrate); } }调用时其实就是一个异步方法String? model await DeviceInfoService.getDeviceModel(); print(model); // 比如 Pixel 7 Pro如果原生侧还没注册对应的通道invokeMethod 会抛出 MissingPluginException。这个异常很多新手不知道要处理直接会让 App 崩掉。我的建议是统一做一层 try-catch或者在上层封装一个返回 Result 类型的方法至少保证异常有提示不会让用户看到闪退。4.2 类型映射是原生模块最容易翻车的环节MethodChannel 传输数据时两边的数据格式会做一层类型映射。很多同学从 Dart 传一个 Map 到原生原生收到后以为是 String一顿操作直接类型转换异常。我先捋一下对应关系Dart 类型Android 类型iOS 类型nullnullNSNullboolBooleanNSNumberintInteger / LongNSNumberdoubleDoubleNSNumberStringStringNSStringUint8Listbyte[]FlutterStandardTypedDataListListNSArrayMapMapNSDictionary这里面最容易错的是数字类型。Dart 侧的 int 在 Android 上会被解析成 Integer 或 Long 取决于数值大小你如果强转成 Int 没问题但如果转成 Byte溢出就来了。在 iOS 上NSNumber 拿到后你要自己判断它是 Bool 还是数字因为它们在底层都是 NSNumber无法靠 isKindOfClass 直接区分。我的一个实操建议是自定义原生模块的接口参数时尽量都用 Map 传参字段名用 String 固定下来。这样原生侧解析时用 getString(key)、getInt(key) 这种写法类型由字段名约定好等于是人为做了接口约束减少类型映射带来的隐性 bug。4.3 调用时机和生命周期别在引擎没准备好时发起调用原生模块的注册依赖于 FlutterEngine。如果你在 App 启动最早期的 Dart 代码里就发起 invokeMethod而引擎还没完成注册就会收到 MissingPluginException。这个问题在混合开发里尤其常见App 启动后先跳一个原生页面原生页面里再创建 FlutterEngine 和 FlutterViewController如果 Dart 侧在 initState 里立即调用原生方法有可能会“抢跑”。我遇到过好几次后来学乖了如果原生模块调用必须在页面加载前完成我会在原生侧确保 engine 创建完成并注册完通道后才通过 methodChannel.invokeMethod 反向通知 Dart 侧“模块已就绪”。换句话说用“原生主动通知”替代“Dart 盲目调用”可靠很多。4.4 真机调试的常用手段原生模块的调试一般分两层第一层是 Dart 侧断点看 invokeMethod 的参数和返回值有没有问题。但原生代码里的问题 Dart 断点看不到。第二层是原生侧的调试工具Android 用 Android Studio 的 LogcatiOS 用 Xcode 的 Console。在原生方法里加入日志输出是排查问题最快的路径。Kotlin 里一行 Log.d 解决Log.d(DeviceInfoPlugin, onMethodCall: ${call.method}, args: ${call.arguments})Swift 里用 print 或者 os_logprint(onMethodCall: \(call.method))每次调试前我都会先在原生侧入口打一行日志确认通道和调用确实进到原生了。如果这行日志都没有问题多半在通道名、注册逻辑或引擎生命周期上如果日志进来了但没有返回多半是异步队列里 result 没被调到。还有一点你可以试试用 Flutter DevTools 连接真机时Dart 侧有一条MethodChannel相关的 timeline 事件能看到方法的调用耗时、参数大小对排查大数据传输很有帮助。5. 常见问题速查表直接对号入座我把这几年写原生模块遇到的高频问题整理成一张表你可以先收藏遇到问题直接对照查找。现象排查方向解决建议Dart 调用直接报 MissingPluginException通道名不一致 / 未注册 / 引擎不对检查通道名是否完全一致含大小写确认注册代码在正确的 FlutterEngine 上执行原生收到调用但结果没返回异步回调里没调 result / 线程不对确认 result 在正确的时机和线程调用不要在子线程里直接调 resultAndroid 上 trim Memory 崩溃原生侧持有了 Flutter 的 result 对象不要在异步回调栈里保存 result如果要做耗时操作注意生命周期管理iOS 真机调用崩溃无日志缺少 Info.plist 权限描述检查是否调用了受隐私保护的系统能力补上对应的 usage descriptionDart 收到数据后类型报错原生返回的数据格式与 Dart 预期不符对照类型映射表检查返回值类型用 Map 包装一层并固定字段类型原生侧拿到 null 但期望是对象过度桥接了 null / dart 侧传了空值参数校验放开头对 null 情况做兜底处理不要假设参数保证存在iOS 上注册的插件不生效插件注册流程被混编跳过检查 AppDelegate 里是否调用了 GeneratedPluginRegistrant.register并确认插件文件加入 Target这个表里的每一条我都在真实项目中踩到过。尤其是第一条 MissingPluginException看起来像是在告诉你“插件没装”其实超过半数情况是通道名拼写不一致。String 的比较底层且严苛一个空格都会导致不匹配。6. 把代码升级成正式插件从临时代码到可复用组件6.1 为什么要从平台通道升级为插件包很多项目刚开始时原生模块代码是直接塞在 MainActivity 或 AppDelegate 里的。项目小的时候没问题但在多模块、多业务线项目里代码一旦多起来MainActivity 会膨胀成一个几千行的“上帝类”改一个模块可能碰坏另一个模块。更让人头疼的是业务线之间如果要复用设备信息的能力你不能把整个 AppDelegate 扔给另一个团队。所以我一般把稳定下来的原生模块抽成一个独立插件包通过 Flutter 的 plugin 机制打包。Android 端做成 AAR 或 Maven 包iOS 端做成 podspec这样组件可以被多个 Flutter 工程引用团队之间平台代码互相隔离。6.2 创建插件项目的流程用 Flutter 命令可以快速创建一个插件骨架flutter create --templateplugin --org com.example device_info_plugin生成的工程结构里会有一个 pubspec.yaml、一个 android 目录、一个 ios 目录还有 example 目录用于本地调试。你的插件代码分别放在 android/src/main 和 ios/Classes 里。6.3 Android 模块的依赖发布细节如果插件需要依赖第三方 SDK比如接入一个定位 SDK你需要在插件工程的 android/build.gradle 里声明依赖。这里有个容易犯的错插件里使用了某个 AAR 依赖但调用方 Flutter 工程的 minSdkVersion 或 compileSdkVersion 不够编译直接失败。解决方案是插件 build.gradle 里不要写死具体的 compileSdkVersion尽量使用 Flutter 框架提供的变量android { compileSdkVersion flutter.compileSdkVersion }这个写法在 Flutter 版本升级时会自动跟随主工程的配置避免插件版本兼容性连环爆炸。反过来如果插件用了新 API 需要更高的 compileSdkVersion你也得显式抬升并在 README 里写清楚最低要求。6.4 iOS 插件的 Podspec 配置细节iOS 插件本质是一个 CocoaPods 的 pod。插件工程里的 ios/device_info_plugin.podspec 文件负责声明依赖和平台版本Pod::Spec.new do |s| s.name device_info_plugin s.version 0.1.0 s.summary A device info plugin. s.platform :ios, 11.0 s.source_files Classes/**/* s.dependency Flutter endplatform 版本要和你工程的实际部署版本匹配。如果你插件里用了 iOS 14 的 API但主工程 deployment target 是 11.0那运行时调用就会崩。我的建议是统一在插件 podspec 里标到所需最低版本然后在 README 里写明接入此插件的主工程部署目标不得低于 iOS 14.0。7. 高级主题EventChannel 和原生主动通知MethodChannel 解决的是“Dart 调原生、原生存回结果”但反过来——“原生主动往 Dart 发消息”的场景MethodChannel 干不了。它需要 EventChannel。EventChannel 最典型的用途是监听系统类事件流比如实时电池电量变化、传感器数据流、定位更新、下载进度回调。你不可能让 Dart 侧反复轮询原生效率低且代码丑陋。EventChannel 能让原生在事件发生时主动推送给 Dart。我看过一个非常典型的场景蓝牙设备连接状态变化。原生层扫码枪建立蓝牙连接、断开连接都要实时通知 Flutter 层刷新界面状态。如果你用 MethodChannel 做轮询延迟高还容易漏掉瞬时状态用 EventChannel 就是“事件一发生就推过去”干净利落。EventChannel 的实现思路和 MethodChannel 很像只是原生侧多了一个 EventSinkDart 侧需要通过 receiveBroadcastStream() 来订阅事件流。这里我不展开写完整代码了因为代码量比较大单独写一篇会更清晰。不过核心心法你只要记住EventChannel 适合“连续变化的事件流”MethodChannel 适合“一次调用一次返回”别搞反。另外一个类似 But 不等同于 EventChannel 的机制是原生反过来给 Dart 发消息的场景Flutter 也提供了 BasicMessageChannel走的是双向自由通信模式。如果对接比较底层的数据流例如蓝牙收发的二进制数据BasicMessageChannel 会更合适它可以直接传 ByteBuffer。8. 版本兼容性Android 碎片化与 iOS 系统差异写原生模块的人最大的噩梦不是不懂 API而是同一个 API 在不同系统版本上行为完全不同。Android 端的碎片化就不用我多说了。比如说文件存储访问Android 10 开始强制分区存储Android 11 又加了一堆包可见性限制Android 13 直接对通知权限下手。这些不是“知道一下就好”的事而是会直接影响你原生模块是否还能正常运行的硬性规则。我给自己定了一个规矩每一个用到的 Android API都要先确认它的最低 API level 和推荐替代写法。在代码里用 Build.VERSION.SDK_INT 判断版本做分支适配这比寄希望于“大部分用户都是新系统”要稳妥得多。iOS 那边虽然碎片化没 Android 严重但系统差异也不是没有。iOS 15 之前和之后部分 API 的过时标记和推荐替代不同iOS 14 之后隐私权限明细更严格。而且 iOS 平台的测试没法覆盖所有旧系统因为你没法像 Android 那样在所有模拟器版本上随便跑。我会在 README 里明确写明支持的 iOS 最低版本同时留一个 CI 的动态测试配置保证升级 Xcode 版本后不会因为编译选项差异导致问题。9. 项目收尾之后的小建议这篇文章我刻意没有让流程太复杂核心是想让你先掌握 MethodChannel 这个最基本的原生模块编写模型后面的 EventChannel、BasicMessageChannel 都建立在同等机制上一通百通。最后分享几点我实际坚持的经验第一个经验原生模块的代码注释一定要写清楚“为什么”。比如“这里判断 SDK 30 是因为 Android 11 改了包可见性策略”这种注释比“获取设备信息”这种废话注释有价值一万倍。原生模块逻辑往往很脆一行系统兼容分支背后可能是一个小时的排查记录不写下来后人包括三个月后的自己根本看不懂。第二个经验接入任何原生依赖都要写好 README。我之前维护过的一个插件因为文档里没有标明 Android minSdkVersion 要求导致三个接入方编译不过各自花了大半天来排查。后来我把系统版本要求、注意事项全部写在 README 顶部再没人问我同样的问题。第三个经验谨慎评估“原生模块到底要写多厚”。有的团队喜欢把大量业务逻辑下沉到原生层说这样性能好。我的看法是原生模块只做操作系统的能力桥接业务逻辑还是留在跨平台层这样才能保证你的业务代码在两端复用也方便后续移植到其他平台。这三个习惯看着不起眼但帮我省了特别多踩坑成本。10. 后续还能往哪个方向深挖这一篇讲的是原生模块的基础实践下一期我准备聊 EventChannel 的具体写法包括如何封装一个实时推送的蓝牙状态监听器再后面可以讲如何在原生模块里集成第三方 SDK比如地图、支付、二进制数据传输的性能优化以及模块上线后的监控和日志体系。如果你在实操过程中遇到具体问题欢迎在评论区把你的报错日志和通道代码贴出来我们一起理一理思路。