查看原文
其他

有赞Flutter插件开发与发布

有赞技术 有赞coder 2021-10-12

文 | 菲克&杨彬 on 电商移动

一、Flutter插件简介

一种专用的 Dart 包,其中包含用 Dart 代码编写的 API,以及针对 Android(使用Java或Kotlin)和针对 iOS(使用 OC 或 Swift)平台的特定实现(另外也可以包含Native的组件代码),也就是说插件包括原生代码与 Dart代码。插件开发完成后,将上传到 dart 插件管理服务仓库,类似于 maven、pod 库,然后在 flutter 开发过程中可以通过 pubspec.yaml(dart包管理配置文件)来获取插件服务。

二、为什么要开发 Flutter 插件

随着 Flutter 生态越来越完善,以及 Flutter 在性能上的高光表现,越来越多的模块将会通过 Flutter 来进行实现。为了更方便的与原生工程进行对接以及降低整体工程的耦合,Flutter 的开发模式也需要做成组件化的模式,拥有独立调试以及可拆卸的特性。原生工程在接入 Flutter 模块时,只需要在 gradle(pod) 中添加依赖,即可与 Flutter 模块进行交互。

在 Flutter 不同的模块开发过程中,我们不想重复的去搭建一些基础的 flutter 组件,比如埋点组件、网络通信组件、图片处理组件等,同时我们也希望在不同的 Flutter 模块开发过程中,保持 Flutter 整体的视觉风格一致,所以我们需要抽离出一些 Flutter 通用插件,来保证风格的统一以及整体工程的简洁、清晰。

总结一下,Flutter 插件化开发的好处:

  • 组件独立维护,降低工程耦合

  • 降低开发Flutter新模块的成本

  • 保持整体风格统一

上面讲了Flutter插件包括原生模块与 Dart 模块,Dart 模块很好理解,就是用 dart 写一些通用 UI、通用 IO 等。那原生模块应该怎么理解?

首先,虽然 Flutter 的生态现在已经越来越完善了,但是相比于 Android 跟 iOS 原生的生态体系,还是远远不够。很多在 Android 跟 iOS 原生上有的很酷炫的库,在 Flutter 中还没有或者是并没有那么的完善。其次,想必大家在原生工程里都有一套用了多年的稳定基础组件,包括网络组件、数据组件等,要重新在 Flutter 中用 dart 来搭建一套,时间成本、风险成本、组件兼容性等都是不可控的。所以,最理想的方式就是 Flutter 的基础组件可以对我们现有原生的组件做一层包装,然后提供接口给 Flutter 模块进行调用,这样一来什么时间、风险、兼容性都不是问题。我们只要维护一套原生组件就好,Flutter 组件只是一层包装,并不在意内部如何去实现。那么 Flutter 跟原生怎么进行交互呢?


三、Flutter 如何与原生交互

Flutter 与原生的交互模型,类似于一种 C-S 模型。其中 Flutter 为 Client 层,原生为 Server 层,两者通过 MethodChannel 进行消息通信,原生端向 Flutter 提供已有的Native 组件功能。

在客户端, MethodChannel允许发送与方法调用相对应的消息。在平台方面,Android上的 MethodChannel和 iOS 上的 FlutterMethodChannel启用接收方法调用并返回结果。这些类允许你使用非常少的“样板”代码开发平台插件。

Flutter 与原生的消息传递采用标准信息编解码器,是一种相对高效的二进制序列化与反序列化。当接收跟发送消息时,这些值在消息中会自动进行序列化与反序列化。详细的请参阅 StandardMessageCodec


3.1 什么是 MethodChannel

Flutter 定义了3种 Channel 模型,分别是:

  • BasicMessageChannel:用于传递字符串和半结构化的信息

  • MethodChannel:用于传递方法调用(method invocation)

  • EventChannel: 用于数据流(event streams)的通信

3种 channel 之间既有共性,也有各自的特性,下面我们就 MethodChannel 进行展开

MethodChannel 有3个重要的成员变量:

  1. - String name

在 Flutter 中会存在多个 Channel,一个 Channel 对象通过 name 来进行唯一的标识,所以在 Channel的命名上一定要独一无二,推荐采用组件名 _Channel 名 组合来进行命名

  1. - BinaryMessenger messenger

BinaryMessenger 是 Platform 端与 Flutter 端通信的工具,其通信使用的消息格式为二进制格式数据。当我们初始化一个 Channel,并向该 Channel 注册处理消息的 Handler 时,实际上会生成一个与之对应的 BinaryMessageHandler,并以 channel name 为 key,注册到 BinaryMessenger 中。当 Flutter 端发送消息到 BinaryMessenger 时,BinaryMessenger 会根据其入参 channel 找到对应的 BinaryMessageHandler,并交由其处理。

