[译] 现代 Flutter 插件开发

2,436 阅读14分钟

原文

对于 Flutter 插件开发者们来说 2019 年是技术进步更新迭代比较重要的一年.我们推出了 Android Plugin API 2.0, 可以帮你在插件中提供更健壮、更多特性的 Android 支持.更新了 pubspec.yaml 格式使得更方便清晰的声明 Android、iOS、web、macOS、windows 和 Linux.而且在我们推进 Flutter 支持多平台的过程中,我们启用了联盟模式,在 Flutter 开发中使用 Flutter 插件可以方便多个团队的不同专家把他们的代码无缝整合.最后我们在插件的测试方面也做出了巨大改变,更多的内容正在紧锣密鼓的开发中.

Android Plugin 2.0

2019 年 12 月,Flutter 发布了新版本的 Android embedding 支持.即负责把 Flutter 和 Android 应用整合起来.包含了像 FlutterActivityFlutterFragmentFlutterViewFlutterEngine等类.v2 版本的 Android embedding 包含了标准的 Android lifecycle 事件支持,独立于 Android UI 的 Flutter 执行(这些在 v1 中缺失的部分).在 v2 Android embedding 开发过程中我们越发觉得,当前开发 Flutter 插件的 API 已经无法满足 v2 Android embedding 的新需求.所以亟需新的 Android plugin API.接下来我们会讨论下这个 API和如何使用它.

首先理解 v2 Android embedding 中的 FlutterEngine 很重要.一个 FlutterEngine 对象即代表一个单独的 Flutter 执行上下文环境.这意味着这个 FlutterEngine 控制着一个 Dart isolate (dart 代码总是需要一个像 main 这样的入口).同时,这个FlutterEngine也会设置一系列 Flutter app 所需的所有标准的 platform channel;包含了 platform view 支持(如何使用 Flutter UI 绘制 texture, 处理一个 Flutter/Dart app运行所需的其他基础需求).除此之外,一个 Android 应用可能会同时包含多个不同的 FlutterEngine.

对 Flutter app 来说, 添加一个插件即意味着对一个单独的 FlutterEngine 应用这个插件.例如,一个 Flutter app 需要访问相机,这个功能需要向指定的 FlutterEngine 实例注册一个相机插件来实现.这个注册过程是自动通过 GeneratedPluginRegistrant 来完成,但是这对于理解每个 FlutterEnginge都维护了自己的 Flutter 插件集是很有帮助的.

在旧的 v1 Android embedding, 所有的插件都是在 Android app 启动的时候初始化并配置,并且只有这一次.在 v2 embedding 中,我们不会假设插件什么时候初始化,或者插件只能在每个FlutterEngine 中初始化一次.那么现在所有的 Android Flutter 插件必须支持实例化而不是静态初始化,必须支持绑定和解绑到一个 FlutterEngine 上.下面的代码演示了 v1 插件初始化实现和 v2 插件初始化过程的不同.

旧插件初始化
class MyOldPlugin {
    public static void registerWith(PluginRegistrar registrar) {
        // 从 registrar 中获取插件需要的各种引用
        // 现在这个插件可以考虑 `初始化` 和 `绑定` 操作了
    }
}
新插件初始化
class MyNewPlugin implements FlutterPlugin {
    public MyNewPlugin() {
        // 所有的 Android 插件类必须支持无参构造.
        // 到这里,你的插件就已经初始化了,但是还没有绑定到任何 Flutter 实例中. 在此处不可进行任何与获取资源或操纵 Flutter 的相关操作.
    }
    
    @override
    public void onAttachedToFlutterEngine(FlutterPluginBinding binding) {
        // 插件现在绑定到给定的 FlutterEngine 上了
        // 可以通过 binding.getFlutterEngine() 来获取关联的 FlutterEngine
        // 可以通过 binding.getBinaryMessenger() 获取 BinaryMessenger
        // 可以通过 binding.getApplicationContext() 获取 Application context
        // 不能在此处访问 Activity, 因为这个 FlutterEngine 可能还没有显示在 Activity 上. 查看 ActivityAware 接口获取更多相关信息.
    }
    
