如何编写 Flutter 插件

欢迎学习《如何编写 Flutter 插件》Codelab!

什么是插件?

插件是一种为应用添加功能的软件。例如,您可能想要移动应用与设备上的摄像头互动。插件是 Flutter 生态系统的重要组成部分。您应首先查看 pub.dev,确认您所需的插件是否已存在。Flutter SDK 作者和 Flutter 社区成员编写了大量插件并将其发布到 pub.dev,与社区分享。

您应特别查看 Flutter Favorite 软件包和插件。Flutter Favorite Favorites 标记用于标识在构建应用时应先考虑的插件。

Flutter 可让用户轻松与跨平台的 Dart 库互动,但有时最好与平台专用代码互动。例如,您可能要与某个数据库通信,但不存在为该数据库编写的 Dart 库。Flutter 提供一种插件编写机制,让您可以与平台专用代码进行通信,还支持您将自己的插件发布到 pub.dev,以便他人使用。

在本 Codelab 中,您将学习如何编写适用于 iOS 和 Android 的插件。您将实现一个在主机平台上处理音频的简单音乐插件,然后开发一个使用该插件制作音乐键盘的示例应用。

以下是最终应用的屏幕截图:

f4275505c0be0bd7.png 746b8f48aa63e2ff.png

学习内容

  • 如何编写适用于 iOS 和 Android 的 Flutter 插件。
  • 如何为插件创建 API。
  • 如何编写使用您的插件的应用。
  • 如何发布插件以便他人使用。

您想通过本 Codelab 学习哪些内容?

我不熟悉这个主题,想简要了解一下。 我对这个主题有所了解,但我想回顾一下。 我在寻找要在我的项目中使用的示例代码。 我在寻找有关特定内容的说明。

要完成本 Codelab,您需要两款软件:Flutter SDK一款编辑器。您可以使用自己偏好的编辑器,例如 Android Studio 或 IntelliJ(已安装 Flutter 和 Dart 插件)或 Visual Studio Code(包含 Dart Code 和 Flutter 扩展程序)。

用于插件开发的某些工具最近出现变更,因此,本 Codelab 假设使用的是 Flutter SDK v1.15.19 或更高版本。您可以使用以下命令查看版本:

$ flutter doctor

您可以使用以下任一设备运行本 Codelab:

Flutter 自带适用于插件的模板,帮助您轻松上手。当您生成插件模板时,可以指定要使用的语言。默认值为适用于 iOS 的 Swift 和适用于 Android 的 Kotlin。在本 Codelab 中,您使用的是 Objective-C 和 Java。

在工作目录中运行以下命令,以创建插件模板,并将其迁移到 Null 安全

$ flutter create --template=plugin --org com.example --template=plugin --platforms=android,ios -a java -i objc plugin_codelab
$ cd plugin_codelab
$ dart migrate --apply-changes
$ cd example
$ flutter pub upgrade
$ dart migrate --apply-changes

这些命令会生成以下目录结构:

plugin_codelab
├── CHANGELOG.md
├── LICENSE
├── README.md
├── android
│   ├── build.gradle
│   ├── gradle
│   │   └── wrapper
│   │       └── gradle-wrapper.properties
│   ├── gradle.properties
│   ├── local.properties
│   ├── plugin_codelab_android.iml
│   ├── settings.gradle
│   └── src
│       └── main
│           ├── AndroidManifest.xml
│           └── java
│               └── com
│                   └── example
│                       └── plugin_codelab
│                           └── PluginCodelabPlugin.java
├── example
│   ├── README.md
│   ├── android
│   ├── build
│   │   └── ios
│   │       └── Runner.build
│   │           └── Release-iphoneos
│   │               └── Runner.build
│   │                   └── dgph
│   ├── ios
│   ├── lib
│   │   └── main.dart
│   ├── plugin_codelab_example.iml
│   ├── pubspec.lock
│   ├── pubspec.yaml
│   └── test
│       └── widget_test.dart
├── ios
│   ├── Assets
│   ├── Classes
│   │   ├── PluginCodelabPlugin.h
│   │   └── PluginCodelabPlugin.m
│   └── plugin_codelab.podspec
├── lib
│   └── plugin_codelab.dart
├── plugin_codelab.iml
├── pubspec.lock
├── pubspec.yaml
└── test
    └── plugin_codelab_test.dart

