Flutter 基础体系 · 第 66/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 插件开发:多平台接口、Federated Plugin、测试和发布

Flutter 插件是一个同时包含 Dart 代码和平台代码的 Dart package。它的作用不是“把任意原生代码包起来”,而是定义一条稳定的数据与调用边界,使 Flutter 层能够调用 Android、iOS、Web、Windows、macOS 或 Linux 的平台能力。

一个完整插件通常包含四层职责:

  1. Dart 公共 API:应用开发者直接调用的类和方法。
  2. 平台接口:规定所有平台实现必须满足的抽象契约。
  3. 平台实现:Android、iOS、Web、桌面等平台上的实际代码。
  4. 平台通信机制: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 插件;
  • pluginClasspackage 告诉 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();
}

这里的契约需要明确:

  • 返回值范围是 0100
  • 这是异步操作;
  • 如果平台不支持,抛出一个可识别的错误;
  • 如果系统暂时无法提供数据,也不能把“未知”伪装成 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 只传递方法名和参数,不负责定义业务语义。业务语义仍由 BatteryLevelPlatformBatteryLevel 负责。

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)
    }
}

关键生命周期是:

  1. onAttachedToEngine 获得 BinaryMessenger 和应用上下文;
  2. 创建与 Dart 侧名称完全相同的 MethodChannel
  3. 注册方法处理器;
  4. 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 的 batteryLevel0.01.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_packageimplements 属于插件发现与接入元数据,不是 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 构建不能证明原生实现有效,反之亦然。


七、平台差异应体现在接口语义中

“支持多个平台”不等于“每个平台都返回完全相同的结果”。应先区分三种情况:

  1. 平台有相同能力:统一返回同一语义;
  2. 平台能力相近但数据模型不同:在接口层规定转换;
  3. 平台没有该能力:返回明确的不支持错误,或在 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,仍需要平台侧封装。


八、插件生命周期、并发和重入

插件代码同时面对三种生命周期:

  1. Flutter Engine 生命周期;
  2. 宿主 Activity、ViewController 或窗口生命周期;
  3. 具体系统资源的生命周期,例如监听器、相机、蓝牙连接。

一个常见错误是只实现“初始化”,没有实现“清理”:

引擎创建
  └─注册通道
      └─打开系统监听
          └─宿主页面销毁
              └─插件仍持有监听器
                  └─重复回调或资源泄漏

正确的设计需要定义:

  • 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 的发布顺序和版本关系:

  1. 先发布或准备好 platform interface;
  2. 发布各平台 implementation package;
  3. 更新 app-facing package 的依赖和 default_package
  4. 在全新示例应用中只依赖 app-facing package;
  5. 分别运行目标平台;
  6. 确认应用不需要手动导入平台实现包。

“全新示例应用”很重要,因为已有工程可能残留旧的插件缓存、旧的 Pod 状态或旧的 Gradle 配置,掩盖发布包本身的问题。


十二、常见失败表现与诊断路径

MissingPluginException

常见原因:

  • 当前平台没有实现;
  • 插件没有在 pubspec.yaml 中声明目标平台;
  • 改动原生插件代码后只热重载,没有完整重启;
  • 宿主工程缓存了旧的插件注册结果;
  • 运行的是与声明平台不同的目标。

诊断顺序应是:

确认运行平台
  → 检查 pubspec 的 flutter.plugin
  → flutter clean
  → flutter pub get
  → 完整重启应用
  → 检查原生构建日志

PlatformException

先记录 codemessagedetails,再区分:

  • 业务可预期错误,例如权限拒绝;
  • 平台暂时不可用;
  • 参数协议错误;
  • 原生代码抛出未处理异常;
  • 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。