    @override
    public void onDetachedFromFlutterEngine(FlutterPluginBinding binding) {
        // 插件不再绑定到 FlutterEngine 
        // 此处需要清理在 onAttachedToFlutterEngine() 中绑定的各种资源和引用
    }
}

如上所述, 新插件必须等到 onAttachedToFlutterEngine() 完成后才能执行操作,而且必须响应 onDetachedFromFlutterEngine() 回调释放所有的资源.你的插件可能会被多次绑定/解绑.

除此之外, 你的插件绝不能在 onAttachedToFlutterEngine() 中依赖 Activity 引用.你的插件绑定到 FlutterEngine 不意味着此 FlutterEngine 已经被显示到 Activity 上了.这是新旧插件API的最重要的区别.旧 v1 插件API中,插件开发者在插件可用时可以立即而且永久引用 Activity.现在已经不再支持了.

插件想要访问 Activity 必须实现第二个接口 ActivityAware.ActivityAware 可以通知你的插件什么时候 Activity 可访问(FlutterEngine 在 Activity 中展示), 什么时候 Activity config change, 什么时候 Activity 不可用(FlutterEngine 被移除出 Activity).新插件必须响应这些回调.下面的代码显示了 ActivityAware 接口如何使用

class MyNewPlugin implements FlutterPlugin, ActivityAware {
  @override
  public void onAttachedToFlutterEngine(FlutterPluginBinding binding) {
    // ...
  }
  @override
  public void onDetachedFromFlutterEngine(FlutterPluginBinding binding) {
    // ...
  }
  @override
  public void onAttachedToActivity(ActivityPluginBinding binding) {
    // 插件此时绑定到了一个 Android Activity
    // 如果此方法被调用,那么肯定是在 onAttachedToFlutterEngine() 之后
    // 通过 binding.getActivity() 获取 Activity 引用
    // 通过 binding.getLifecycle() 监听 Lifecycle 变化
    // 监听 Activity results, new Intents, user leave hints 和 state saveding 回调
  }
  
  @override
  public void onDetachedFromActivityForConfigChanges() {
    // 插件绑定的 Activity 因为 config change 而被销毁了.虽然很快会恢复现场,但是插件必须清理任何关于这个 Activity 的引用及资源
  }
  @override
  public void onReattachedToActivityForConfigChanges(
    ActivityPluginBinding binding
  ) {
    // 在 config change 后插件重新绑定到了一个新 acitvity 实例.可以重新连接 activity 引用及其相关资源
  }
  @override
  public void onDetachedFromActivity() {
    // 插件不再和 activity 关联.此处可以清理所有引用和资源。插件可能再也不会和 activity 绑定了
  }
}

新插件 API 明确的识别出插件是不是和一个 Activity 绑定(任何 Activity 由于 configuration 改变导致的销毁重建).对于安卓开发者来说还是很熟悉的.

使用 Flutter v2 Android embedding API 写插件的关键在于响应插件的生命周期回调.只要你在合适的时机获取引用、释放引用,那么插件就会如期运行的.

有些插件,如相机插件,仅在 Activity 可用时才有效.那么这些仅 UI 相关的插件可以等到 onAttachedToActivity() 执行后再进行相关操作.然后在 onDetachedFromActivity() 中清理所有的引用以注销自己.没有任何强制要求必须在 onAttachedToFlutterEngine() 中做任何逻辑处理.一个插件仅在绑定到 Activity 后才开始处理逻辑是完全 OK 的.

pubspec 格式

