Pigeon- Flutter多端接口一致性以及规范化管理实践

2020-10-26 11:40:11 浏览数 (1)

导语: 跨端开发中,经常会遇到插件,接口管理上的问题。了解完本文,你将会了解Flutter是如何通过Pigeon去解决plugin中多端开发难以管理的问题。

demo源码地址 https://github.com/linpenghui958/flutterPigeonDemo

warning:目前Pigeon还是prerelease版本,所以可能会有breaking change。下文以0.1.7版本为例。

为何需要Pigeon

在hybird开发中,前端需要native能力,需要native双端开发提供接口。这种情况下就如何规范命名,参数等就成了一个问题,如果单独维护一份协议文件,三端依照协议文件进行开发,很容易出现协议更改后,没有及时同步,又或者在实际开发过程没有按照规范,可能导致各种意外情况。在Flutter插件包的开发中,因为涉及到native双端代码实现能力,dart侧暴露统一的接口给使用者,也会出现同样的问题,这里Flutter官方推荐使用Pigeon进行插件管理。

Pigeon的作用

Flutter官方提供的Pigeon插件,通过dart入口,生成双端通用的模板代码,Native部分只需通过重写模板内的接口,无需关心methodChannel部分的具体实现,入参,出参也均通过生成的模板代码进行约束。假设接口新增,或者参数修改,只需要在dart侧更新协议文件,生成双端模板,即可达到同步更新。

以Flutter官方plugin中的video_player为例,接入pigeon后最终效果如下

可以看到接入pigeon后整体代码简洁了不少,而且规范了类型定义。接下来我们看一下如何从零接入Pigeon。

接入Pigeon

先看一下pub.dev上Pigeon的介绍,Pigeon只会生成Flutter与native平台通信所需的模板代码,没有其他运行时的要求,所以也不用担心Pigeon版本不同而导致的冲突。(这里的确不同版本使用起来差异较大,笔者这里接入的时候0.1.7与0.1.10,pigeon默认导出和使用都不相同)

创建package

ps:如果接入已有plugin库,可以跳过此部分,直接看接入部分。

执行生成插件包命令:

代码语言:javascript复制
flutter create --org com.exmple --template plugin flutterPigeonDemo

要创建插件包,使用--template=plugin参数执行flutter create

  • lib/flutter_pigeon_demo.dart
    • 插件包的dart api
  • android/src/main/kotlin/com/example/flutter_pigeon_demo/FlutterPigeonPlugin.kt
    • 插件包Android部分的实现
  • ios/Classes/FlutterPigeonDemoPlugin.m
    • 插件包ios部分的实现。
  • example/
    • 使用该插件的flutterdemo。

这里常规通过methodChannel实现plugin的部分省略,主要讲解一下如何接入pigeon插件。

添加依赖

首先在pubspec.yaml中添加依赖

代码语言:javascript复制
dev_dependencies:
  flutter_test:
    sdk: flutter
  pigeon:
    version: 0.1.7

然后按照官方的要求添加一个pigeons目录,这里我们放dart侧的入口文件,内容为接口、参数、返回值的定义,后面通过pigeon的命令,生产native端代码。

这里以pigeons/pigeonDemoMessage.dart为例

代码语言:javascript复制
import 'package:pigeon/pigeon.dart';

class DemoReply {
  String result;
}

class DemoRequest {
  String methodName;
}

// 需要实现的api
@HostApi()
abstract class PigeonDemoApi {
  DemoReply getMessage(DemoRequest params);
}

// 输出配置
void configurePigeon(PigeonOptions opts) {
  opts.dartOut = './lib/PigeonDemoMessage.dart';
  opts.objcHeaderOut = 'ios/Classes/PigeonDemoMessage.h';
  opts.objcSourceOut = 'ios/Classes/PigeonDemoMessage.m';
  opts.objcOptions.prefix = 'FLT';
  opts.javaOut =
  'android/src/main/kotlin/com/example/flutter_pigeon_demo/PigeonDemoMessage.java';
  opts.javaOptions.package = 'package com.example.flutter_pigeon_demo';
}

pigeonDemoMessage.dart文件中定义了请求参数类型、返回值类型、通信的接口以及pigeon输出的配置。

