有赞Flutter插件开发与发布

一、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

1.什么是MethodChannel

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

  • BasicMessageChannel:用于传递字符串和半结构化的信息
  • MethodChannel:用于传递方法调用(method invocation)
  • EventChannel: 用于数据流(event streams)的通信

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

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

- String name    

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

- 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返回。

- MethodCodec codec  

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

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

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

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

2.Flutter与原生通信整体流程

  • 首先从dart层调用_channel.invokeMethod("方法名",参数),invoke方法会将传入的方法名与参数封装成MethodCall对象,然后通过MethodCodec对MethodCall对象进行编码,形成二进制格式。然后通过BinaryMessenger的send方法,将二进制格式的数据进行发送,我们继续看一下send方法是如何实现的
Future<dynamic> invokeMethod(String method, [dynamic arguments]) async {  
        assert(method != null);
              ///send messenge
        final dynamic result = await BinaryMessages.send( 
          name,
          codec.encodeMethodCall(MethodCall(method, arguments)),
        );
        if (result == null)
          throw MissingPluginException('No implementation found for method $method on channel $name');
        return codec.decodeEnvelope(result);
    }
  • 这里截取了send方法里关键代码, dart层最终通过调用了native方法 Window_sendPlatformMessage,将序列化后的对象通过c层进行发送
static Future<ByteData> send(String channel, ByteData message) {  
    final _MessageHandler handler = _mockHandlers[channel];
    if (handler != null) 
      return handler(message);
    return _sendPlatformMessage(channel, message);
}
String _sendPlatformMessage(String name,  
                              PlatformMessageResponseCallback callback,
                              ByteData data) native 'Window_sendPlatformMessage';
  • 我们在Flutter engine的native代码中可以找到上述native方法的对应实现,这里截取关键部分,可以看到最后是交给了WindowClient的handlePlatformMessage方法进行实现,我们继续往下跟
...
dart_state->window()->client()->HandlePlatformMessage(  
        fml::MakeRefCounted<PlatformMessage>(name, response));
...
  • (这里以Android举例,iOS同理)可以看到,在Android平台HandlePlatformMessage方法中,调用到了JNI方法,将c层收到的信息向java层抛
void PlatformViewAndroid::HandlePlatformMessage(  
    fml::RefPtr<blink::PlatformMessage> message) {
  JNIEnv* env = fml::jni::AttachCurrentThread();
  fml::jni::ScopedJavaLocalRef<jobject> view = java_object_.get(env);
  auto java_channel = fml::jni::StringToJavaString(env, message->channel()); 
  if (message->hasData()) {
    fml::jni::ScopedJavaLocalRef<jbyteArray> message_array(env, env->NewByteArray(message->data().size()));
    env->SetByteArrayRegion(
        message_array.obj(), 0, message->data().size(),
        reinterpret_cast<const jbyte*>(message->data().data()));
    message = nullptr;
    // This call can re-enter in InvokePlatformMessageXxxResponseCallback.
    FlutterViewHandlePlatformMessage(env, view.obj(), java_channel.obj(),
                                     message_array.obj(), response_id);  
  } else {
    message = nullptr;
    // This call can re-enter in InvokePlatformMessageXxxResponseCallback.
    FlutterViewHandlePlatformMessage(env, view.obj(), java_channel.obj(),
                                     nullptr, response_id);           
  }
}
  • 看一下JNI对应的java方法,最终通过handler.onMessage(),完成了本次dart信息的传递。方法中的handler,就是我们前面提到的MethodHandler,也是我们插件的Native模块注册的MethodHandler
  private void handlePlatformMessage(final String channel, byte[] message, final int replyId) {
        this.assertAttached();
        BinaryMessageHandler handler = (BinaryMessageHandler)this.mMessageHandlers.get(channel); 
        if (handler != null) {
            try {
                ByteBuffer buffer = message == null ? null : ByteBuffer.wrap(message);
                handler.onMessage(buffer, new BinaryReply() {
                    // ...
                });
            } catch (Exception var6) {
                // ...
            }
        } else {
            Log.e("FlutterNativeView", "Uncaught exception in binary message listener", var6);
            nativeInvokePlatformMessageEmptyResponseCallback(this.mNativePlatformView, replyId);
        }
    }

MethodHandler 接口有2个回调参数 MethodCallResult

