Skip to main content

实现一个简单的 Flutter 插件

· 13 min read

记录一次 Flutter 插件的实现, 目前仅实现了 iOS 端.

实现​

插件调用原生 API, 需要 Pods 支持, 开发流程如下.

整体流程​

  1. 使用 IDE 或命令行新建工程:

    flutter create --org com.example --template=plugin 包名

    若想指定开发语言, 则需要:

    flutter create --org com.example --template=plugin -i swift -a kotlin 包名

    插件包 API 在 lib 中实现.

  2. 针对 iOS 平台, 需要首先在 example 中执行如下命令:

    flutter build ios --no-codesign
  3. 通过平台通道来实现 dart 和平台间的交互.

    Flutter 作为 UI 层和平台进行交互时, 使用的是 MethodChannel 机制.

    其实就是一套消息发送机制, 消息的发送和接收都是异步的, 从而保证不会阻塞 UI 线程.

    在 Flutter 端, 使用 MethodChannel 向平台发送消息.

    在平台端, 通过 MethodChannel(Android) 或 FlutterMethodChannel(iOS) 来接收消息并回传结果.

    并且平台端也可以反向地和 Flutter 端进行联系, 从而实现一套 Flutter 作为后端, 而平台作为前端的结构.(具体做法可以参考 quick actions 这个插件.)

  4. 具体平台上数据类型支持可以参考这个链接.

  5. Flutter 和平台间交互的实现可以看这个链接.

  6. 包支持三种方式的使用:

    • 发布到 Dart 包网站再通过包管理来引入使用.

    • 相对路径或绝对路径的方式:

      dependencies:
      plugin1:
      path: ../plugin1/
    • git URL 的方式:

      dependencies:
      package1:
      git:
      url: git://github.com/flutter/packages.git
      path: packages/package1

实践​

  1. 建立一个 flutter_plugin_demo 的工程, 这里直接使用 IDE, 因为还会进行依赖的获取, 故第一次耗时较长.

    可以看到, 在 lib 中已经生成了部分可供使用的代码了, 其中使用 MethodChannel 类来实例化了一个消息通道对象, 通过这个对象的 invokeMethod 方法就可以向平台端发送消息:

    final String str = await _channel.invokeMethod('消息名称', [1, 2, 'a']);

    其中第一个参数是消息名称.

    另外消息通道需要用到编解码器(codec), 默认情况下使用的是标准编解码器(另外还提供了一个 JSON 形式的编解码器, 可以将消息名和参数对应解析, 估计是用在网页端的).

  2. 定义插件中的 Flutter 层 API: 比如要提供平台上的电量信息.

    • 设置消息通道名为: top.rayy.demos/flutter_plugin_demo, 这样的写法只是一种格式约定, 并没有实际功能上的含义, 接收端初始化消息通道时使用相同字符串即可.

      class FlutterPluginDemoA {
      static const MethodChannel _channel =
      const MethodChannel('top.rayy.demos/flutter_plugin_demo');
      }
    • 设置异步 API, 向平台端发送消息以获取电量信息:

      static Future<String> getBatteryLevel() async {
      try {
      int value = await _channel.invokeMethod('getBatteryLevel');
      return '$value';
      } catch (e) {
      return 'unknown';
      }
      }

      发送消息时有三种可能的情况:

      1. 成功收到结果.
      2. 收到平台异常: PlatformException
      3. 收到消息未实现(对应平台没有实现这个消息的处理)异常: MissingPluginException

      故需要使用 try-catch 来包裹消息发送的调用.

  3. 在特定平台上实现:

    和普通的消息通道使用不同, 插件实现时, 在 iOS 端对应的处理类上实现了 FlutterPlugin 协议, 故不需要显式实例化消息通道, 而是在协议规定的类方法 registerWithRegistrar 中处理消息通道的实例化, 并且在 对象方法 handleMethodCall 中处理消息接收和结果发送.

    上述两个协议方法实现如下所示:

    + (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    FlutterMethodChannel* channel = [FlutterMethodChannel
    methodChannelWithName:@"top.rayy.demos/flutter_plugin_demo"
    binaryMessenger:[registrar messenger]];
    FlutterPluginDemo_aPlugin* instance = [[FlutterPluginDemo_aPlugin alloc] init];
    [registrar addMethodCallDelegate:instance channel:channel];
    }

    - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
    } else if ([@"getBatteryLevel" isEqualToString:call.method]) {
    // TODO: 消息具体的处理代码.
    } else {
    result(FlutterMethodNotImplemented);
    }
    }

    在 Xcode 中保证编译正常后继续.

  4. 实现具体处理代码:

    - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
    } else if ([@"getBatteryLevel" isEqualToString:call.method]) {
    int level = [self batteryLevel];
    result([NSNumber numberWithInt:level]);
    } else {
    result(FlutterMethodNotImplemented);
    }
    }

    - (int)batteryLevel {
    UIDevice *device = UIDevice.currentDevice;
    device.batteryMonitoringEnabled = YES;
    if (device.batteryState == UIDeviceBatteryStateUnknown) {
    return -1;
    } else {
    return (int)(device.batteryLevel * 100);
    }
    }
  5. 在 example 中进行使用测试:

    因为要从异步转到同步, 在 Flutter 中利用了一个技巧, 即在某个方法中异步获取到值后进行 setState 操作来更新对应属性, 从而更新界面.

    Future<void> initPlatformState() async {
    String batteryLevel;
    try {
    batteryLevel = await FlutterPluginDemoA.getBatteryLevel();
    } on PlatformException {
    // TODO: 异常处理
    }

    // 判断当前状态对象是否仍然和 Widget 关联, 如果没关联的话, setState 会崩溃.
    if (!mounted) return;

    setState( () {
    _batteryLevel = batteryLevel;
    });
    }

    剩下的就是尝试在界面上展示出来了.

  6. 使用 git 提供给其他 dart 工程使用:

    • 上传到一个可用的 git 仓库, 而后,

    • 尝试:

      dependencies:
      package1:
      git:
      # 替换为库的 URL
      url: git://github.com/flutter/packages.git
      # 如果有多个包, 需要替换为具体路径(比如 Flutter 团队提供的就是若干插件在一块的)
      # path: packages/package1

      这里上传到的是 github 上面.

      证明可以直接使用.