Binarymessenger 在 Android 端是一个接口,其具体实现为 FlutterNativeView。而其在 iOS 端是一个协议,名称为 FlutterBinaryMessenger,FlutterViewController 遵循了它。

Binarymessenger 并不知道 Channel的存在,它只和 BinaryMessageHandler 打交道。而 Channel 和 BinaryMessageHandler 则是一一对应的。由于 Channel 从 BinaryMessageHandler 接收到的消息是二进制格式数据,无法直接使用,故 Channel 会将该二进制消息通过 Codec(消息编解码器)解码为能识别的消息并传递给 Handler 进行处理。

当 Handler 处理完消息之后,会通过回调函数返回 result,并将 result 通过编解码器编码为二进制格式数据,通过 BinaryMessenger 返回。

  1. - MethodCodec codec

消息编解码器 Codec 主要用于将二进制格式的数据转化为 Handler 能够识别的数据

MethodCodec 主要是对 MethodCall 中这个对象进行序列化与反序列化

MethodCall 是 Flutter 向 Native 发起调用产生的对象,其中包含了方法名以及一个参数集合(map 或者是 Json)

介绍完3个重要的变量,我们把整个流程连起来,看一下完成的交互流程是怎么样的


3.2 Flutter 与原生通信整体流程

  • 首先从 dart 层调用 _channel.invokeMethod("方法名",参数),invoke 方法会将传入的方法名与参数封装成 MethodCall 对象,然后通过 MethodCodec 对 MethodCall 对象进行编码,形成二进制格式。然后通过 BinaryMessenger的send 方法,将二进制格式的数据进行发送,我们继续看一下 send 方法是如何实现的:

  1. Future<dynamic> invokeMethod(String method, [dynamic arguments]) async {

  2. assert(method != null);

  3. ///send messenge

  4. final dynamic result = await BinaryMessages.send(

  5. name,

  6. codec.encodeMethodCall(MethodCall(method, arguments)),

  7. );

  8. if (result == null)

  9. throw MissingPluginException('No implementation found for method $method on channel $name');

  10. return codec.decodeEnvelope(result);

  11. }

  • 这里截取了 send 方法里关键代码, dart 层最终通过调用了 native 方法  Window_sendPlatformMessage,将序列化后的对象通过 c 层进行发送:

  1. static Future<ByteData> send(String channel, ByteData message) {

  2. final _MessageHandler handler = _mockHandlers[channel];

  3. if (handler != null)

  4. return handler(message);

  5. return _sendPlatformMessage(channel, message);

  6. }

  7. String _sendPlatformMessage(String name,

  8. PlatformMessageResponseCallback callback,

  9. ByteData data) native 'Window_sendPlatformMessage';

  • 我们在 Flutter engine 的 native 代码中可以找到上述 native 方法的对应实现,这里截取关键部分,可以看到最后是交给了 WindowClient的handlePlatformMessage 方法进行实现,我们继续往下跟:

  1. ...

  2. dart_state->window()->client()->HandlePlatformMessage(

  3. fml::MakeRefCounted<PlatformMessage>(name, response));

  4. ...

  • (这里以 Android 举例,iOS 同理)可以看到,在 Android 平台 HandlePlatformMessage 方法中,调用到了 JNI 方法,将 c 层收到的信息向 java层抛:

  1. void PlatformViewAndroid::HandlePlatformMessage(

  2. fml::RefPtr<blink::PlatformMessage> message) {

  3. JNIEnv* env = fml::jni::AttachCurrentThread();

  4. fml::jni::ScopedJavaLocalRef<jobject> view = java_object_.get(env);

  5. auto java_channel = fml::jni::StringToJavaString(env, message->channel());

  6. if (message->hasData()) {

  7. fml::jni::ScopedJavaLocalRef<jbyteArray> message_array(env, env->NewByteArray(message->data().size()));

  8. env->SetByteArrayRegion(

  9. message_array.obj(), 0, message->data().size(),

  10. reinterpret_cast<const jbyte*>(message->data().data()));

  11. message = nullptr;

  12. // This call can re-enter in InvokePlatformMessageXxxResponseCallback.

  13. FlutterViewHandlePlatformMessage(env, view.obj(), java_channel.obj(),

  14. message_array.obj(), response_id);

  15. } else {

  16. message = nullptr;

  17. // This call can re-enter in InvokePlatformMessageXxxResponseCallback.

  18. FlutterViewHandlePlatformMessage(env, view.obj(), java_channel.obj(),

  19. nullptr, response_id);

  20. }

  21. }

  • 看一下 JNI 对应的 java 方法,最终通过 handler.onMessage(),完成了本次 dart 信息的传递。方法中的 handler,就是我们前面提到的 MethodHandler,也是我们插件的 Native 模块注册的 MethodHandler

  1. private void handlePlatformMessage(final String channel, byte[] message, final int replyId) {

  2. this.assertAttached();

  3. BinaryMessageHandler handler = (BinaryMessageHandler)this.mMessageHandlers.get(channel);

  4. if (handler != null) {

  5. try {

  6. ByteBuffer buffer = message == null ? null : ByteBuffer.wrap(message);

  7. handler.onMessage(buffer, new BinaryReply() {

  8. // ...

  9. });

  10. } catch (Exception var6) {

  11. // ...

  12. }

  13. } else {

  14. Log.e("FlutterNativeView", "Uncaught exception in binary message listener", var6);

  15. nativeInvokePlatformMessageEmptyResponseCallback(this.mNativePlatformView, replyId);

  16. }

  17. }