以下是一些重要文件的说明:

  • pubspec.yaml - 用于定义插件的 YAML 文件。它用于指定插件的名称、依赖项、版本、支持的操作系统等。此文件在插件的 pub.dev 页面上使用。
  • CHANGELOG.md - 每次要发布插件的新版本时,都必须更新此 markdown 文件,以指明新版本出现的变化。
  • README.md - 此 Markdown 文件会显示在插件 pub.dev 列表的首页上。它应该详细说明插件的定义和使用方法。
  • lib/plugin_codelab.dart - 用于实现插件前端的 Dart 代码。插件客户端有权访问此目录中的公共类和函数。
  • android/src/main/java/com/example/plugin_codelab/PluginCodelabPlugin.java - 用于实现 plugin_codelab.dart 中所述 Android 功能的原生 Java 代码。
  • ios/Classes/PluginCodelabPlugin.m - 用于实现 plugin_codelab.dart. 中所述 iOS 功能的 Objective-C 代码(还存在一个匹配的头文件)。
  • example/ - 此目录包含您的插件的客户端。开发插件时,通过修改此文件,可以查看插件的实际应用。
  • example/lib/main.dart - 用于执行插件的 Dart 代码。您可以在这里构建示例界面。

按照以下说明在 iOS 或 Android 设备上运行示例:

$ cd plugin_codelab/example
$ flutter run

您应看到类似下图的内容:

52b7d03a33b9cbfa.png

查看为插件的前端生成的代码:

lib/plugin_codelab.dart

class PluginCodelab {
  static const MethodChannel _channel =
      const MethodChannel('plugin_codelab');

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

cf1e10b838bf60ee.png 观察结果

  • PluginCodelab 是您的插件用户所调用的类。
  • 该类创建一个 MethodChannel,允许 Dart 代码与主机平台进行通信。
  • 此插件的 API 只有一个方法,即属性的 getter platformVersion。当有人在 Dart 中调用此 getter 时,MethodChannel 会调用 getPlatformVersion() 方法,并以异步方式等待 String 的返回。
  • 平台专用代码负责解读 getPlatformVersion 消息的含义,您稍后会看到相关内容。

example/lib/main.dart

Future<void> initPlatformState() async {
  String platformVersion;
  // Platform messages may fail, so we use a try/catch PlatformException.
  try {
    platformVersion = await PluginCodelab.platformVersion;
  } on PlatformException {
    platformVersion = 'Failed to get platform version.';
  }

  // If the widget was removed from the tree while the asynchronous platform
  // message was in flight, we want to discard the reply rather than calling
  // setState to update our non-existent appearance.
  if (!mounted) return;

  setState(() {
    _platformVersion = platformVersion;
  });
}

cf1e10b838bf60ee.png 观察结果

  • 这是插件的客户端。
  • 此代码调用在 lib/plugin_codelab.dart 中定义的 getter。
  • 请注意,该调用封装在 try-block 中。如果针对 iOS 的平台专用代码返回 FlutterError 或在 Java 中抛出异常,则系统会在 Dart 端重新显示这些错误或异常。

ios/Classes/PluginCodelabPlugin.m

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
  FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:@"plugin_codelab"
            binaryMessenger:[registrar messenger]];
  PluginCodelabPlugin* instance = [[PluginCodelabPlugin alloc] init];
  [registrar addMethodCallDelegate:instance channel:channel];
}

