《Flutter 平台通道:与原生代码握手》
本文做什么,不做什么
Flutter 引擎之外的事——电池电量、蓝牙、推送、原生存储——都要穿过一层桥才能用,这层桥就是平台通道。本文用”取电池电量 + 订阅充电状态流”两个场景,把 MethodChannel 和 EventChannel 的 Android(Kotlin)、iOS(Swift)双端实现各写一遍,顺带把参数序列化的边界说清。边界先说死:dart:ffi 的同步 C 绑定不展开;Pigeon 代码生成只点名不展开;封装成可发布的插件不做;生产里拿电量请直接用 battery_plus 这类现成插件——本文手写是为了讲清它底下发生了什么。版本口径:Flutter 3.22(2024-05-14 GA,配套 Dart 3.4)。
什么时候必须写平台代码
先过一道判断,别上来就写桥:
- pub.dev 有维护良好的插件(battery_plus、path_provider 这类)——直接用,手写是给自己找维护负担;
- 系统能力没有现成插件,或公司内部只有 iOS/Android 两份原生 SDK——必须自己写;
- 要嵌入原生视图(地图、广告)——那是 PlatformView 的事,另一套机制,本文不碰。
通道的本质一句话:Dart 与原生之间没有共享内存,一切消息经过序列化、走 binary messenger 异步投递。所以通道调用全是 Future、全是异步,且参数必须是”两边编解码器都认识”的类型——后面有一节专门数这些类型。
MethodChannel:一次请求,一次响应
场景定为拿电池电量。Dart 侧声明通道并发起调用:
// lib/battery.dart
import 'package:flutter/services.dart';
class Battery {
static const _channel = MethodChannel('com.xrush.battery/level');
/// 电量百分比;原生侧报错会以 PlatformException 抛出
Future<int> level() async {
final v = await _channel.invokeMethod<int>('getLevel');
if (v == null) {
throw StateError('通道返回为空');
}
return v;
}
}
Android 侧(Kotlin)在 MainActivity 里接住:
// android/app/src/main/kotlin/.../MainActivity.kt
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.xrush.battery/level"
).setMethodCallHandler { call, result ->
when (call.method) {
"getLevel" -> {
val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
if (level != -1) result.success(level)
else result.error("UNAVAILABLE", "电量不可用", null)
}
else -> result.notImplemented()
}
}
}
}
iOS 侧(Swift)挂在 AppDelegate 上:
// ios/Runner/AppDelegate.swift
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: "com.xrush.battery/level",
binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { (call, result) in
switch call.method {
case "getLevel":
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel // 0~100 的 Int,取不到是 -1
if level != -1 { result(level) }
else {
result(FlutterError(code: "UNAVAILABLE",
message: "电量不可用", details: nil))
}
default:
result(FlutterMethodNotImplemented)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
三条铁律,违反任何一条都是线上事故:通道名双端逐字一致(大小写、斜杠都是名字的一部分);方法名双端一致,未注册的方法走 notImplemented 分支;每个分支都必须回话——success、error、notImplemented 三选一,漏了 Dart 侧的 Future 就永远挂着。还有个新手必撞的现场:通道名写错或 handler 没注册,Dart 侧抛的不是 PlatformException 而是 MissingPluginException——第一排查项永远是”名字对没对、handler 注册了没”。
组织方式也有讲究:一个通道承载多个方法(Kotlin 的 when、Swift 的 switch 分发),别一个方法开一个通道——通道名是路由单位,method 才是语义单位。注册时机放在 configureFlutterEngine 里正合适:引擎每次创建都会调它,热重启、换页面后 handler 依然在位,Dart 侧不用关心原生注册了没有。
序列化边界:什么能过桥
默认的 StandardMessageCodec 认这些类型,清单背下来:
- null、bool、int、double、String;
- 数值与二进制数组:Uint8List、Int32List、Int64List、Float32List、Float64List;
- List 与 Map——但嵌套里面的元素也必须是清单内的类型。
自定义对象过不了桥:要么手动转 Map({'id': 42, 'name': 'x'}),要么走 JSON codec,要么用 Pigeon 让代码生成替你写转换。过桥是双向的:Dart 发的 Map 到 Kotlin 是 HashMap、到 Swift 是 Dictionary,原生侧取参数就是普通集合操作——取不到就 error 回话,别假设参数永远齐整。Dart 侧的类型系统管不到桥对岸,参数校验在原生侧再防一遍是必要的。大整数留个心眼:移动端 Dart 的 int 是 64 位,但编译到 Web 后底层是 double,超过 2^53 就失真——跨端 ID 这类字段走 String 是省心的选择。错误也有约定:原生侧 result.error("UNAVAILABLE", msg, null),Dart 侧收到的就是 PlatformException(code: UNAVAILABLE, message: msg)——code 用大写常量双端对齐,别散落魔法字符串。
EventChannel:平台往 Dart 推流
电量是问一次答一次,充电状态则是”变了就通知”——这是 EventChannel 的舞台:Dart 侧拿到一个 Stream,原生侧在监听开始与结束时注册和注销。
static const _events = EventChannel('com.xrush.battery/charging');
Stream<bool> charging() =>
_events.receiveBroadcastStream().map((v) => v as bool);
Android 侧用 StreamHandler,onListen 注册广播接收器、onCancel 注销:
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.xrush.battery/charging"
).setStreamHandler(object : EventChannel.StreamHandler {
private var receiver: BroadcastReceiver? = null
override fun onListen(args: Any?, sink: EventChannel.EventSink) {
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
sink.success(status == BatteryManager.BATTERY_STATUS_CHARGING)
}
}
registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}
override fun onCancel(args: Any?) {
receiver?.let { unregisterReceiver(it) }
receiver = null
}
})
iOS 侧对称:FlutterEventChannel 配一个实现 FlutterStreamHandler 的对象,onListen 里经 NotificationCenter 监听电池状态变化,onCancel 里移除观察者:
final class ChargingHandler: NSObject, FlutterStreamHandler {
private var observer: NSObjectProtocol?
func onListen(withArguments arguments: Any?,
sink: @escaping FlutterEventSink) -> Void {
UIDevice.current.isBatteryMonitoringEnabled = true
observer = NotificationCenter.default.addObserver(
forName: UIDevice.batteryStateDidChangeNotification,
object: nil, queue: nil) { _ in
sink(UIDevice.current.batteryState == .charging)
}
}
func onCancel(withArguments arguments: Any?) -> Void {
if let o = observer {
NotificationCenter.default.removeObserver(o)
observer = nil
}
}
}
// AppDelegate 里注册:
let eventChannel = FlutterEventChannel(
name: "com.xrush.battery/charging",
binaryMessenger: controller.binaryMessenger)
eventChannel.setStreamHandler(ChargingHandler())
两个坑:onListen 与 onCancel 必须成对,注册了不注销就是接收器/观察者泄漏,Activity 都会被拖着;流可能中途报错——原生侧 sink.error(code, msg, details) 之后流终止,Dart 侧的 onError 收到 PlatformException,UI 层要么重订阅要么提示降级。另外 receiveBroadcastStream 返回的是广播流,多处 listen 会各自触发一次 onListen,平台侧就注册多份——流对象缓存起来复用,别在 build 里现造。
线程模型:handler 跑在哪个线程
默认约定:双端的 handler 都在平台主线程被调用,result/sink 的回话也必须回主线程。这意味着 handler 里不能干重活——读大文件、解码图片这类事要派发到后台线程,干完再回主线程回话。Android 用 Handler(Looper.getMainLooper()).post,iOS 用 DispatchQueue.main.async。漏掉这步的典型症状:UI 掉帧甚至 ANR,而 Dart 侧看起来什么都没做错——因为错在原生侧把主线程堵了。
顺带认识 BasicMessageChannel
通道家族还有第三件套:MethodChannel 是”请求-响应”,EventChannel 是”订阅-推送”,BasicMessageChannel 则是不定式的自由消息——任意一方随时 send,对面 handler 收:
static const _raw =
BasicMessageChannel<String>('com.xrush.battery/raw', StringMessageCodec());
final reply = await _raw.send('ping');
_raw.setMessageHandler((msg) async => 'pong');
什么时候用它:不需要方法名语义、也不需要流语义的杂项消息,比如双端心跳、简单状态同步。多数业务用前两个就够,知道它存在,遇到场景能想起来就行。
通道怎么测
《Flutter 测试入门:单元测试与 widget test》的心智直接接上:通道测试不需要真机,把消息拦下来即可——
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, (call) async => 87);
expect(await Battery().level(), 87);
mock 返回 87、null、抛 PlatformException 三种情况各测一条,Dart 侧的错误处理路径就全钉住了。双端真机的验证留给手动清单:改插拔一次充电线,看流有没有动静。
验收清单
- 真机上 Dart 侧拿到的电量数值与系统设置里显示的一致;
- iOS 模拟器 batteryLevel 恒为 -1,走 UNAVAILABLE 错误分支——这条用于验证错误路径,别当 bug 报;
- 故意把 Dart 侧通道名改一个字母,抛 MissingPluginException——熟悉第一排查项;
- 调用未注册的方法名,Dart 侧捕获 MissingPluginException 且应用不崩;
- 插拔充电线,充电状态流收到 true/false 翻转,拔掉监听后无泄漏报错;
- 通道单测在
flutter test下全绿,覆盖成功、null、异常三种 mock。
双端联调的一招土办法
通道出问题时,别盯着单端猜。在原生 handler 入口把 method 与参数打出来,Dart 侧在 invokeMethod 前后各打一行,然后把两边日志按时间线对齐——”消息发出去了没、对端收到没、回话回来没”三问立刻见分晓。双端日志一贴,80% 的通道问题(名字不一致、参数类型不认识、handler 没注册)在时间线断点处自己现形,剩下 20% 才轮到看序列化类型清单。
学完之后
手写通道是理解 Flutter 混合开发的地基,往上走三条路:Pigeon 用代码生成把双端签名对齐,省掉手写转换;FFI 走同步 C 绑定,适合算法密集场景;PlatformView 嵌原生视图。本文的位置在《Flutter 学习路线》的”平台互操作”一节——路线里”多数需求有插件兜底”的判断,本文给了它的成本依据。
本文数据核对时间:2024-08-02:Flutter 3.22(2024-05-14 GA,配套 Dart 3.4);MethodChannel/EventChannel/StandardMessageCodec 为 flutter 框架 services 库内置,API 以 api.flutter.dev 为准;Kotlin 侧 configureFlutterEngine 与 Swift 侧 FlutterAppDelegate 挂接方式为该版本模板口径;battery_plus 等现成插件为生产推荐,本文实现仅作教学;iOS 模拟器电量返回 -1 为平台公开行为。