MethodHandler 接口有2个回调参数 MethodCall 、Result

  1. public interface MethodCallHandler {

  2. void onMethodCall(MethodCall var1, MethodChannel.Result var2);

  3. }

其中 MethodCall 就是我们前面说的,由 dart 端传递过来通过序列化、反序列化的对象。

Platform 端可以从 MethodCall 中取出方法名以及参数,然后进行实现。

Result 是一个回调接口,最终的结果会通过另一个序列化、反序列化的过程返回给 dart,过程就跟上述的一致,如果无需任何返回的,可以不用这个参数。

  1. public interface Result {

  2. void success(@Nullable Object var1);

  3. void error(String var1, @Nullable String var2, @Nullable Object var3);

  4. void notImplemented();

  5. }

  • MethodHandler 是在什么时候注册的?

在插件运行的时候,我们会调用插件的 registerWith 方法,在生成 MethodChannel 对象时,同时向 MethodChannel 注册了一个 MethodHandler,MethodHandler 对象跟 MethodChannel 对象是一一对应的。

以上就是整个 Flutter 与 Native 的交互流程,消息的传递是通过跨平台的 c 来实现。以下是 Flutter 到原生的消息传递流程图,Native 到 Flutter 也是类似的。

讲完了通信流程,下面开始正式进入插件开发。


四、创建插件工程

推荐通过命令行来创建,因为通过IDE来创建有时候会卡住,而且会比较慢

  1. flutter create --org com.qima.kdt --template=plugin -i swift -a kotlin flutter_plugin

  • 创建好以后的目录结构如下

  • rootProject

    • lib dart 模块

    • android android 模块

    • ios ios 模块

    • example 示例测试工程可用于插件的调试

    • pubspec.yaml flutter 项目的配置文件

    • ….


4.1 什么是 pubspec.yaml

dart 生态下的包管理配置文件类似 Android 中的 gradle、iOS 中的 Podfile,在这里可以统一管理整个 flutter 工程的 dart 依赖包,以及管理整个插件的发布属性。


4.2 创建过程可能会遇到的问题

  • IDE  一直卡在 creating Flutter Project……

原因:Flutter 工程在创建过程中需要下载需要的插件,因为网络原因导致需要的插件无法下载成功会导致该问题

解决:

  • 切换网络,或者搭一个梯子

  • 通过命令行来创建插件

  • 编译Android模块遇到Invoke-customs are only supported starting with Android O (--min-api 26)

在 app.gradle 中增加

  1. compileOptions {

  2. sourceCompatibility JavaVersion.VERSION_1_8

  3. targetCompatibility JavaVersion.VERSION_1_8

  4. }

创建完插件工程后,分别对原生端与 Flutter 端进行开发


4.3 原生端开发

  • 实现 MethodCallHandler 接口,注册 MethodChannel 对象,MethodChannel 在创建时一定要保证 name 唯一

  • 将 MethodHandler 接口注册到 MethodChannel 中

  • 包装原生端组件,包括一些二方库、三方库,将包好的方法通过 MethodCallHandler 暴露给 Flutter 端


4.4 Flutter 端开发

  • 找到 MethodChannel 对象,通过唯一标识 name,注意(name一定要与原生端注册的一致)

  • 定义 dart 方法,因为要保证方法的执行不产生阻塞,所以推荐用 Future async await .相关的语法见 dart 语法

  • 调用 methodChannel.invokeMothed() 与原生进行通信