传统的 Flutter 插件是一个可以使用 Flutter 应用能访问 Android 和 iOS 平台功能的独立包;技术上来说,这个插件混合了 Android 和 iOS 平台指定代码和 Dart 代码.即使这样,假设任意 Flutter 插件支持 Android 和 iOS 也是不准确的(例如 android_intent 插件仅支持 Android), 它就是在这样一种假设下设计插件生态系统的.大多时候这个假设没什么毛病,这意味实例数越少错误带来的成本也更低,而且简化假设可以开启快速迭代,保证目标一致.

随着 Flutter 逐渐覆盖更多的平台,我们也开始扩大这一假设:

1、我们期待更多个插件仅支持 Flutter 支持平台的子集(更多如官方插件示例)

2、我们希望解开插件支持平台所需要的相关知识的工具特性束缚(如智能的 pub.dev 搜索, 平台信息工具操作)

此处缺失的核心功能是如何清晰表明插件支持哪个平台,所以我们针对多平台开发重新设计了 Flutter 插件的 pubspec 定义

之前的 pubspec schema 是在 flutter.plugin 键中保存了不同的插件配置信息, 而新插件中我们在 flutter.plugin.platforms 中定义新的平台键值对,指定平台特定的插件配置.例如,我们在这个插件中支持 Android、iOS、macOS 和 web

flutter:
    plugin:
        platforms:
            android:
                package: com.example.hello
                pluginClass: HelloPlugin
            ios:
                pluginClass: HelloPlugin
            macos:
                pluginClass: HelloPlugin
            web:
                pluginClass: HelloPlugin
                fileName: hello_web.dart
    environment:
        sdk: ">=2.1.0 <3.0.0"
        flutter: ">=1.10.0"

支持平台集的子集的插件可以忽略掉其他平台 key.

flutter:
  plugin:
    platforms:
      android:
        package: com.example.hello
        pluginClass: HelloPlugin
      ios:
        pluginClass: HelloPlugin
environment:
  sdk: ">=2.1.0 <3.0.0"
  # Flutter versions prior to 1.10 did not support
  # the flutter.plugin.platforms map.
  flutter: ">=1.10.0"

使用新 schema 时要求 Flutter SDK 大于 1.10.0.因为这是第一个支持新 schema 的 Flutter 工具版本.

迁移旧插件到新的 schema

本章使用电池插件作为例子展示如何迁移到新 schema.

迁移最重要的一件事是声明本插件支持的平台(之前的版本中插件可能仅支持 Android 那么需要包含一个 iOS 空实现,反之亦然)

下面是迁移前的插件 pubspec.yaml 文件

name: sample
version: 0.3.1+5

flutter:
    plugin:
        androidPackage: io.flutter.plugins.sample
        iosPrefix: FLT
        pluginClass: SamplePlugin
        
environment:
    flutter: ">=1.6.7 <2.0.0"

假设此插件支持 Android 和 iOS,那么升级到新 schema 需要改动

  • 升级最低支持的 Flutter 版本(即支持新 schema 的最低版本)
  • 最小版本声明
  • 使用新平台属性替换当前的 flutter.plugin
  • 如果之前声明了 iosPrefix, 那么重命名 iOS 插件的主文件

升级后的 pubspec 文件如下

name: sample
version: 0.3.2

flutter:
    plugin:
        platforms:
            android:
                package: io.flutter.plugins.sample
                pluginClass: SamplePlugin
            ios:
                pluginClass: FLTSamplePlugin

environment:
    flutter: ">=1.0.0 <2.0.0"

现在支持 Android 和 iOS 的声明部分都位于 flutter.plugin.platforms 之下了.flutter.plugin.androidPackage 变为了 flutter.plugin.android.package.iosPrefix 没有新的对应,pluginClass 可以使用单独的 flutter.plugin.platforms.ios.pluginClass 来替换, 而值变成了FLTSamplePlugin.

之前使用 iosPrefix 属性的插件