这里@HostApi()标注了通信对象和接口的定义,后续需要在native侧注册该对象,在Dart侧通过该对象的实例来调用接口。

configurePigeon为执行pigeon生产双端模板代码的输出配置。

  • dartOut为dart侧输出位置
  • objcHeaderOut、objcSourceOut为iOS侧输出位置
  • prefix为插件默认的前缀
  • javaOut、javaOptions.package为Android侧输出位置和包名

之后我们只需要执行如下命令,就可以生成对应的代码到指定目录中。

代码语言:javascript复制
flutter pub run pigeon --input pigeons/pigeonDemoMessage.dart
  • --input为我们的输入文件

生成模板代码后的项目目录如下

项目目录

我们在Plugin库中只需要管理标红的dart文件,其余标绿的则为通过Pigeon自动生成的模板代码。

我们接下来看一下双端如何使用Pigeon生成的模板文件。

Android端接入

这里Pigeon生产的PigeonDemoMessage.java文件中,可以看到入参和出参的定义DemoRequest、DemoReply,而PigeonDemoApi接口,后面需要在plugin中继承PigeonDemoApi并实现对应的方法,其中setup函数用来注册对应方法所需的methodChannel。

ps: 这里生成的PigeonDemoApi部分,setup使用了接口中静态方法的默认实现,这里需要api level 24才能支持,这里需要注意一下。 考虑到兼容性问题,可以将setup的定义转移到plugin中。

首先需要在plugin文件中引入生成的PigeonDemoMessage中的接口和类。FlutterPigeonDemoPlugin先要继承PigeonDemoApi。然后在onAttachedToEngine中进行PigeonDemoApi的setup注册。并在plugin中重写PigeonDemoApi中定义的getMessage方法

伪代码部分

代码语言:javascript复制
// ... 省略其他引入
import com.example.flutter_pigeon_demo.PigeonDemoMessage.*

// 继承PigeonDemoApi
public class FlutterPigeonDemoPlugin: FlutterPlugin, MethodCallHandler, PigeonDemoApi {

 //...
 override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
     channel = MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), "flutter_pigeon_demo")
     channel.setMethodCallHandler(this);
     // pigeon生成的api进行初始化
     PigeonDemoApi.setup(flutterPluginBinding.binaryMessenger, this);
   }
   
   // 重写PigeonDemoApi中的getMessage方法
   override fun getMessage(arg: DemoRequest): DemoReply {
      var reply = DemoReply();
      reply.result = "pigeon demo result";
      return reply;
 }
}
iOS接入

ios相关目录下的PigeonDemoMessage.m也有FLTDemoReply、FLTDemoRequest、FLTPigeonDemoApiSetup的实现。首先需要在plugin中引入头文件PigeonDemoMessage.h,需要在registerWithRegistrar中注册setup函数,并实现getMessage方法。

代码语言:javascript复制
#import "FlutterPigeonDemoPlugin.h"
#import "PigeonDemoMessage.h"

@implementation FlutterPigeonDemoPlugin
  (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    FlutterPigeonDemoPlugin* instance = [[FlutterPigeonDemoPlugin alloc] init];
    // 注册api
    FLTPigeonDemoApiSetup(registrar.messenger, instance);
}

// 重写getMessage方法
- (FLTDemoReply*)getMessage:(FLTDemoRequest*)input error:(FlutterError**)error {
    FLTDemoReply* reply = [[FLTDemoReply alloc] init];
    reply.result = @"pigeon demo result";
    return reply;
}

@end
Dart侧使用

最终在dart侧如何调用呢 首先看一下lib下Pigeon生成的dart文件PigeonDemoMessage.dartDemoReply、DemoRequest用来实例化入参和出参 然后通过PigeonDemoApi的实例去调用方法。

代码语言:javascript复制
import 'dart:async';

import 'package:flutter/services.dart';
import 'PigeonDemoMessage.dart';

class FlutterPigeonDemo {
  static const MethodChannel _channel =
      const MethodChannel('flutter_pigeon_demo');

  static Future<String> get platformVersion async {
    final String version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }

  static Future<DemoReply> testPigeon() async {
    // 初始化请求参数
    DemoRequest requestParams = DemoRequest()..methodName = 'requestMessage';
    // 通过PigeonDemoApi实例去调用方法
    PigeonDemoApi api = PigeonDemoApi();
    DemoReply reply = await api.getMessage(requestParams);
    return reply;
  }

}

至此,Pigeon的接入就已经完成了。

接入Pigeon后的效果

本文demo代码较为简单,接入Pigeon前后的差异并不明显,我们可以看下一Flutter官方plugin中的video_player接入前后的对比。

左侧为接入Pigeon前,处理逻辑都在onMethodCall中,不同的方法通过传入的call.method来区分,代码复杂后很容易变成面条式代码,而且返回的参数也没有约定,有较多不确定因素。

右侧接入Pigeon后,只需要重写对应的方法,逻辑分离,直接通过函数名区分,只需要关心具体的业务逻辑即可。

而在dart的调用侧,接入前都是通过invokeMethod调用,传入的参数map内也是dynamic类型的值。接入后直接调用api的实例对象上的方法,并且通过Pigeon生成的模板代码,直接实例化参数对象。

总结:通过Pigeon来管理Flutter的plugin库,只需要在dart侧维护一份协议即可,即使在多端协同开发的情况下,也能达到约束和规范的作用。

在实现原生插件时我们可以省去很多重复代码,并且不需要关心具体methodchannel的name,也避免了常规情况下,可能出现的面条式代码,只需通过重写pigeon暴露的方法就可以完成双端的通信。而dart侧也只需要通过模板暴露的实例对象来调用接口方法。

源码分析

使用的时候,我们只知道运行命令flutter pub run pigeon --input xxx就可以生成双端模板代码,接下来我们深入了解一下,这其中Pigeon到底做了什么。

首先,看一下plugin库默认导出的pigeon_lib.dart入口文件,这里主要有几个定义PigeonOptions、ParseResults、Pigeon。

  • PigeonOptions,是执行命令生成模板时的选项。
  • ParseResults,表示解析的结果集合包含了AST对象root,和解析过程产生的错误信息集合erros。
  • Pigeon,是实际进行代码生成的类。

其中Pigeon的入口为run方法,这里进行了模板代码的生成。

run函数的入参是一个String类型的List,这里对应的是通过命令行输入的,PigeonOptions的选项。

函数开始先实例化了pigeon对象,并对传入的options进行解析生成编译所需的PigeonOptions。

这里提供了两种方式,一种是通过命令直接传入,一种是通过入口文件内configurePigeon的定义传入。

代码语言:javascript复制
// Pigeon实例初始化
final Pigeon pigeon = Pigeon.setup();
// 解析命令行穿传入的参数
final PigeonOptions options = Pigeon.parseArgs(args);
// 解析入口文件内的参数
_executeConfigurePigeon(options);

//校验input(输入文件)或者dartOut(dart输出路径)是否为空
if (options.input == null || options.dartOut == null) {
  print(usage);
  return 0;
}

接下来会对objcHeaderOut、javaOut为空的情况取默认值处理。

代码语言:javascript复制
// 解析apis
final ParseResults parseResults = pigeon.parse(apis);
for (Error err in parseResults.errors) {
  errors.add(Error(message: err.message, filename: options.input));
}

这里parse解析生成的parseResults对象,最终用parseResults中的ast对象root来生成多端模板代码。

这里首先将需要实现的api类和参数类进行了区分。(ps:这里_isApi中便是通过dart入口中@HostApi注解进行区分)

代码语言:javascript复制
for (Type type in types) {
  final ClassMirror classMirror = reflectClass(type);
  if (_isApi(classMirror)) {
    apis.add(classMirror);
  } else {
    classes.add(classMirror);
  }
}

然后对参数类型进行区分,并给root对象添加了classes和apis属性。

这里classes对应模板中参数的类。而apis则对应模板中含有函数的方法类。