cf1e10b838bf60ee.png 观察结果

  • 系统在设置新引擎时调用此初始化代码。
  • 此代码生成一个用于与插件进行通信的通道。
  • 请注意,此处指定的通道名称必须与在 lib/plugin_codelab.dart 中定义的名称一致。
  • 将自己设置为 methodCallDelegate,意味着创建的实例会接收与所提供的二进制信使相关联的消息。

ios/Classes/PluginCodelabPlugin.m

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
  if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
  } else {
    result(FlutterMethodNotImplemented);
  }
}

cf1e10b838bf60ee.png 观察结果

android/src/main/java/com/example/plugin_codelab/PluginCodelabPlugin.java

@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
  channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "plugin_codelab");
  channel.setMethodCallHandler(this);
}

cf1e10b838bf60ee.png 观察结果

  • 此 Java 代码实现适用于 Android 的 getPlatformVersion()

android/src/main/java/com/example/plugin_codelab/PluginCodelabPlugin.java

@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
  if (call.method.equals("getPlatformVersion")) {
    result.success("Android " + android.os.Build.VERSION.RELEASE);
  } else {
    result.notImplemented();
  }
}

cf1e10b838bf60ee.png 观察结果

  • 此 Java 代码处理发自 Dart 的消息。请注意,此代码在形式上与 iOS 插件类似,但存在一些细微差别。

72ad6e60941a67c6.png您发现了一些特别内容!

现在,您为合成器提供了平台专用实现。当按键盘上的键时,合成器发出声音。您可以将此代码看作您要在 Flutter 中显示的库。通常,构建插件时,您已经拥有一个用作构建基础的已定义平台 API,如本例所示。

您现在拥有相同功能的两种不同实现:一个用于 iOS,另一个用于 Android。您需要将这些实现作为应用的一部分进行编译,以便插件能进行调用。

添加到 iOS

将以下文件添加到您的项目中:

将这些文件放入 ios/Classes 位置后,它们会作为插件的 iOS build 的一部分进行编译。通过查看 ios/plugin_codelab.podspec,即可看到,默认情况下,它使用 glob 来定义要编译的来源。只需将文件放入正确的位置即可。

添加到 Android

将以下文件添加到您的项目中:

android/src/main/java/com/example/plugin_codelab/Synth.java

将这个 Java 文件放入 android/src/main/java/com/example 位置后,它们会作为插件的 Android build 的一部分进行编译。通过查看 Gradle 构建系统,即可看到,只需将文件放入正确目录即可进行编译。

合成器接口说明

合成器接口在 iOS 和 Android 上类似,由四种方法组成:

class Synthesizer {
  void start();
  void stop();
  int keyDown(int key);
  int keyUp(int key);
}

keyUp()keyDown() 两种方法表示当音乐键盘上的键被按下并释放时所发送的事件。key 参数表示正在被按下或释放的键。它是对音乐键盘上所有键的枚举。MIDI 标准定义了这些键的枚举,其中 60 是中央 C 音的值,随每个黑键或白键(半音程)递增一。您的插件使用了此定义。

在构建插件的下一步中,需要考虑要在 Flutter 和主机平台之间来回发送哪些类型的信息。如果您在尝试表示某个已定义 API 的库,则可以模仿该接口,从而减轻工作量。

在本 Codelab 中,我们为每个平台提供了合成器代码,以便您在 Dart 代码中模仿其接口:

lib/plugin_codelab.dart

import 'dart:async';

import 'package:flutter/services.dart';

class PluginCodelab {
  static const MethodChannel _channel = const MethodChannel('plugin_codelab');

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

  static Future<int?> onKeyDown(int key) async {
    final int? numNotesOn = await _channel.invokeMethod('onKeyDown', [key]);
    return numNotesOn;
  }

  static Future<int?> onKeyUp(int key) async {
    final int? numNotesOn = await _channel.invokeMethod('onKeyUp', [key]);
    return numNotesOn;
  }
}

请注意,invokeMethod() 的第二个参数会列出一些发送给方法调用的参数。