在插件中使用 Swift​

严格来说这个不应算到 Flutter 这里, 而是 iOS 端的一个实践而已.

只需要在 OC 实现文件中引用 swift 生成的头文件就可以了...

不过注意, 引入头文件的时候需要使用如下语法:

#import <模块名/模块名-Swift.h>

// 比如

#import <plugin_swift_demo/plugin_swift_demo-Swift.h>

另外 Swift 类需要是 public 的且标记为 @objc 才能在 OC 中访问到, 由于标记为 @objc, 故还有一个隐性要求就是 class, 且 class 必须继承自 NSObject.

但是要注意, 默认情况下, Flutter 插件生成命令生成的是静态库类型的 pod, 而使用 swift 的话默认是动态库类型的 Framework.

且暂时没有找到在 OC 静态库中混编 OC 和 Swift 的方法.

故目前如果想要在插件中自己编写 swift 代码的话, 最好创建 Swift 版本的插件, 而如果只用 OC 的话, 则可以通过 Pod 引入三方 Swift 库作为这个库的平台端依赖使用.

插件中的 Pod 库分析​

首先是定义的 Podfile 文件, 看懂这个后再继续. 这个文件中主要是在为 iOS 平台 example 提供 Flutter 依赖和自定义 Pod 库依赖. pod 可以认出这个 pod 是开发 pod, 使用下面的代码放到 Podfile 中即可将文件放到顶层, 方便开发:

# Uncomment this line to define a global platform for your project
# platform :ios, '9.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}

if ENV['FLUTTER_FRAMEWORK_DIR'] == nil
abort('Please set FLUTTER_FRAMEWORK_DIR to the directory containing Flutter.framework')
end

target 'Runner' do
use_frameworks!

# Pods for Runner

# Flutter Pods
pod 'Flutter', :path => ENV['FLUTTER_FRAMEWORK_DIR']

