Flutter 基础体系 · 第 66/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 插件开发:多平台接口、Federated Plugin、测试和发布
Flutter 插件是一个同时包含 Dart 代码和平台代码的 Dart package。它的作用不是“把任意原生代码包起来”,而是定义一条稳定的数据与调用边界,使 Flutter 层能够调用 Android、iOS、Web、Windows、macOS 或 Linux 的平台能力。
一个完整插件通常包含四层职责:
- Dart 公共 API:应用开发者直接调用的类和方法。
- 平台接口:规定所有平台实现必须满足的抽象契约。
- 平台实现:Android、iOS、Web、桌面等平台上的实际代码。
- 平台通信机制:MethodChannel、EventChannel、BasicMessageChannel、Pigeon 或 Dart FFI。
这几层并非都必须拆成独立 package,但当插件支持多个平台或需要多人维护时,拆分通常更容易保持接口稳定。
一、先区分 package、plugin 和平台实现
Flutter 生态中常见的 Dart package 有两类:
- 纯 Dart package:只包含 Dart 代码,例如日期、集合或网络协议库。
- plugin package:除 Dart 代码外,还声明并包含一个或多个平台实现。
一个 plugin package 的 pubspec.yaml 通常包含 flutter.plugin 配置。例如,一个直接维护 Android 和 iOS 实现的插件可以类似这样声明:
name: battery_level
description: Read the current battery percentage.
version: 1.0.0
publish_to: none
environment:
sdk: ^3.0.0
flutter: ">=3.10.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
plugin:
platforms:
android:
package: com.example.battery_level
pluginClass: BatteryLevelPlugin
ios:
pluginClass: BatteryLevelPlugin
这里有三个不同层次的声明:
package是 Dart 包的名称;flutter.plugin.platforms.android表示该包有 Android 插件;pluginClass和package告诉 Flutter 工具如何找到 Android 原生入口。
插件不是通过 main() 启动的。Flutter 工具和引擎会根据插件元数据自动完成注册;Android、iOS 和桌面平台通常在引擎创建时注册插件,Web 则通过 Dart 的插件注册机制加载实现。
创建一个基础插件可以使用:
flutter create \
--template=plugin \
--platforms=android,ios,web \
--org=com.example \
battery_level
命令的前提是已经安装 Flutter SDK,并且 flutter doctor 没有阻止目标平台工作的关键错误。创建后应先执行:
cd battery_level
flutter pub get
flutter test
flutter analyze
这些命令分别验证依赖解析、Dart/Flutter 测试和静态分析。它们不能证明原生代码一定能运行,因为 Android、iOS 或桌面平台仍需要各自的构建工具链和运行环境。
二、先设计跨平台接口,而不是先设计 MethodChannel
插件最重要的设计对象不是通道名,而是跨平台能力模型。
假设我们要提供“读取当前电量”的能力。一个合理的跨平台接口可以是:
abstract interface class BatteryLevelApi {
Future<int> getBatteryPercentage();
}
这里的契约需要明确:
- 返回值范围是
0到100; - 这是异步操作;
- 如果平台不支持,抛出一个可识别的错误;
- 如果系统暂时无法提供数据,也不能把“未知”伪装成
0; - 是否支持持续监听,属于另一个接口,不应隐含在一次读取方法中。
如果某个平台没有电池,例如某些桌面设备或浏览器环境,那么它的实现应该返回“不支持”,而不是随意返回一个默认值:
class UnsupportedBatteryLevelApi implements BatteryLevelApi {
@override
Future<int> getBatteryPercentage() {
throw UnsupportedError(
'Battery percentage is not supported on this platform.',
);
}
}
0 是一个有效电量值,而“不支持”是另一个状态。把两者混合会导致上层 UI 错误地显示“电量为 0%”。
同步接口和异步接口不能随意替换
平台调用通常涉及消息传递、系统 API 或权限,因此应优先使用 Future<T>。即使 Android 某次调用可以立即取得结果,接口也不应因此声明为同步方法:
// 不推荐:未来无法适配异步平台 API
int getBatteryPercentage();
// 推荐:所有实现拥有统一的时序语义
Future<int> getBatteryPercentage();
接口的异步性是调用者可观察到的契约。它允许某个平台直接返回 Future.value(80),也允许另一个平台等待系统回调,而不改变公共 API。
三、直接实现插件:Dart API、MethodChannel 与原生代码
下面用一个最小的 battery_level 插件说明完整调用链:
Flutter Widget
│
▼
Dart 公共 API
│
▼
MethodChannel.invokeMethod("getBatteryPercentage")
│
▼
Flutter Engine 的平台消息通道
│
┌──┴───────────────┐
▼ ▼
Android Kotlin iOS Swift
│ │
▼ ▼
系统电池 API 系统电池 API
1. Dart 公共 API
lib/battery_level.dart:
import 'package:flutter/services.dart';
class BatteryLevel {
BatteryLevel({BatteryLevelPlatform? platform})
: _platform = platform ?? MethodChannelBatteryLevel();
final BatteryLevelPlatform _platform;
Future<int> getBatteryPercentage() {
return _platform.getBatteryPercentage();
}
}
abstract interface class BatteryLevelPlatform {
Future<int> getBatteryPercentage();
}
class MethodChannelBatteryLevel implements BatteryLevelPlatform {
static const MethodChannel _channel = MethodChannel(
'com.example.battery_level/battery',
);
@override
Future<int> getBatteryPercentage() async {
final value = await _channel.invokeMethod<int>(
'getBatteryPercentage',
);
if (value == null || value < 0 || value > 100) {
throw StateError('Platform returned an invalid battery percentage: $value');
}
return value;
}
}
这里的 MethodChannel 只传递方法名和参数,不负责定义业务语义。业务语义仍由 BatteryLevelPlatform 和 BatteryLevel 负责。
invokeMethod<int> 的类型参数只能帮助 Dart 侧表达预期类型,不能替代运行时校验。原生端返回字符串、空值或超出范围的整数时,Dart 侧仍然必须处理。
通道名称应具有稳定且足够唯一的命名空间,例如:
com.example.battery_level/battery
不要使用过于通用的名称:
battery
channel
getBattery
多个插件或应用组件可能共享同一个 Flutter Engine,通道名称冲突会使消息被错误处理。
2. Android 实现
Android 插件类使用 FlutterPlugin 生命周期接入引擎:
package com.example.battery_level
import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class BatteryLevelPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var applicationContext: Context
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
channel = MethodChannel(
binding.binaryMessenger,
"com.example.battery_level/battery"
)
channel.setMethodCallHandler(this)
}
override fun onMethodCall(
call: MethodCall,
result: MethodChannel.Result
) {
when (call.method) {
"getBatteryPercentage" -> {
val manager = applicationContext.getSystemService(
Context.BATTERY_SERVICE
) as BatteryManager
val percentage = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
)
if (percentage !in 0..100) {
result.error(
"UNAVAILABLE",
"Battery percentage is not available",
null
)
} else {
result.success(percentage)
}
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(
binding: FlutterPlugin.FlutterPluginBinding
) {
channel.setMethodCallHandler(null)
}
}
关键生命周期是:
onAttachedToEngine获得BinaryMessenger和应用上下文;- 创建与 Dart 侧名称完全相同的
MethodChannel; - 注册方法处理器;
- 在
onDetachedFromEngine中解除处理器,避免旧对象继续持有通道。
这里使用 applicationContext,因为读取电池不需要 Activity。插件如果保存 Activity 引用,就必须额外处理配置变化、Activity 重建和引擎与 Activity 分离,否则容易造成泄漏或调用失效。
getIntProperty 可能返回无法使用的值。插件将其转换为带错误码的 result.error,Dart 侧会收到 PlatformException。这比返回 -1 更容易让调用者区分“读取失败”和“有效数值”。
3. iOS 实现
iOS 可以通过 UIDevice 获取电量:
import Flutter
import UIKit
public class BatteryLevelPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.example.battery_level/battery",
binaryMessenger: registrar.messenger()
)
let instance = BatteryLevelPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "getBatteryPercentage":
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel
if level < 0 {
result(
FlutterError(
code: "UNAVAILABLE",
message: "Battery level is not available",
details: nil
)
)
} else {
result(Int(level * 100.0))
}
default:
result(FlutterMethodNotImplemented)
}
}
}
iOS 的 batteryLevel 是 0.0 到 1.0 的浮点数,-1.0 表示不可用。因此转换公式是:
percentage = floor(level × 100)
示例中使用 Int 转换,实际取整方式应在接口文档中固定。如果 Android 返回整数而 iOS 原始值是小数,跨平台接口必须规定取整规则,否则同一设备可能在不同平台显示不同结果。
4. 错误如何从原生端到 Dart 端
MethodChannel 的错误路径可以表示为:
原生 result.error(code, message, details)
│
▼
Dart await invokeMethod(...)
│
▼
PlatformException(code, message, details)
Dart 侧可以根据错误码进行处理:
Future<int?> readBatteryOrNull(BatteryLevel battery) async {
try {
return await battery.getBatteryPercentage();
} on PlatformException catch (error) {
if (error.code == 'UNAVAILABLE') {
return null;
}
rethrow;
}
}
不要在插件内部无条件捕获所有异常并返回 null。这样会隐藏权限错误、通道注册错误和原生 API 崩溃等问题。只有已经定义为“可接受的不可用状态”才适合转换成空值。
四、MethodChannel 能传什么,不能保证什么
MethodChannel 使用 Flutter 的标准消息编解码器传输数据。常见可传值包括:
null;bool、整数、浮点数、字符串;List;Map;- 由这些类型递归组成的结构。
因此下面的调用是合理的:
await _channel.invokeMethod<int>(
'getBatteryPercentage',
);
await _channel.invokeMethod<void>(
'setRefreshInterval',
<String, Object?>{
'milliseconds': 1000,
},
);
但自定义 Dart 对象不能直接传递:
class Device {
final String id;
Device(this.id);
}
// 不能直接作为 MethodChannel 参数
await channel.invokeMethod('openDevice', Device('abc'));
应显式转换为标准结构:
await channel.invokeMethod(
'openDevice',
<String, Object?>{'id': 'abc'},
);
如果参数结构复杂、方法数量多,手工维护字符串和 Map 会产生以下风险:
- 方法名拼写错误只能运行时发现;
- 参数键名在 Dart 与原生代码中重复定义;
- 类型变化容易遗漏某个平台;
- 错误返回结构没有编译期约束。
这时可以使用 Pigeon。Pigeon 根据 Dart 中的消息模型生成类型化的平台通信代码,减少手写 MethodChannel 的重复协议代码。它改善的是“消息协议的类型安全和代码生成”,并不会自动解决平台能力不一致、权限或生命周期问题。
五、EventChannel 与持续状态
一次性读取适合 MethodChannel,持续事件适合 EventChannel。例如监听电量变化:
static const EventChannel _events = EventChannel(
'com.example.battery_level/battery_events',
);
Stream<int> batteryPercentageStream() {
return _events.receiveBroadcastStream().map((value) {
if (value is! int || value < 0 || value > 100) {
throw StateError('Invalid battery event: $value');
}
return value;
});
}
持续事件的生命周期比一次调用复杂:
Dart listen
│
▼
原生 onListen
│
▼
注册系统监听器
│
▼
系统产生事件
│
▼
eventSink.success(value)
│
▼
Dart Stream listener
Dart cancel
│
▼
原生 onCancel
│
▼
注销系统监听器
如果原生端在 onCancel 后仍然向旧的 eventSink 发送事件,可能造成资源泄漏、重复事件或对象已经失效的错误。因此实现必须把“是否存在订阅”作为状态管理的一部分,而不是只在初始化时注册一次监听器。
一个合理的事件状态模型至少包括:
无订阅 ──listen──> 已订阅
已订阅 ──系统事件──> 已订阅
已订阅 ──cancel──> 无订阅
已订阅 ──引擎分离──> 已清理
同时还要决定多个 Dart 订阅者如何处理:
- 只允许一个订阅;
- 共享同一个原生监听器;
- 每个订阅都创建独立监听器。
插件应在文档中说明这一点。receiveBroadcastStream 暗示 Dart 侧可以广播订阅,但不自动规定原生端资源应该如何复用。
六、Federated Plugin:把接口和实现拆成多个 package
Federated Plugin 是 Flutter 插件的一种组织方式:将公共 API、平台接口和每个平台实现拆分为多个相互关联的 package。
典型结构如下:
battery_level/
├── lib/
│ └── battery_level.dart # 面向应用的 API
└── pubspec.yaml
battery_level_platform_interface/
├── lib/
│ └── battery_level_platform_interface.dart
└── pubspec.yaml
battery_level_android/
├── android/
├── lib/
│ └── battery_level_android.dart
└── pubspec.yaml
battery_level_ios/
├── ios/
├── lib/
│ └── battery_level_ios.dart
└── pubspec.yaml
battery_level_web/
├── lib/
│ └── battery_level_web.dart
└── pubspec.yaml
各 package 的职责不同:
- app-facing package:应用依赖的入口,例如
battery_level; - platform interface package:稳定的抽象接口和测试替身;
- implementation package:某个具体平台的实现;
- endorsed implementation:通过主插件的插件元数据自动接入,应用通常不必直接导入平台实现包。
为什么需要 platform interface
如果 app-facing package 直接持有 MethodChannel,测试时通常必须模拟底层消息通道。把接口单独抽出来后,应用 API 只依赖抽象,测试可以直接注入 fake。
使用 plugin_platform_interface 时,还需要防止第三方实现错误绕过接口约束。接口包可以这样写:
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
abstract class BatteryLevelPlatform extends PlatformInterface {
BatteryLevelPlatform() : super(token: _token);
static final Object _token = Object();
static BatteryLevelPlatform _instance = MethodChannelBatteryLevel();
static BatteryLevelPlatform get instance => _instance;
static set instance(BatteryLevelPlatform instance) {
PlatformInterface.verify(instance, _token);
_instance = instance;
}
Future<int> getBatteryPercentage() {
throw UnimplementedError(
'getBatteryPercentage() has not been implemented.',
);
}
}
class MethodChannelBatteryLevel extends BatteryLevelPlatform {
static const _channel = MethodChannel(
'com.example.battery_level/battery',
);
@override
Future<int> getBatteryPercentage() async {
final value = await _channel.invokeMethod<int>(
'getBatteryPercentage',
);
if (value == null || value < 0 || value > 100) {
throw StateError('Invalid battery percentage: $value');
}
return value;
}
}
PlatformInterface 的 token 机制用于约束接口实例的替换方式。平台实现通常应继承接口类,而不是仅仅实现一个同名 Dart interface。这样可以在后续增加默认方法时保持更好的兼容性,并让接口包能够验证实现来源。
app-facing package 可以只负责转发:
import 'package:battery_level_platform_interface/battery_level_platform_interface.dart';
class BatteryLevel {
Future<int> getBatteryPercentage() {
return BatteryLevelPlatform.instance.getBatteryPercentage();
}
}
Android implementation package 中的 Dart 层可以这样提供默认实例:
import 'package:battery_level_platform_interface/battery_level_platform_interface.dart';
class BatteryLevelAndroid extends BatteryLevelPlatform {
BatteryLevelAndroid() : super();
static void registerWith() {
BatteryLevelPlatform.instance = BatteryLevelAndroid();
}
@override
Future<int> getBatteryPercentage() {
return MethodChannelBatteryLevel().getBatteryPercentage();
}
}
实际实现通常会把通道对象放在 Android package 的 Dart 代码中,并由其原生部分完成注册。上面的代码重点是说明依赖方向和实例替换关系;生产代码应将 MethodChannel 逻辑集中在该平台 package 内,避免公共 API package 重新知道 Android 细节。
Federated Plugin 的依赖关系
合理的依赖方向是:
应用
│
▼
app-facing package
│
▼
platform interface package
▲
│
各平台 implementation package
平台实现依赖接口,公共 API 也依赖接口;接口不能依赖任何具体平台。否则接口包会被 Android、iOS 或 Web 的实现反向污染,拆分就失去了意义。
app-facing package 的 pubspec.yaml 可通过 default_package 声明默认实现:
flutter:
plugin:
platforms:
android:
default_package: battery_level_android
ios:
default_package: battery_level_ios
web:
default_package: battery_level_web
某个平台实现包则声明自己实现哪个主插件及其平台:
flutter:
plugin:
implements: battery_level
platforms:
android:
package: com.example.battery_level
pluginClass: BatteryLevelPlugin
这里的 default_package 和 implements 属于插件发现与接入元数据,不是 Dart 的 import 语法。版本升级时应同时验证:
flutter pub get
flutter pub deps
flutter analyze
flutter test
flutter build apk
flutter build ios --no-codesign
flutter build web
其中 iOS 构建需要 macOS 和 Xcode;Android 构建需要 Android SDK;Web 构建不能证明原生实现有效,反之亦然。
七、平台差异应体现在接口语义中
“支持多个平台”不等于“每个平台都返回完全相同的结果”。应先区分三种情况:
- 平台有相同能力:统一返回同一语义;
- 平台能力相近但数据模型不同:在接口层规定转换;
- 平台没有该能力:返回明确的不支持错误,或在 API 设计中暴露可选能力。
Android 与 iOS
Android 常见问题包括:
- 某些系统 API 具有最低 API level;
- 电池、蓝牙、定位等能力可能需要权限;
- Activity 可能重建,不能把短生命周期对象当作插件全局对象;
- 原生回调线程和 Dart 调用线程不应被假定为完全相同。
iOS 常见问题包括:
- 模拟器不一定提供真实硬件数据;
- 系统 API 可能返回“不可用”哨兵值;
- 权限描述必须存在于宿主应用的配置中;
- 应用进入后台后,部分能力会被系统暂停或限制。
因此平台接口不能只写:
Future<int> getBatteryPercentage();
还应该在文档中规定:
成功:返回 0..100 的整数。
设备或系统无法提供:抛出 code=UNAVAILABLE 的平台错误。
插件未注册:允许抛出 MissingPluginException。
调用参数错误:抛出 ArgumentError 或平台错误。
Web
Web 插件不是把 Android 或 iOS 代码编译成 JavaScript。它必须使用浏览器提供的 Web API、JavaScript 互操作,或者明确声明不支持。
浏览器能力通常受以下因素限制:
- API 是否被浏览器实现;
- 当前页面是否处于安全上下文;
- 用户权限和浏览器策略;
- iframe 或跨域限制;
- 桌面浏览器和移动浏览器的行为差异。
因此 Web 实现可能需要:
class BatteryLevelWeb extends BatteryLevelPlatform {
@override
Future<int> getBatteryPercentage() {
throw UnsupportedError(
'This browser does not expose a supported battery API.',
);
}
}
这是一个合法的平台实现策略,前提是公共文档明确说明 Web 支持范围。比起在不可靠的浏览器 API 上返回伪造数据,这种行为更容易诊断。
Windows、macOS 和 Linux
桌面平台并不是一个统一平台:
- Windows 插件可能使用 C++/WinRT、Windows API 或其他原生库;
- macOS 插件可能使用 Swift、Objective-C 或系统框架;
- Linux 插件常见 GTK、D-Bus 或发行版相关 API;
- 桌面设备可能没有电池,或者电池接口由硬件和桌面环境决定。
如果实现使用 Dart FFI,调用链可能不再经过 MethodChannel:
Dart API
│
▼
dart:ffi
│
▼
动态库 / 系统 ABI
FFI 适合调用 C ABI 或本地动态库,不能直接替代所有平台消息通信。它要求处理指针生命周期、内存释放、ABI 兼容性和线程安全;如果调用的是 Objective-C、Swift 或 Kotlin 对象 API,仍需要平台侧封装。
八、插件生命周期、并发和重入
插件代码同时面对三种生命周期:
- Flutter Engine 生命周期;
- 宿主 Activity、ViewController 或窗口生命周期;
- 具体系统资源的生命周期,例如监听器、相机、蓝牙连接。
一个常见错误是只实现“初始化”,没有实现“清理”:
引擎创建
└─注册通道
└─打开系统监听
└─宿主页面销毁
└─插件仍持有监听器
└─重复回调或资源泄漏
正确的设计需要定义:
onAttachedToEngine时创建哪些对象;onDetachedFromEngine时释放哪些对象;- Activity 分离时哪些调用必须失败;
- 同一方法被并发调用时是否允许;
- 第二次
start()是幂等、报错还是重新连接; stop()是否可以在未启动时调用。
例如一个连接型插件可以定义状态:
Idle
└─connect()──> Connecting
├─成功──> Connected
└─失败──> Idle
Connected
├─disconnect()──> Idle
└─引擎分离──> Disposed
如果 Dart 侧连续调用:
final a = api.connect();
final b = api.connect();
await Future.wait([a, b]);
原生实现必须有明确结果。可选策略包括:
- 第二次直接复用第一次连接的 Future;
- 第二次抛出
ALREADY_CONNECTING; - 允许多个调用排队,但最终只建立一个连接。
不能让结果取决于线程调度顺序,却不在接口中说明。
MethodChannel 的调用本身是异步的,但异步不等于自动并发安全。平台代码访问共享资源时仍可能需要锁、串行队列或状态检查。尤其是原生回调在后台线程执行时,不能假设 Dart 侧和原生侧共享同一线程模型。
九、测试:分别验证接口、消息协议和平台代码
插件测试不能只写一个“调用成功”的测试。至少要覆盖三层:
Dart 公共 API
│
├─接口测试:fake 平台实现
├─通道测试:模拟 MethodChannel 消息
└─原生测试:Android/iOS/Web 各自工具链
1. 公共 API 的 fake 测试
如果公共 API 依赖 BatteryLevelPlatform.instance,可以注入 fake:
import 'package:battery_level_platform_interface/battery_level_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeBatteryLevelPlatform extends BatteryLevelPlatform {
FakeBatteryLevelPlatform(this.value);
final int value;
@override
Future<int> getBatteryPercentage() async => value;
}
void main() {
test('public API delegates to platform interface', () async {
final previous = BatteryLevelPlatform.instance;
addTearDown(() {
BatteryLevelPlatform.instance = previous;
});
BatteryLevelPlatform.instance = FakeBatteryLevelPlatform(87);
final api = BatteryLevel();
expect(await api.getBatteryPercentage(), 87);
});
}
这个测试验证的是“公共 API 是否正确委托”,不验证 MethodChannel 名称,也不验证 Android 电池 API。测试边界清晰后,失败原因更容易定位。
2. MethodChannel 协议测试
Dart 侧的通道实现可以通过 Flutter 测试框架模拟原生响应:
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const channel = MethodChannel(
'com.example.battery_level/battery',
);
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'getBatteryPercentage':
return 87;
default:
throw PlatformException(
code: 'NOT_IMPLEMENTED',
message: 'Unknown method: ${call.method}',
);
}
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('decodes battery percentage from channel', () async {
final api = MethodChannelBatteryLevel();
expect(await api.getBatteryPercentage(), 87);
});
}
测试前提是使用 flutter_test,并且测试运行在 Flutter 测试环境中,而不是普通的 dart test。
还应测试错误和非法响应:
test('throws when platform returns an invalid value', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async => 101);
final api = MethodChannelBatteryLevel();
expect(
api.getBatteryPercentage,
throwsA(isA<StateError>()),
);
});
这里测试的是 Dart 侧的协议校验。如果要验证 PlatformException,模拟处理器应显式抛出:
setMockMethodCallHandler(channel, (call) async {
throw PlatformException(
code: 'UNAVAILABLE',
message: 'No battery data',
);
});
3. 测试异常路径
插件至少需要覆盖:
- 方法名未知时返回
notImplemented; - 原生数据为空;
- 原生数据类型错误;
- 平台 API 返回不可用哨兵值;
- 权限被拒绝;
- 插件没有注册;
- 引擎分离后继续调用;
- 事件订阅取消后不再产生事件;
- 并发调用是否符合接口规定。
MissingPluginException 通常表示当前运行环境没有对应平台实现或插件没有正确注册,不应简单转换成“设备不支持”。诊断时应先确认:
flutter clean
flutter pub get
flutter run
如果仍然出现问题,再检查目标平台是否在插件元数据中声明,以及宿主工程是否使用了正确的 Flutter embedding 和插件注册流程。
4. 原生测试不能由 Dart 测试替代
Android 的 BatteryManager 行为应使用 Android 测试工具链验证;iOS 的 UIDevice 行为应使用 XCTest 或实际设备/模拟器验证;Web 实现应在浏览器环境中构建和运行。
Dart 的 MethodChannel 测试只能证明:
Dart 发出了正确的方法名和参数,
并且能正确解释模拟的返回值。
它不能证明:
Kotlin/Swift 已注册;
系统权限声明正确;
原生 API 在真实设备上可用;
插件在后台或引擎重建后仍然正确。
十、版本兼容与接口演进
插件接口一旦发布,平台实现和应用可能不会同时升级。因此公共接口的修改必须考虑旧实现。
假设原接口是:
abstract class BatteryLevelPlatform extends PlatformInterface {
Future<int> getBatteryPercentage();
}
新增方法有两种影响:
Future<String> getBatteryHealth();
如果把它设为新的抽象方法,所有已有平台实现都必须立即修改,否则编译失败。这是一次高影响变更。
可以提供默认实现:
Future<String> getBatteryHealth() {
throw UnsupportedError('Battery health is not supported.');
}
这样旧平台实现仍可编译,新平台可以覆盖它。代价是调用者可能在运行时才发现平台不支持。
接口变更应分别考虑:
- Dart API 是否发生破坏性变化;
- platform interface 是否增加抽象成员;
- 各 implementation package 的最低依赖版本;
- app-facing package 是否仍能自动找到默认实现;
- Android/iOS 原生最低版本是否变化;
- Web 和桌面支持范围是否变化。
通常使用语义化版本管理:
- 修复错误且保持兼容:增加 patch;
- 增加兼容功能:增加 minor;
- 删除 API、改变返回语义或提高最低环境要求:通常增加 major。
版本约束也必须允许 Dart 3 和目标 Flutter SDK,例如:
environment:
sdk: ^3.0.0
flutter: ">=3.10.0"
具体下限应根据实际使用的 API、插件接口包版本和 CI 验证矩阵确定,不能只因为当前本机版本较新就随意提高下限。
十一、发布前的完整验证流程
发布 package 前,先检查 Git 工作区、版本号、变更日志和许可证。然后使用:
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
dart pub publish --dry-run
dart pub publish --dry-run 会执行发布前检查但不会真正上传。它可以发现 package 元数据、文件内容和发布结构问题,但不能替代目标平台构建。
还应针对实际支持的平台执行构建:
flutter build apk
flutter build web
flutter build macos
flutter build windows
flutter build linux
这些命令的前置条件不同:
flutter build apk需要 Android SDK;flutter build web不需要原生移动端工具,但只能验证 Web;flutter build macos需要 macOS;flutter build windows需要 Windows 的桌面构建工具;flutter build linux需要 Linux 桌面依赖。
不能因为一个 package 能在 Web 构建,就宣称 Android 插件也可用。发布说明应明确列出每个平台的状态:
Android:支持,最低 API level 为……
iOS:支持,需要……
Web:仅支持具备某浏览器 API 的环境
Windows:不支持
Linux:不支持
如果使用 Federated Plugin,还要验证各 package 的发布顺序和版本关系:
- 先发布或准备好 platform interface;
- 发布各平台 implementation package;
- 更新 app-facing package 的依赖和
default_package; - 在全新示例应用中只依赖 app-facing package;
- 分别运行目标平台;
- 确认应用不需要手动导入平台实现包。
“全新示例应用”很重要,因为已有工程可能残留旧的插件缓存、旧的 Pod 状态或旧的 Gradle 配置,掩盖发布包本身的问题。
十二、常见失败表现与诊断路径
MissingPluginException
常见原因:
- 当前平台没有实现;
- 插件没有在
pubspec.yaml中声明目标平台; - 改动原生插件代码后只热重载,没有完整重启;
- 宿主工程缓存了旧的插件注册结果;
- 运行的是与声明平台不同的目标。
诊断顺序应是:
确认运行平台
→ 检查 pubspec 的 flutter.plugin
→ flutter clean
→ flutter pub get
→ 完整重启应用
→ 检查原生构建日志
PlatformException
先记录 code、message 和 details,再区分:
- 业务可预期错误,例如权限拒绝;
- 平台暂时不可用;
- 参数协议错误;
- 原生代码抛出未处理异常;
- Dart 与原生通道名称不一致。
不要只记录 message。错误码应保持稳定,便于应用层和日志系统进行分类。
事件重复或停止后仍回调
通常是原生监听器被注册多次,或者 onCancel 没有注销。应记录以下状态:
监听器注册次数
当前 Dart 订阅数量
当前 eventSink 是否有效
引擎 detach 是否已经发生
在 Debug 构建中加入断言或日志,比在生产环境中猜测更有效。
iOS 或 Android 只在真实设备失败
模拟器通常不能代表硬件能力。电池、蓝牙、摄像头、传感器和后台行为都可能不同。测试设备能力时,应把“平台存在”与“设备提供数据”分开建模,并为不可用情况提供稳定错误语义。
结语
一个可维护的 Flutter 插件,核心不是把一段 Kotlin 或 Swift 代码放进 package,而是建立稳定的跨平台契约:
公共 Dart API
↓
平台接口
↓
具体平台实现
↓
平台系统能力
直接插件适合规模较小、平台数量有限的场景;Federated Plugin 适合需要独立演进 Android、iOS、Web 和桌面实现的场景。MethodChannel 适合方法调用,EventChannel 适合持续事件,Pigeon 适合复杂且需要类型化的消息协议,FFI 则适合 C ABI 和本地库调用。
接口必须明确成功值、不可用状态、错误码、生命周期和并发语义;测试必须分别覆盖 Dart 委托、消息协议和真实平台实现;发布必须同时验证 package 元数据、依赖版本、目标平台构建和全新应用接入。只有这些边界都被明确,插件才不仅能“在当前设备上跑起来”,还能够在平台差异和版本演进中保持可预测。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Platform Channel 深入:Codec、线程、错误和性能
- 下一篇:Flutter Web 与桌面:渲染器、窗口、文件、输入和平台差异
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论