现在,您拥有用于发声的平台专用库和可控制该代码的 Dart 代码,但它们没有连接起来。如果您现在调用上述任何 Dart 方法,则会产生“未实现”异常,因为您尚未在插件中实现主机端。这就是下一步的工作内容。

在 iOS 上建立连接

首先,修改插件以创建并启动合成器实例:

ios/Classes/PluginCodelabPlugin.m

@implementation PluginCodelabPlugin {
  int _numKeysDown;
  FLRSynthRef _synth;
}
- (instancetype)init {
  self = [super init];
  if (self) {
    _synth = FLRSynthCreate();
    FLRSynthStart(_synth);
  }
  return self;
}

- (void)dealloc {
  FLRSynthDestroy(_synth);
}

接下来,开始处理通过通道发送的消息:

- (void)handleMethodCall:(FlutterMethodCall *)call
                  result:(FlutterResult)result {
  if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS "
        stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
  } else if ([@"onKeyDown" isEqualToString:call.method]) {
    FLRSynthKeyDown(_synth, [call.arguments[0] intValue]);
    _numKeysDown += 1;
    result(@(_numKeysDown));
  } else if ([@"onKeyUp" isEqualToString:call.method]) {
    FLRSynthKeyUp(_synth, [call.arguments[0] intValue]);

    _numKeysDown -= 1;
    result(@(_numKeysDown));
  } else {
    result(FlutterMethodNotImplemented);
  }
}

请注意,代码现在也会查找 onKeyDownonKeyUp 消息。要获取 key 参数,请从 call.arguments 中提取。返回的值装箱为 NSNumber(如平台通道文档所述),因此请使用 intValue 转换该值。

请参阅完成的文件 PluginCodelabPlugin.m

在 Android 上建立连接

首先,修改插件以创建并启动合成器实例:

android/src/main/java/com/example/plugin_codelab/PluginCodelabPlugin.java

public class PluginCodelabPlugin implements FlutterPlugin, MethodCallHandler {
  private MethodChannel channel;
  private Synth synth;
  private static final String channelName = "plugin_codelab";

  private static void setup(PluginCodelabPlugin plugin, BinaryMessenger binaryMessenger) {
    plugin.channel = new MethodChannel(binaryMessenger, channelName);
    plugin.channel.setMethodCallHandler(plugin);
    plugin.synth = new Synth();
    plugin.synth.start();
  }

  @Override
  public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
    setup(this, flutterPluginBinding.getBinaryMessenger());
  }

接下来,开始处理通过通道发送的消息:

@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
  if (call.method.equals("getPlatformVersion")) {
    result.success("Android " + android.os.Build.VERSION.RELEASE);
  } else if (call.method.equals("onKeyDown")) {
    try {
      ArrayList arguments = (ArrayList) call.arguments;
      int numKeysDown = synth.keyDown((Integer) arguments.get(0));
      result.success(numKeysDown);
    } catch (Exception ex) {
      result.error("1", ex.getMessage(), ex.getStackTrace());
    }
  } else if (call.method.equals("onKeyUp")) {
    try {
      ArrayList arguments = (ArrayList) call.arguments;
      int numKeysDown = synth.keyUp((Integer) arguments.get(0));
      result.success(numKeysDown);
    } catch (Exception ex) {
      result.error("1", ex.getMessage(), ex.getStackTrace());
    }
  } else {
    result.notImplemented();
  }
}

与 iOS 类似,代码现在会查找 onKeyDownonKeyUp 消息。同样使用 arguments.get() 提取此处的 key 值。确保在 Android 上您的插件会处理可能出现的任何异常。

请参阅完成的文件 PluginCodelabPlugin.java

至此,插件实现了所有连接工作,您可能想要了解插件的实际应用情况。为此,您可以实现一个简单的键盘界面示例应用:

example/lib/main.dart

import 'package:flutter/material.dart';
import 'dart:async';

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