代码语言:javascript复制
root.classes =
      _unique(_parseClassMirrors(classes), (Class x) => x.name).toList();

  root.apis = <Api>[];
  for (ClassMirror apiMirror in apis) {
    final List<Method> functions = <Method>[];
    for (DeclarationMirror declaration in apiMirror.declarations.values) {
      if (declaration is MethodMirror && !declaration.isConstructor) { 
        // 省略处理过程
      }
    }
    final HostApi hostApi = _getHostApi(apiMirror);
    root.apis.add(Api(
        name: MirrorSystem.getName(apiMirror.simpleName),
        location: hostApi != null ? ApiLocation.host : ApiLocation.flutter,
        methods: functions,
        dartHostTestHandler: hostApi?.dartHostTestHandler));
  }

最后根据解析后的root对象,来生成对应各端的代码。

代码语言:javascript复制
if (options.dartOut != null) {
  await _runGenerator(
    options.dartOut,
    (StringSink sink) =>
    generateDart(options.dartOptions, parseResults.root, sink));
}
if (options.objcHeaderOut != null) {
  await _runGenerator(
    options.objcHeaderOut,
    (StringSink sink) => generateObjcHeader(
      options.objcOptions, parseResults.root, sink));
}
if (options.objcSourceOut != null) {
  await _runGenerator(
    options.objcSourceOut,
    (StringSink sink) => generateObjcSource(
      options.objcOptions, parseResults.root, sink));
}
if (options.javaOut != null) {
  await _runGenerator(
    options.javaOut,
    (StringSink sink) =>
    generateJava(options.javaOptions, parseResults.root, sink));
}

这里每个具体生成输出的函数就比较简单,这里以dart端的generateDart函数为例,通过root对象,遍历其中的class和api来生成对应的模板代码,这里模板都是已经预先定义好的。如果项目本身有定制化输出模板的需求,只需要修改对应的部分就好了。

代码语言:javascript复制
void generateDart(DartOptions opt, Root root, StringSink sink) {
  final List<String> customClassNames =
      root.classes.map((Class x) => x.name).toList();
  final Indent indent = Indent(sink);
  indent.writeln('// $generatedCodeWarning');
  indent.writeln('// $seeAlsoWarning');
  indent.writeln(
      '// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import');
  indent.writeln('// @dart = ${opt.isNullSafe ? '2.10' : '2.8'}');
  indent.writeln('import 'dart:async';');
  indent.writeln('import 'package:flutter/services.dart';');
  indent.writeln(
      'import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List;');
  indent.writeln('');

  final String nullBang = opt.isNullSafe ? '!' : '';
  // 遍历输出参数类
  for (Class klass in root.classes) {
    sink.write('class ${klass.name} ');
    indent.scoped('{', '}', () {
      for (Field field in klass.fields) {
        final String datatype =
            opt.isNullSafe ? '${field.dataType}?' : field.dataType;
        indent.writeln('$datatype ${field.name};');
      }
      indent.writeln('// ignore: unused_element');
      indent.write('Map<dynamic, dynamic> _toMap() ');
      indent.scoped('{', '}', () {
        indent.writeln(
            'final Map<dynamic, dynamic> pigeonMap = <dynamic, dynamic>{};');
        for (Field field in klass.fields) {
          indent.write('pigeonMap['${field.name}'] = ');
          if (customClassNames.contains(field.dataType)) {
            indent.addln(
                '${field.name} == null ? null : ${field.name}$nullBang._toMap();');
          } else {
            indent.addln('${field.name};');
          }
        }
        indent.writeln('return pigeonMap;');
      });
      indent.writeln('// ignore: unused_element');
      indent.write(
          'static ${klass.name} _fromMap(Map<dynamic, dynamic> pigeonMap) ');
      indent.scoped('{', '}', () {
        indent.writeln('final ${klass.name} result = ${klass.name}();');
        for (Field field in klass.fields) {
          indent.write('result.${field.name} = ');
          if (customClassNames.contains(field.dataType)) {
            indent.addln(
                'pigeonMap['${field.name}'] != null ? ${field.dataType}._fromMap(pigeonMap['${field.name}']) : null;');
          } else {
            indent.addln('pigeonMap['${field.name}'];');
          }
        }
        indent.writeln('return result;');
      });
    });
    indent.writeln('');
  }
  // 省略apis接口部分的输出
}

腾讯音乐QQ音乐/全民K歌招聘客户端、web前端、后台开发,点击查看原文投递简历!或邮箱联系: godjliu@tencent.com

0 人点赞