之前的 schema 属性在 iOS 插件的主界面和文件名之间有点矛盾,例如对于使用之前 shcema 定义的 sample 插件,应该有一个 SamplePlugin.h 文件作为 FLTSamplePlugin 接口的声明.这种不一致将不复存在,也就是当升级到新 schema 时SamplePlugin.h 必须命名为FLTSamplePlugin.h.没有使用 iosPrefix 属性的插件则不需要重命名文件.

想要了解更多关于多平台支持的知识,请访问flutter.dev 开发插件包

联盟

pubspec schema 不仅允许你指定确定的平台支持,而且可以灵活的跨多个 packages 实现.过去,插件的 Dart 代码,Android 的 Java(或 Kotlin) 代码,iOS 的 Object-C(或 Swift) 代码都需要写在同一个 Dart 包里.现在如果我们想添加另一个平台(Web、MacOS、Windoes,等)支持,不再需要在同一个包里写代码了.插件可以分布在多个被称为 联盟插件 的包里.

相比单一包插件,联盟插件有这么几个优势

  • 插件作者不需要对每个Flutter 支持的平台(Android、iOS、Web、MacOS 等)都是专业开发者
  • 你可以添加新的平台支持代码,而不需要原插件作者的 review 和合并你的代码
  • 每个包可以单独的维护和测试

那么,究竟怎样来创建一个新的联盟插件呢?先来看看这几个技术名词

  • 面向 app 的包(app-facing package): 这是一个你将要在你的 app 中使用而导入的插件包.例如package:url_launcher 就是一个面向 app 的包.面向 app 的包一般声明了面向 app 的 API,而且可以和各种不同的平台包一起实现平台独立的功能.
  • 平台包:这是一个实现了平台独立功能的包,需要被面向 app 的包来使用.例如package:url_launcher_web 就是被 package:url_launcher 用来在 Web 平台的 Flutter app 中打开 URLs.平台包不应被导入进 app,它们只能被 面向 app 的包 在特定的平台中调用.
  • 平台接口包:这是一个把 平台独立包面向 app 包连接在一起的胶水.即面向 app 包声明的 API 可以在 Flutter app 中被调用,平台接口包声明了所有平台必须实现的接口以便面向 app 包可以在指定平台使用.一个单独的包定义了这样的接口以便所有的平台包可以以统一的方式实现相同的功能.

package declare

上图展示了 app、面向 app 包、平台接口包、平台包之间的关系.app 只需要导入面向 app 包即可(在这个例子中是 package:url_launcher)

那么平台接口包是如何正确连接面向app包和平台包的呢?之前是没有“平台包”的概念的,只有一个 Android 代码的子文件夹和另一个 iOS 代码的子文件夹.面向app包是通过 MethodChannel 来在面向app包和平台包之间通信的.你可以认为MethodChannel就是实际意义上的“平台接口”,因为面向app包是在MethodChannel中调用的,当然相应的平台代码必须监听正确的 MethodChannel方法及其参数.没有办法来静态的确保 Android 代码或 iOS 代码是否在监听正确的 MethodChannel 调用.

打开 URL 的旧方法

Future<void> launch(String url) {
    channel.invokeMethod('launch', {
        'url': url,
    });
}

在联盟插件架构中,平台接口包替代了MethodChannel.面向app包需要使用的平台特定功能已经封装在平台包的平台接口中.在我们的例子中,面向app包是package:url_launcher,它唯一的平台指定功能是在指定平台上打开 URL.那么特别简单的平台接口就是这样.

abstract class UrlLauncherPlatform {
    /// 打开给定的 [url]
    Future<void> launch(String url);
    
    /// 此平台接口的可用实例.
    ///
    /// 当平台包被注册后就可以设置此值,通常是平台初始化时.
    ///
    // 例如 web 平台包 (package:url_launcher_web) 会扩展此类实现在新 tab 页打开 URL,在初始化时设置此实例对象: UrlLauncherPlatform.instance = WebUrlLauncher();
    static UrlLauncherPlatform instance;
}

