Flutter platform channels for Linux

## はじめに

FlutterでLinux向けのplatform channelsを使う方に向けた記事です。
DartとLinux platformとの連携の仕方を書きます。
flutter開発環境の構築手順、ライセンスやパッケージとして公開する手順などは書きません。

FlutterでのLinux platformは現在Desktop向けを指しますが、
[Desktop向けFlutter](https://flutter.dev/desktop)は、本記事執筆時点(2020/11/3)でアルファ版扱いです。
本記事に記載している内容、特に`linux/`以下は今後大きく変更されている可能性がありますので注意してください。

そのような事情もあるので「作り方の調べ方」を意識して書いたつもりですが、
誤りや古くなっている箇所などに気づかれた方はご指摘いただけると幸いです。

以下の環境で確認しています。

```
$ uname -a
Linux chama 5.5.8 #1 SMP Sat Mar 7 22:29:22 JST 2020 x86_64 x86_64 x86_64 GNU/Linux
$ lsb_release -d
Description:    Ubuntu 18.04.5 LTS
$ flutter --version
Flutter 1.24.0-7.0.pre.42 • channel master • https://github.com/flutter/flutter
Framework • revision c0ef94780c (2 days ago) • 2020-10-31 03:12:27 -0700
Engine • revision 3460519398
Tools • Dart 2.12.0 (build 2.12.0-3.0.dev)
```

## Platform Channels概要

Flutterでプラットフォーム固有のAPIを呼び出すときには、Platform Channelsという仕組みを使います。

Platform Channelsは柔軟に多様なプラットフォームと連携できるように、
シンプルな非同期のメッセージパッシングの仕組みになっています。

非同期だけだとAPIを使う側のコードがわかりにくくなりそうですが、
Dartにはasync/awaitがあるので、可読性を損なわずに柔軟かつシンプルに実装できるようになっています。

Platform Channelsには、用途に応じた下記の３つのAPIが用意されています。
これらの使い分けや経緯については、[こちらの記事](https://medium.com/flutter/flutter-platform-channels-ce7f540a104e)がわかりやすいです。

- 関数呼び出しを実現する[MethodChannel](https://api.flutter.dev/flutter/services/MethodChannel-class.html)
- イベント配信を実現する[EventChannel](https://api.flutter.dev/flutter/services/EventChannel-class.html)
- より汎用的なメッセージパッシングを実現する[BasicMessageChannel](https://api.flutter.dev/flutter/services/BasicMessageChannel-class.html)

## まずはテンプレートを作る

`flutter create`コマンドを作ってプロジェクトを生成します。
このとき`--template plugin`を指定することでplugin開発用の雛形からプロジェクトを作成できます。
`--org`には組織[Reverse domain name notation](https://en.wikipedia.org/wiki/Reverse_domain_name_notation)を指定します。
下記のコマンドで、linux platform向けのpluginを"platform_proxy"という名前で作成します。

```
flutter create --platforms linux --template plugin --org xyz.takeoverjp.example platform_proxy
```

本筋ではないですが、何をいじったかわかるようにこの時点で一旦git commitしておくことをおすすめします。


## サンプルアプリを実行してみる

生成された`platform_proxy`の中には、`example`というサンプルアプリも用意されています。
サンプルアプリを実行すると下記のような画面が表示されます。
Linuxを触ったことがあれば文字列からピンとくるかも知れないですが、`uname -v`の結果を出力するサンプルになっています。

![Screenshot from 2020-11-03 02-21-48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37714/3ca98c17-ff84-8881-6970-7f90200c20e3.png)


## 生成されたコードを読む

生成されたコードのうち、以下の３つに注目することで、呼び出しの流れを理解することができます。

- `platform_proxy/example/lib/main.dart`
  - Plugin API呼び出し
- `platform_proxy/lib/platform_proxy.dart`
  - Plugin API実装（Dart部）
  - プラットフォーム非依存
- `platform_proxy/linux/platform_proxy_plugin.cc`
  - Plugin API実装（Native部）
  - プラットフォーム依存

また、Plugin API開発時は単体テストも一緒に修正するため、ここで併せて紹介します。

### Plugin API呼び出し部

```dart:platform_proxy/example/lib/main.dart
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;
    // Platform messages may fail, so we use a try/catch PlatformException.
    try {
      platformVersion = await PlatformProxy.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;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: Text('Running on: $_platformVersion\n'),
        ),
      ),
    );
  }
}
```

`PlatformProxy.platformVersion`というのが、templateが追加したAPIです。
コメントにもあるとおり、PlatformChannelは非同期のメッセージです。
今回利用しているMethodChannelも非同期呼び出しなので、async/awaitを使っています。
Dartのasync/awaitについては、[こちらの記事](https://blog.chocolapod.net/momokan/entry/104)がわかりやすいです。
端的に言えば、非同期呼び出しに対する応答を、次のイベントループで受け取るための構文です。


### Plugin API実装（Dart部）

```dart:platform_proxy/lib/platform_proxy.dart
class PlatformProxy {
  static const MethodChannel _channel =
      const MethodChannel('platform_proxy');

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

`PlatformProxy#platformVersion`の実装です。
`platform_proxy`に対する`MethodChannel`を生成し、`MethodChannel#invokeMethod`を呼び出しています。

ここで、[channel名が重なるとhandlerが上書きされて正しく通信できなくなります](https://api.flutter.dev/flutter/services/MethodChannel-class.html)。
本家では見つけられませんでしたが、[こちらのページ](https://medium.com/flutter/flutter-platform-channels-ce7f540a104e)では`(domain名)/(plugin名)/(channel名)`にすることが推奨されています。
templateでは上記の通りdirectなchannel名で`MethodChannel`を生成していますが、
特にプラグインの場合は名前衝突のリスクが大きいので忘れずに変更しましょう。
例えば、[Flutter公式のfile_chooser plugin](https://github.com/google/flutter-desktop-embedding/blob/master/plugins/file_chooser/lib/src/channel_controller.dart)では、`flutter/filechooser`としています。

[`MethodChannel#invokeMethod`](https://api.flutter.dev/flutter/services/MethodChannel/invokeMethod.html)は
呼び出したいメソッド名と引数を渡し非同期で呼び出します。
また、応答はFutureなのでawaitで結果を待つことができます。

ところで、`invokeMethod`に渡すMethod名は単なる文字列です。
仮に呼び出し側がMethod名をtypoしても、コンパイルエラーにはならず実行時例外になります。
型やプロパティ名が間違っている場合もコンパイル時に検出することはできません。
そのような誤用を防ぎつつ、可読性の高いplugin APIにするためのwrappterがこのレイヤの責務です。


### Plugin API実装（Native部）


```c++:platform_proxy/linux/platform_proxy_plugin.cc
static void platform_proxy_plugin_handle_method_call(
    PlatformProxyPlugin* self,
    FlMethodCall* method_call) {
  g_autoptr(FlMethodResponse) response = nullptr;

  const gchar* method = fl_method_call_get_name(method_call);

  if (strcmp(method, "getPlatformVersion") == 0) {
    struct utsname uname_data = {};
    uname(&uname_data);
    g_autofree gchar *version = g_strdup_printf("Linux %s", uname_data.version);
    g_autoptr(FlValue) result = fl_value_new_string(version);
    response = FL_METHOD_RESPONSE(fl_method_success_response_new(result));
  } else {
    response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
  }

  fl_method_call_respond(method_call, response, nullptr);
}
...
static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call,
                           gpointer user_data) {
  PlatformProxyPlugin* plugin = PLATFORM_PROXY_PLUGIN(user_data);
  platform_proxy_plugin_handle_method_call(plugin, method_call);
}

void platform_proxy_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
  PlatformProxyPlugin* plugin = PLATFORM_PROXY_PLUGIN(
      g_object_new(platform_proxy_plugin_get_type(), nullptr));

  g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
  g_autoptr(FlMethodChannel) channel =
      fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
                            "platform_proxy",
                            FL_METHOD_CODEC(codec));
  fl_method_channel_set_method_call_handler(channel, method_call_cb,
                                            g_object_ref(plugin),
                                            g_object_unref);

  g_object_unref(plugin);
}
```

Desktop Linux向けFlutterでは、イベントループをGLibを使って実装しています。
私はGLibに慣れてないのではじめ面食らってしまいましたが、
GLibの関数(`g_***`)はほぼオブジェクト管理のためのコードなの一旦気にせず
flutterの関数(`fl_***`)に注目すればわかりやすいと思います。

plugin登録時に、`fl_method_channel_new`で生成した`FlMethodChannel`に
`fl_method_channel_set_method_call_handler`でコールバック関数を登録しています。

Dart側で対象のAPIを呼ぶとFlutter Engineから`method_call_cb`が呼び出され、
引数を適切にキャストした上で`platform_proxy_plugin_handle_method_call`を呼び出します。

そして`fl_method_call_get_name`でmethod名を取得し対応した処理をします。
今回の`getPlatformVersion`では、`uname(2)`を呼び出しています。
結果を`FL_METHOD_RESPONSE(fl_method_success_response_new(result))`で返り値に設定し、
`fl_method_call_respond`を呼び出すことでFlutter Engineに応答を返しています。

なお[platform channelsの通信はmain threadで行う](https://flutter.dev/docs/development/platform-integration/platform-channels#channels-and-platform-threading)必要があります。
別のthreadで結果を算出した場合は、`g_main_context_invoke`などを使ってmain threadにcontextを移してから応答を返しましょう。

### Plugin APIの単体テスト

`flutter test`を実行することで、Plugin APIの単体テストが実行できます。
`MethodChannel`には、`setMockMethodCallHandler`というDI用のメソッドが用意されており、
Native部を切り離した状態で単体テスト可能です。
逆に、Native部の実装のテストにはならないので、別途行う必要があります。

```dart:test/plugin_method_test.dart
void main() {
  const MethodChannel channel = MethodChannel('platform_proxy');

  TestWidgetsFlutterBinding.ensureInitialized();

  setUp(() {
    channel.setMockMethodCallHandler((MethodCall methodCall) async {
      return '42';
    });
  });

  tearDown(() {
    channel.setMockMethodCallHandler(null);
  });

  test('getPlatformVersion', () async {
    expect(await PlatformProxy.platformVersion, '42');
  });
}
```

## APIの追加

BasicMessageChannelでDartからplatformの関数を呼び出す場合は、
上記３箇所のコードをN増しすることでAPIを追加できます。

別の型を返す場合や引数を受け取る場合は、
[sample](https://github.com/google/flutter-desktop-embedding)や[headerコメント](https://github.com/flutter/engine/tree/master/shell/platform/linux/public/flutter_linux)が参考になります。

また、以下のサンプルプロジェクトに他のバリエーションも実装していますので参考にしてください。
[takeoverjp/flutter-linux-plugin-example: A plugin example for platform channels on Linux desktop.](https://github.com/takeoverjp/flutter-linux-plugin-example)

- MethodChannelでplatformからDartの関数を呼び出す
- EventChannelでplatformからevent通知する
- BasicMessageChannelでDartからplatformの関数を呼び出す

## [参考] 自動生成パッケージ Pigeonについて

さて、前述の方法ではメソッド名を単なる文字列として扱っていたり、
それぞれのメソッドに対する引数・返り値に対する型が紳士協定になっています。
ここがDart部とNative部でずれた場合、例外か最悪未定義動作になります。
必ずplugin経由でアクセスしきちんとテストしていれば、実際に問題が発生する可能性は抑えられますが、不安は残ります。

対策として、[pigeon](https://pub.dev/packages/pigeon)という自動生成するためのパッケージが用意されています。
Dartのサブセットで記述されたPigeonファイルから、前述のようなplatformとDart間で通信するためのコードを生成します。

ただし、記事執筆時点(2020/11/3)の0.1.14ではまだdesktopが未サポートです。
現時点でもDart部の実装を生成するツールとしては利用可能です。
Native部とのシンボルを合わせるのが大事な要素なのですこし残念な使い方になってしまいますが、
参考に方法を残しておきます。

自動生成の流れは以下のとおりです。
[API定義ファイル](https://github.com/takeoverjp/flutter-linux-plugin-example/blob/master/pigeons/platform_proxy_message.dart)と[生成されたDartコード](https://github.com/takeoverjp/flutter-linux-plugin-example/blob/master/lib/pigeon_platform_proxy.dart)も見ていただけばよりイメージが掴みやすいかもしれません。

1. `pubspec.yaml`の`dev_dependencies`に`pigeon`を追加
2. APIのプロトタイプ宣言と必要な型を定義したdartファイルを用意
3. 以下のコマンドで、Dart部を生成する

```shell
flutter pub run pigeon --input pigeons/platform_proxy_message.dart --dart_out lib/pigeon_platform_proxy.dart
```

その後、生成されたdartファイルにあわせてNative部を実装することになります。
生成されるdartファイルでは、BasicMessageChannelを使っているので、Native部も合わせる必要があります。

Pigeon(0.1.14)で作成するAPIの制約として、引数および返り値は1個以下のAPI定義ファイルで定義されたclassでなければなりません。
つまり、intを引数で受け取ったり、複数の値を引数に受け取ったり、intを返したりすることはサポートしていません。
結果として必ずclass経由になるので、渡す方も受け取る方も少し冗長になります。
また、platform側からのイベント通知やメソッド呼び出しにもまだ対応していないようでした。


## おわりに

pluginのtemplateとして生成されるコードを題材に、Linux Desktopでのplatform channelsの使い方を見てみました。
platform channels自体はシンプルですが、Dart部とNative部を両方実装する必要があり、結構大変目な印象です。

もし大きなデータを扱う処理の場合は、シリアライズが走るplatform channelsだと不利なので、ffiを使った方が良いと思います。

一方、やりたいことがIPCで、通信相手がunix domain socket/dbus/grpcに対応している場合など、
そもそもDartから直接dart:io/dbus.dart/grpc-dartを使うことで目的を達成できることもあるかと思います。

そう考えると、Android/iOSの場合はjava/kotlin/Obj-C/swiftのplatfrom APIを使うためにplatform channels必須ですが、
Linuxのことだけ考えればあまり出番ないのかも知れません。

状況にあった方法を選択できるよう、それぞれの方法をちゃんと知っておかねばと思う今日この頃でした。。。


## 参考

- [Developing packages & plugins - Flutter](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin)
  - Flutter本家のplugin packageの作り方。
- [Writing custom platform-specific code - Flutter](https://flutter.dev/docs/development/platform-integration/platform-channels)
  - Flutter本家のplatform channelsの使い方。
- [Desktop support for Flutter - Flutter](https://flutter.dev/desktop)
  - Flutter本家のDesktop support状況。
- [google/flutter-desktop-embedding: Experimental plugins for Flutter for Desktop](https://github.com/google/flutter-desktop-embedding)
  - Flutter本家が用意しているdesktop用のplugin
  - Linux Desktopだけでなく、Windows/Macにも対応している
  - templateよりは複雑なので、ファイル分割などtemplateより現実的な規模のpluginとして参考になる
- [engine/shell/platform/linux at master · flutter/engine](https://github.com/flutter/engine/tree/master/shell/platform/linux)
  - Linux Desktop向けMessageChannelの実装
- [pigeon | Dart Package](https://pub.dev/packages/pigeon)
- [Flutter Platform Channels. “Nice UI. But how does Flutter deal… | by Mikkel Ravn | Flutter | Medium](https://medium.com/flutter/flutter-platform-channels-ce7f540a104e)
- [Writing Plugins for Linux in Flutter | by Yash Johri | Level Up Coding](https://levelup.gitconnected.com/writing-plugins-for-linux-in-flutter-38e46bd7872f)
- [Flutter アーキテクチャ ガイド（初版） - kurun-books - BOOTH](https://booth.pm/ja/items/2396643)
  - この辺のことに興味がある人は全員読んだ方がいいと思うぐらい良い本です。これが500円は控えめに言って頭おかしいと思います（褒め言葉）。
- [Flutter MethodChannel APIの使い方 - Qiita](https://qiita.com/kurun_pan/items/db6c8fa94bbfb5c0c8d7)
- [Flutter EventChannel APIの使い方 - Qiita](https://qiita.com/kurun_pan/items/6d63ebf1e894d3620b20)
- [Flutter MessageChannel APIの使い方 - Qiita](https://qiita.com/kurun_pan/items/9c2b6fdba602203f8f83)
  - 上記の本の著者(@kurun_panさん)のMethodChannel/EventChannel/MessageChannelの記事。
  - 良記事なので、platform channelを触るときは目を通すことをおすすめします。
- [Dart 非同期処理の async / await について / 桃缶食べたい。](https://blog.chocolapod.net/momokan/entry/104)
- [Oct 27_Graphical User Interface Using Flutter in Embedded Systems_Hidenori Matsubayashi.pdf](https://static.sched.com/hosted_files/osseu2020/e0/Oct%2027_Graphical%20User%20Interface%20Using%20Flutter%20in%20Embedded%20Systems_Hidenori%20Matsubayashi.pdf)
- [FlutterでPluginプロジェクトを作って実装してみた | Developers.IO](https://dev.classmethod.jp/articles/flutter_plugin_project1/)