enum _KeyType { Black, White }

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeRight])
      .then((_) {
    runApp(new MyApp());
  });
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String? _platformVersion = 'Unknown';

  @override
  void initState() {
    super.initState();
    initPlatformState();
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlatformState() async {
    String? platformVersion;
    try {
      platformVersion = await PluginCodelab.platformVersion;
    } on PlatformException {
      platformVersion = 'Failed to get platform version.';
    }

    if (!mounted) return;

    setState(() {
      _platformVersion = platformVersion;
    });
  }

  void _onKeyDown(int key) {
    print("key down:$key");
    PluginCodelab.onKeyDown(key).then((value) => print(value));
  }

  void _onKeyUp(int key) {
    print("key up:$key");
    PluginCodelab.onKeyUp(key).then((value) => print(value));
  }

  Widget _makeKey({@required _KeyType keyType, @required int key}) {
    return AnimatedContainer(
      height: 200,
      width: 44,
      duration: Duration(seconds: 2),
      curve: Curves.easeIn,
      child: Material(
        color: keyType == _KeyType.White
            ? Colors.white
            : Color.fromARGB(255, 60, 60, 80),
        child: InkWell(
          onTap: () => _onKeyUp(key),
          onTapDown: (details) => _onKeyDown(key),
          onTapCancel: () => _onKeyUp(key),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Color.fromARGB(255, 250, 30, 0),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Text('Running on: $_platformVersion\n'),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: <Widget>[
                  _makeKey(keyType: _KeyType.White, key: 60),
                  _makeKey(keyType: _KeyType.Black, key: 61),
                  _makeKey(keyType: _KeyType.White, key: 62),
                  _makeKey(keyType: _KeyType.Black, key: 63),
                  _makeKey(keyType: _KeyType.White, key: 64),
                  _makeKey(keyType: _KeyType.White, key: 65),
                  _makeKey(keyType: _KeyType.Black, key: 66),
                  _makeKey(keyType: _KeyType.White, key: 67),
                  _makeKey(keyType: _KeyType.Black, key: 68),
                  _makeKey(keyType: _KeyType.White, key: 69),
                  _makeKey(keyType: _KeyType.Black, key: 70),
                  _makeKey(keyType: _KeyType.White, key: 71),
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

请注意以下事项:

  • 您必须导入 'package:plugin_codelab/plugin_codelab.dart' 才能使用此插件。该插件示例的依赖项在 example/pubspec.yaml, 中定义,是插件运行的前提。
  • main(), 中,屏幕方向被固定为横向,以便整个键盘显示在屏幕上。
  • _onKeyDown()_onKeyUp() 两种方法都是在上述步骤中设计的插件 API 的客户端。
  • 代码使用 InkWell(交互式矩形)绘制各个键。

运行该应用,您将看到正常运行的音乐键盘:

cd example
flutter run

显示的内容应如图所示:

f4275505c0be0bd7.png

恭喜!您成功创建了适用于 iOS 和 Android 的 Flutter 插件,并设计了一个可弹奏的炫酷音乐键盘。您可以从 https://github.com/flutter/codelabs/tree/master/plugin_codelab 下载完成的项目进行对比。

后续步骤

  • 添加端到端测试。Flutter 团队提供了一个库,用于创建名为 e2e 的端到端集成测试。
  • 发布到 pub.dev。创建插件后,您可能想在线共享该插件,以便他人使用。有关如何将插件发布到 pub.dev 的完整文档,请参阅开发插件软件包

扩展合成器

如果您有兴趣试用合成器并加以改进,可以考虑以下后续步骤:

  • 现在,合成器生成的是正弦波。生成锯形波会怎样?
  • 您发现了吗?按下并释放键时,会有爆音。这是由于振荡器突然开启和关闭造成的。通常,合成器使用振幅包络解决该问题。
  • 现在,您只能每次按一个键。这被称为单音。而真正的钢琴是多音的。