以上就完成了整个插件部分的开发,开发完成后,先不急着将插件发布。可以先在本地的 example 中对所开发的插件进行验证,验证无误后,再进行发布


五、插件测试

在 example/lib/main.dart 下调用插件中的方法,然后直接通过命令将工程跑起来查看输出:

  1. flutter run

插件都还没有发布,为什么 example 工程可以直接引用?

看一下 example 目录下的 pubspec.yaml 文件,里面有一句:

  1. xxxxx(插件名):

  2. path: ../

pubspec.yaml 不但可以引用服务器上的插件,也可以引用本地路径下的插件。如此我们可以在插件未发布的情况下,直接在本地的测试工程里对插件进行测试。

    后续的所有 flutter 模块的单独调试,也是同样的模式。开发完 flutter 模块后,直接在 example 工程中引入调试,不必与 host 工程进行耦合,可以提供整体的开发效率。测试没有问题后,在进行插件发布,集成开发。


    六、插件发布

    6.1 私有 Flutter 服务器环境搭建

    Flutter 插件默认是上传到 Flutter 社区的公共仓库中,实际开发中,我们会有很多暂时不想要开源,只供团队内部使用的插件。因此将这些插件发布到 Flutter 社区中明显是不合适的,所以需要搭建一个团队内私有的 flutter 插件管理环境。官方提供了接入文档,这里不展开了。

    • dart 环境配置

    • 服务器搭建

    6.1.1 官方代码结构简要说明

    • example.dart 程序入口,负责各种数据配置,及服务启动

    • shelf_pubserver.dart 定义了当前dart服务支持的所有接口

    • 获取某个插件的信息 /api/packages/

    • 获取某个插件特定版本的信息 /api/packages//versions/

    • 下载插件 /api/packages//versions/.tar.gz

    • 上传插件 /api/packages/versions/new

    • 删除插件 /api/packages//uploaders/

    因为上传的插件文件都是存储在 Linux 服务器上的,并且已经提供以上这些接口,因此后期也可以简单搭建个 flutter web 网站,查看私有服务器上的插件包信息,方便开发使用。

    • 启动服务

    1. dart example/example.dart

    2. -s 是否fetch官方仓库

    3. -h ${ip / domain}

    4. -p 端口

    5. -d 上传上来的插件包在服务器上的存储地址

    完成了私有 flutter 插件管理服务环境后,准备开始插件的上传,首先需要检查本地插件的发布配置信息。


    6.2 完善 pubspec.yaml 文件

    1. name: 插件名称

    2. description: 插件描述

    3. version: 0.0.1 版本号

    4. author: xxxx<xx@xxx.com>

    5. homepage: 项目主页地址

    6. publish_to: 填写私有服务器的地址(如果是发布到flutter pub则不用填写,插件默认是上传到flutter pub)



    6.3 检验是否满足上传条件


    1. flutter packages pub publish --dry-run


    --dry-run 参数表示本次执行会检查插件的配置信息是否有效,插件是否满足上传条件。如果成功的话并不会真正的将插件上传,而是会显示本次要发布插件的信息,并提示成功。一般在插件的正式发布前,建议先执行该命令,避免在上传过程中出现错误

    当插件符合上传条件后,可以开始进行正式发布


    6.4 正式发布

    • 发布至 pub 平台


    1. flutter packages pub publish


    • 发布至私有服务器


    1. flutter packages pub publish --server $服务器地址


    pubspec.yaml 文件中列出的包作者与授权发布该包的人员列表不同。发布某个软件包的第一个版本的人自动成为第一个也是唯一一个有权上传其他版本软件包的人。要允许或禁止其他人上载版本,请使用 pub uploader 命令。

    最终出现如下内容,代表上传成功

    1. ....

    2. |-- local.properties

    3. |-- pubspec.yaml

    4. |-- test

    5. | '-- xxxx.dart

    6. '-- xxxx.iml


    7. Looks great! Are you ready to upload your package (y/n)? y

    8. Uploading...

    9. Successfully uploaded package.


    七、插件引用

    开发上传完成后,就可以在后续的任何 Flutter 模块中,在 pubspec.yaml 中添加依赖进行引用

    pubspec.yaml 更多用法见 pubspec.yaml官方文档

    • pub 仓库插件

    1. #插件名:版本号

    2. flutter_boost: ^0.0.411

    • 私有仓库引用

    1. ${library name}:

    2. hosted:

    3. name: ${library name}

    4. url: xxxxx

    5. version: ^1.0.0

    ok,以上就是完整的 Flutter 插件开发、发布、引用的流程。


    八、有赞路由插件开发实践

    有赞路由插件第一版的开发思路是对开源项目 flutter-boost 做一层包装,然后接入到 flutter 业务中。后期用有赞自己的 flutter 路由组件替换 flutter-boost。

    我们按照上述流程,在 pubspec.yaml 中引入了 flutter-boost 插件,然后进行二次包装。在包装 dart 接口时很顺利,没有遇到什么阻碍。然而在 Native 模块,却一直不能引用到 flutter-boost 中的 native code。不仅仅是 android 如此,iOS 的同学也遇到同样的问题。

    是不是插件引用插件,宿主插件就无法引用接入插件的 native 代码呢?我们又试了试,创建了一个 flutter module 以及一个一个 flutter application 来接入 flutter-boost 插件,看看能否引用到 flutter-boost 中的原生代码,最后发现都可以引用,唯独 flutter plugin 无法引用。

    看来应该是插件工程的特殊性导致。于是,我们开始对比插件工程与其他工程的区别,最终发现,module 工程以及 application 工程比插件工程多了一个 include_flutter.groovy 文件

    1. rootProject.name = 'android_generated'

    2. setBinding(new Binding([gradle: this]))

    3. evaluate(new File('include_flutter.groovy'))

    iOS 多了一个 podhelper.rb

    1. flutter_application_path = '../my_flutter/'

    2. eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)

    3. end

    看一看这个文件到底做了什么,以 android 举例:

    1. def scriptFile = getClass().protectionDomain.codeSource.location.toURI()

    2. def flutterProjectRoot = new File(scriptFile).parentFile.parentFile


    3. gradle.include ':flutter'

    4. //获取项目的根目录

    5. gradle.project(':flutter').projectDir = new File(flutterProjectRoot, '.android/Flutter')


    6. def plugins = new Properties()

    7. //在根目录下找到一个叫 .flutter-plugins的文件,然后逐行读入;

    8. def pluginsFile = new File(flutterProjectRoot, '.flutter-plugins')

    9. if (pluginsFile.exists()) {

    10. pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }

    11. }

    12. //.flutter-plugins的内容如下,存放了对应原生模块的名字以及路径

    13. flutter_boost=/Users/xxx/Downloads/flutter/.pub-cache/hosted/pub.flutter-io.cn/flutter_boost-0.0.415/

    14. xservice_kit=/Users/xxx/Downloads/flutter/.pub-cache/hosted/pub.flutter-io.cn/xservice_kit-0.0.29/


    15. //如果是android工程的,则通过gradle引用到工程中,完成对插件原生lib的引用

    16. plugins.each { name, path ->

    17. def pluginDirectory = flutterProjectRoot.toPath().resolve(path).resolve('android').toFile()

    18. gradle.include ":$name"

    19. gradle.project(":$name").projectDir = pluginDirectory

    20. }

    21. ...

    ok,到这里就很清楚了。一个 dart 插件不仅仅提供的是 dart 层的功能,其原生层的功能也可以直接给宿主的原生层去引用。dart 插件在完成打包后,其原生部分的代码也会被打成一个依赖包。插件工程默认是不能够引用三方插件的原生依赖包,只能引用到 dart 部分。当然如果想要引用到三方插件的 native 功能,需要自己写一个类似于 flutter module 工程自动创建的依赖包收集脚本。


    九、总结

    目前 Flutter 生态越来越完善,后续不可避免的会越来越多的与 Flutter 进行交互。为了更好的与 Native 项目的兼容,减少原生工程与 Flutter 业务的耦合,Flutter 插件化是一个不错的选择。目前有赞 Flutter 插件化项目已经封装了网络、埋点、路由等基础插件,后续将在线上应用进行接入尝试,希望能给正在探索 Flutter 的同学一些灵感。


    相关阅读:

    StandardMessageCodec:https://api.flutter.dev/flutter/services/StandardMessageCodec-class.html


    扩展阅读

    1. 有赞移动 App 一键切换网关实践

    2. 有赞零售小票打印图片二值化方案

    3. 有赞 Android 崩溃保护的探索及实践

    4. 有赞零售小票打印跨平台解决方案

    5. 有赞移动 iOS 组件化(模块化)架构设计实践

    -The End-

    Vol.203





    有赞技术团队

    为 442 万商家,150 个行业,330 亿电商交易额

    提供技术支持


    微商城|零售|美业 | 教育


    微信公众号:有赞coder    微博:@有赞技术

    技术博客:tech.youzan.com





    The bigger the dream, 

    the more important the team.

    : . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存