public interface MethodCallHandler {  
        void onMethodCall(MethodCall var1, MethodChannel.Result var2);
    }

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

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

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

public interface Result {  
        void success(@Nullable Object var1);
        void error(String var1, @Nullable String var2, @Nullable Object var3);
        void notImplemented();
    }
  • MethodHandler 是在什么时候注册的?

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

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

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

四、创建插件工程

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

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项目的配置文件
    • ….

1.什么是pubspec.yaml

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

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

  • IDE 一直卡在 creating Flutter Project……

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

解决:

  • 切换网络,或者搭一个梯子
  • 通过命令行来创建插件
  • 编译Android模块遇到Invoke-customs are only supported starting with Android O (--min-api 26)

在app.gradle中增加

groovy compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 }

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

3.原生端开发

  • 实现MethodCallHandler接口,注册MethodChannel对象,MethodChannel在创建时一定要保证name唯一
  • 将MethodHandler接口注册到MethodChannel中
  • 包装原生端组件,包括一些二方库、三方库,将包好的方法通过MethodCallHandler暴露给Flutter端

4.Flutter端开发

  • 找到MethodChannel对象,通过唯一标识name,注意(name一定要与原生端注册的一致)
  • 定义dart方法,因为要保证方法的执行不产生阻塞,所以推荐用Future async await .相关的语法见dart语法
  • 调用methodChannel.invokeMothed()与原生进行通信

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

五、插件测试

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

flutter run  

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

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

    yaml xxxxx(插件名): path: ../

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

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

六、插件发布

1.私有Flutter服务器环境搭建

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

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网站,查看私有服务器上的插件包信息,方便开发使用。

  • 启动服务
dart example/example.dart  
-s 是否fetch官方仓库 
-h ${ip / domain} 
-p 端口 
-d 上传上来的插件包在服务器上的存储地址

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

2.完善pubspec.yaml文件

name: 插件名称  
description: 插件描述  
version: 0.0.1 版本号  
author: xxxx<xx@xxx.com>  
homepage: 项目主页地址  
publish_to: 填写私有服务器的地址(如果是发布到flutter pub则不用填写,插件默认是上传到flutter pub)  

3.检验是否满足上传条件

flutter packages pub publish --dry-run  

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

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

4.正式发布

  • 发布至pub平台
flutter packages pub publish  
  • 发布至私有服务器
flutter packages pub publish --server $服务器地址  

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

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

....
|-- local.properties
|-- pubspec.yaml
|-- test
|   '-- xxxx.dart
'-- xxxx.iml

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

七、插件引用

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

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

  • pub仓库插件
#插件名:版本号
flutter_boost: ^0.0.411  
  • 私有仓库引用
${library name}:
     hosted:
       name: ${library name}
        url:  xxxxx
    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文件

rootProject.name = 'android_generated'  
setBinding(new Binding([gradle: this]))  
evaluate(new File('include_flutter.groovy'))  

iOS多了一个 podhelper.rb

flutter_application_path = '../my_flutter/'  
eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)  
end  

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

def scriptFile = getClass().protectionDomain.codeSource.location.toURI()  
def flutterProjectRoot = new File(scriptFile).parentFile.parentFile

gradle.include ':flutter'  
//获取项目的根目录
gradle.project(':flutter').projectDir = new File(flutterProjectRoot, '.android/Flutter')

def plugins = new Properties()  
//在根目录下找到一个叫 .flutter-plugins的文件,然后逐行读入;
def pluginsFile = new File(flutterProjectRoot, '.flutter-plugins')  
if (pluginsFile.exists()) {  
    pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}
//.flutter-plugins的内容如下,存放了对应原生模块的名字以及路径
flutter_boost=/Users/xxx/Downloads/flutter/.pub-cache/hosted/pub.flutter-io.cn/flutter_boost-0.0.415/  
xservice_kit=/Users/xxx/Downloads/flutter/.pub-cache/hosted/pub.flutter-io.cn/xservice_kit-0.0.29/

//如果是android工程的,则通过gradle引用到工程中,完成对插件原生lib的引用
plugins.each { name, path ->  
    def pluginDirectory = flutterProjectRoot.toPath().resolve(path).resolve('android').toFile()
    gradle.include ":$name"
    gradle.project(":$name").projectDir = pluginDirectory
}
...

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

九、总结

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

欢迎关注我们的公众号