# 在上层目录找到当前插件的绝对路径和插件名称, 根据这两个信息来提供 Pod 库的引入规则.
# 这样开发库的所有文件可以在根目录下显示, 且仍然可以避免使用绝对路径.
if File.exists? '../.flutter-plugins'
flutter_root = File.expand_path('..')
File.foreach('../.flutter-plugins') { |line|
plugin = line.split(pattern='=')
if plugin.length == 2
name = plugin[0].strip()
path = plugin[1].strip()
resolved_path = File.expand_path("#{path}/ios", flutter_root)
pod name, :path => resolved_path
else
puts "Invalid plugin specification: #{line}"
end
}
end
end

post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_BITCODE'] = 'NO'
end
end
end

总结​

插件开发的时候最好使用 swift 模板, 方便起步, 另外替换上述 Podfile 方便开发. 不过有个蛋疼的地方就是移动文件的时候无法放到指定的文件夹下.

另外可以看看 CocoaPod 的文档, 因为其他相关的问题都是由于 CocoaPod 上的配置问题引起的.

Flutter和平台间的交互​

交互的时候在 Flutter 端是这个样的:

  • 用 method channel 发送消息
  • 用 event channel 接收消息

平台端正好相反:

  • 使用 method channel 接收消息
  • 使用 eventSink 发送消息

从平台向 Flutter 发送/接收消息的流程如下所示:

之前已经搞了从 Flutter 向平台发送消息的流程, 现在来看从平台往 Flutter 发送消息的流程

Flutter 端

  1. 建立 EventChannel:

    static const EventChannel eventChannel =
    EventChannel('samples.flutter.io/charging');
  2. 在合适的位置启动监听 EventChannel 并提供事件处理方法:

      
    void initState() {
    super.initState();
    eventChannel.receiveBroadcastStream().listen(_onEvent, onError: _onError);
    }

    void _onEvent(Object event) {
    // ...
    }

    void _onError(Object error) {
    // ...
    }

    其中 Object event 是可以进行编解码的数据类型, 它代表的是平台端传入的数据.

平台端

  1. 选择一个合适的类, 并在其中建立事件通道:

    let chargingChannel = FlutterEventChannel(name: "samples.flutter.io/charging",
    binaryMessenger: controller)
  2. 在这个类中声明一个事件槽, 同时让该类实现 FlutterStreamHandler 协议:

    class SomeClass: NSObject, FlutterStreamHandler {
    private var eventSink: FlutterEventSink?
    }

    之后拿到事件槽的实例后, 就可以通过该实例向 Flutter 端发送消息.

  3. 将该类设置为事件通道的 streamHandler:

    chargingChannel.setStreamHandler(self)
  4. 实现协议方法 onListenWithArguments 和 onCancelWithArguments:

    public func onListen(withArguments arguments: Any?,
    eventSink: @escaping FlutterEventSink) -> FlutterError? {
    self.eventSink = eventSink
    return nil
    }

    public func onCancel(withArguments arguments: Any?) -> FlutterError? {
    eventSink = nil
    return nil
    }
  5. 通过事件槽发送消息:

        private func sendBatteryStateEvent() {
    guard let eventSink = eventSink else { return }

    switch UIDevice.current.batteryState {
    case .full:
    eventSink("charging")
    case .charging:
    eventSink("charging")
    case .unplugged:
    eventSink("discharging")
    default:
    eventSink(FlutterError(code: "unavailable",
    message: "Charging status unavailable",
    details: nil))
    }
    }

    此时如果 Flutter 端想要接收消息, 则只需要在 onEvent 中写如下代码:

      void _onEvent(Object event) {
    if (!mounted) { return; }
    String status;
    if (event == 'charging') {
    status = '正在充电';
    } else {
    status = '没有充电';
    }
    setState(() {
    _chargingStatus = '充电状态: $status';
    });
    }

typedef 的语法好弹弹堂:

typedef Compare = int Function(Object a, Object b);

typedef Compare<T> = int Function(T a, T b);

// 下面的写法和 typedef Compare = void Function(Object todo); 是一样的.
typedef void Compare(Object todo);

具体的实现已经放到了这个库中, 其中:

  • 通过电量数据的获取来展示 Flutter ---> 平台 的消息发送和结果处理.

  • 通过监听网络状态变化来展示 平台 ---> Flutter 的事件发送和处理.