平时正常开发里,最烦听到的一句话就是:这个功能在 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_info:
class 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 13(API 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:(NSObject<FlutterPluginRegistrar> *)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 Future<String?> getDeviceModel() async { return await _channel.invokeMethod<String>('getDeviceModel'); } static Future<String?> getSystemVersion() async { return await _channel.invokeMethod<String>('getSystemVersion'); } static Future<String?> getScreenSize() async { return await _channel.invokeMethod<String>('getScreenSize'); } static Future<void> 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 类型 |
|---|---|---|
| null | null | NSNull |
| bool | Boolean | NSNumber |
| int | Integer / Long | NSNumber |
| double | Double | NSNumber |
| String | String | NSString |
| Uint8List | byte[] | FlutterStandardTypedData |
| List | List | NSArray |
| Map | Map | NSDictionary |
这里面最容易错的是数字类型。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 的 Logcat,iOS 用 Xcode 的 Console。在原生方法里加入日志输出,是排查问题最快的路径。
Kotlin 里一行 Log.d 解决:
Log.d("DeviceInfoPlugin", "onMethodCall: ${call.method}, args: ${call.arguments}")Swift 里用 print 或者 os_log:
print("onMethodCall: \(call.method)")每次调试前,我都会先在原生侧入口打一行日志,确认通道和调用确实进到原生了。如果这行日志都没有,问题多半在通道名、注册逻辑或引擎生命周期上;如果日志进来了但没有返回,多半是异步队列里 result 没被调到。
还有一点你可以试试:用 Flutter DevTools 连接真机时,Dart 侧有一条MethodChannel相关的 timeline 事件,能看到方法的调用耗时、参数大小,对排查大数据传输很有帮助。
5. 常见问题速查表:直接对号入座
我把这几年写原生模块遇到的高频问题整理成一张表,你可以先收藏,遇到问题直接对照查找。
| 现象 | 排查方向 | 解决建议 |
|---|---|---|
| Dart 调用直接报 MissingPluginException | 通道名不一致 / 未注册 / 引擎不对 | 检查通道名是否完全一致(含大小写),确认注册代码在正确的 FlutterEngine 上执行 |
| 原生收到调用但结果没返回 | 异步回调里没调 result / 线程不对 | 确认 result 在正确的时机和线程调用,不要在子线程里直接调 result |
| Android 上 trim Memory 崩溃 | 原生侧持有了 Flutter 的 result 对象 | 不要在异步回调栈里保存 result,如果要做耗时操作,注意生命周期管理 |
| iOS 真机调用崩溃无日志 | 缺少 Info.plist 权限描述 | 检查是否调用了受隐私保护的系统能力,补上对应的 usage description |
| Dart 收到数据后类型报错 | 原生返回的数据格式与 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 --template=plugin --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 很像,只是原生侧多了一个 EventSink,Dart 侧需要通过 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(比如地图、支付)、二进制数据传输的性能优化,以及模块上线后的监控和日志体系。如果你在实操过程中遇到具体问题,欢迎在评论区把你的报错日志和通道代码贴出来,我们一起理一理思路。