现在代替使用 MethodChannel,面向app包就可以调用平台接口了.

打开 URL 的新方法

Future<void> launch(String url) {
    return UrlLauncherPlatform.instance.launch(url);
}

面向 app包就可以直接调用平台接口了.那么平台接口是如何连接到平台包的?平台包实现了平台接口,然后当平台初始化时把自己注册为默认的平台接口实例对象.

例如,如果我们想写package:url_launcher_web,我们只需要实现 UrlLauncherPlatform 接口然后在 web 平台打开 URL 即可.实例代码如下:

class UrlLauncherWeb extends UrlLauncherPlatform {
    /// 当app初始化时,web平台自动调用此方法
    static void registerWith(Register register) {
        var webLauncher = UrlLauncherWeb();
        UrlLauncherPlatform.instance = webLauncher;
    }
    
    @override
    Future<void> launch(String url) => window.open(url, '');
}

迁移到联盟架构比较好的一点是,一旦定义好了面向app包和平台接口包,那么添加新的平台支持就很简单了(而且也不必非得自己动手!).所有的工作只需要创建一个新的扩展了平台接口包里定义的平台接口的插件包即可.

想了解更多关于联盟插件的知识,查看flutter.dev 联盟插件

插件测试

只要你新建了跨平台插件或者为已有的插件添加新平台,那么可以通过测试来拯救你的时间和未来的坑.自动化测试使用函数回归来保护你的插件,这样你可以快速开发新特性、合并贡献.

一个良好的测试插件包括典型的跨多包测试套件.如果写那些绝不会失败或不可靠的测试可能减慢开发速度,那么仅测试你觉得重要的关键用例.

AutomatedWidgetsFlutterBinding 测试

推荐在开发机上使用 AutomatedWidgetsFlutterBinding 测试,而不是某个设备或浏览器.这样,测试可能运行更高效,但是某些功能就需要 mock .

在面向app包(如 myplugin)中,包的单元测试可以确保面向app包的API调用都会得到和平台接口包一致的结果.这些测试通常导入 package:mockito 来提供模拟的平台接口,验证是否能收到正确的调用.package:url_launcehr的测试用例

test('returns true', () async => {
    when(mock.canLaunch('foo')).thenAnswer((_) => Future<bool>.value(true));
    final bool result = await canLaunch('foo');
    expect(result, isTrue);
});

在平台接口包(如myplugin_platform_interface),平台接口是一个抽象类而且不能直接实例化.然而平台接口包也可以包含实现了包接口的方法实现,这就是需要测试的地方.测试这种包时的焦点应该在调用此平台接口和方法的调用结果.这些测试通常使用 setMockMethodCallHandlerisMethodCall matcher 来验证行为

test('canLaunch', () async => {
    await launcher.canLaunch('http://example.com/');
    expect(log, <Matcher>[isMethodCall('canLaunch', arguments: <String,Object>{
        'url': 'http://example.com/',
    })]);
});

平台测试(如 myplugin_web),可以充分利用平台优势测试平台特定功能.在当前 Flutter SDK 中, Flutter 测试提供了实验性能的 --platform 标记来选择在 dart:html 可用的Chrome-like 的环境里运行测试.

这个测试模式对于平台实现包(如 myplugin_web) 来说非常有用.

test('cannot launch "tel" URLs', () => {
   expect(canLaunch('tel:11111111'), completion(isFalse)); 
});

除此之外,可以在 Chrome 使用在浏览器里试验性支持 'flutter driver' 测试 来运行 GUI 测试

更多关于插件测试的信息,查看flutter.dev测试你的插件

总结

如你所见,这次发布为 Flutter 插件开发者推出了很多新特性,允许构建更多特性和健壮的跨平台插件.如果你对 web 平台更感兴趣,我推荐你阅读 Harry Terkelsen的关于如何开发 Flutter Web 插件的上下两篇文章( ).flutter.dev 的开发插件包文档也是个不错的选择.