🐦

🐦🐦🐦pigeonとmethod channelを比べてみた🐦🐦🐦

2024/04/04に公開

はじめに

こんにちは加藤です。

今回のテーマはPigeon(ピジョン)というパッケージの紹介です。

ずっと気になっていたのですが、使ったことがなかったので&AI(旧ドリグロ)での勉強会を機に使ってみました。
定期的に勉強会があるとモチベーションが持続しやすいのでありがたいですね。

全部コードにしてるので、レポジトリ見ながら読んでください。
レポジトリではpull requestの形でmethod channelとpigeonの比較をしています。
よければそれも見てみてください。

https://github.com/shunkat/pigeon_sample

(iOSはシミュレータだとバッテリーの値が取得できないので実機でやってみてください)

今回やらないこと

  • Pigeonの内部の細かい仕組みの説明
  • ネイティブ側からFlutter側の呼び出し
  • Windows / Linux / Macのネイティブアプリとの連携の話

まずはPlatform Channel

Flutterはクロスプラットフォームな開発が特徴ですが、時にはAndroid側やiOS側のネイティブ機能を使いたい時がありますよね。

ネイティブ使いたい時の例

  • ネイティブ側で発表された新機能を使いたい
  • ライブラリが存在しない(or怪しいのしかない)ニッチ機能を使いたい
  • すでにネイティブアプリが存在していてそれを修正したい時

そういう場合はPlatformChannelという仕組みがデフォルトで用意されています。

PlatformChannelには実は三種類あります。

  1. Method Chanel(シンプル非同期で双方向)
  2. Event Channel(Host→Flutterへの一方向だけどストリーム)
  3. BasicMessageChannel(継続的非同期で双方向)

今回はMethodChannelを使います。

具体的には以下のような仕組みで動いているようです。ちなみ非同期です。

Untitled

MethodChannelがあれば大丈夫じゃんと一瞬思ってしまうのですが、いくつか残念なポイントがあります。

残念ポイント1

ネイティブメソッドの呼び出しが文字列。

→メソッド名をそのまま文字列として書いておく必要があるが、タイポの危険性がある。

残念ポイント2

返り値がStringになってしまう

→シリアライズされて全部Stringで返ってくるのでそれを別の型に変換する必要があるが、その際に型不一致でエラーが起きる可能性がある

Pigeonがあるよ

そこでPigeonが登場しました。

FlutterからHost(各プラットフォーム)のコードの呼び出しや、逆にHostからFlutter側のコードを呼び出す時にも使えます。

しかも先ほどの残念ポイントを解消してくれています。

仕組み

FlutterとHostの通信部分の仕組みはBasicMessageChannelだそうです。

最初に静的ファイルを元にインタフェースを定義しておき、そのインタフェースを使うことで、型の安全を保つという仕組みです。

静的ファイルで定義したインタフェースは、コード生成によってFlutter側のコードもHost側のコードも書いてくれるので手間も減っていい感じですね。

インターフェースとは

そのメソッドの引数と返り値の型のことです。どういう値を渡したらどういう値が返ってくるかを、中身の実装は抜きにして決めておくものですね。

ざっくり使い方

  1. pigeonをdev_dependencyに追加
  2. root配下に設定とインタフェースを定義したファイルを配置
  3. コードを生成
  4. それぞれのプラットフォームやFlutter側で実際に呼び出したり、中身を実装したりする

インターフェース定義ファイルのお作法

import 'package:pigeon/pigeon.dart';

// Pigeonの設定を行う: 例どのHostと通信するか、どこのファイルを生成するかなど
(PigeonOptions(
  dartOut: 'lib/messages.g.dart',
  dartOptions: DartOptions(),
  kotlinOut:
      'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/Messages.g.kt',
  kotlinOptions: KotlinOptions(),
  swiftOut: 'ios/Runner/Messages.g.swift',
  swiftOptions: SwiftOptions(),
))

// 必要であれば共通の型定義
class SampleMessage {
  SampleMessage(
    this.hoge,
    this.fuga,
  );
  
  String? hoge;
  String fuga;
}

// Host側からFlutterを呼び出すときは、@FlutterApi()をつける
()
abstract class SampleFlutterApi {
  SampleMessage hogehoge();
}

// Flutter側からHost側を呼び出すときは、@HostApi()をつける
()
abstract class BatteryHostApi {
  int getBatteryLevel();
}

使い方

method channelで作ったものと同じ機能を作ってみます

1. pigeonをdev_dependencyに追加

dart pub add dev:pigeon

2. インタフェースファイル定義

ルート直下にpigeonsフォルダを作って、中にmessage.dartを作成します。ファイル名は適当で良いです。

import 'package:pigeon/pigeon.dart';

// Pigeonの設定を行う: 例どのHostと通信するか、どこのファイルを生成するかなど
(PigeonOptions(
  dartOut: 'lib/messages.g.dart',
  dartOptions: DartOptions(),
  kotlinOut:
      'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/Messages.g.kt',
  kotlinOptions: KotlinOptions(),
  swiftOut: 'ios/Runner/Messages.g.swift',
  swiftOptions: SwiftOptions(),
))

// Flutter側からHost側を呼び出すときは、@HostApi()をつける
()
abstract class BatteryHostApi {
  int getBatteryLevel();
}

3. コード生成

dart run pigeon —-input ./pigeons/message.dart

これで以下のファイルが生まれているはずです。

スクリーンショット 2024-04-04 9.02.41.png

4. 使ってみる

まずはFlutter側のコード

  String _batteryLevel = 'Unknown battery level.';

  Future<void> _getBatteryLevel() async {
    // 生成されたインタフェースを呼び出し
    final api = BatteryHostApi();
    String batteryLevel;
    try {
      final result = await api.getBatteryLevel();
      batteryLevel = 'Battery level at $result % .';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

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

次にAndroid側のコード

// 生成されたインタフェースを継承、上書きして中身を実装する
private class BatteryLevelApi(private val context: Context) : BatteryHostApi {
    override fun getBatteryLevel(): Long {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            // Int型からLong型への変換
            return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).toLong()
        } else {
            val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
            // intentがnullでない場合のみ計算を行う
            intent?.let {
                val level = it.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
                val scale = it.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
                return (level * 100 / scale).toLong()
            }
            // intentがnullの場合、適切なデフォルト値を返す
            return -1
        }
    }
}

class MainActivity: FlutterActivity() {

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        val api = BatteryLevelApi(this)
        BatteryHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, api)
    }

}

ここで困ったのが生成コードがLong型だった点、Flutterのint型はKotlinのInt型ではなくLong型と対応しているらしい。めんどくさいけど変換処理を挟む必要があった。

最後にiOS側のコード

BatteryLevelApi.swiftを作成して

import Foundation

enum BatteryError: Error {
    case unavailable
}

class BatteryLevelApi: BatteryHostApi {
    func getBatteryLevel() throws -> Int64 {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        if device.batteryState == UIDevice.BatteryState.unknown {
            throw BatteryError.unavailable
        } else {
            return Int64(device.batteryLevel * 100)
        }
    }
}

AppDelegate内部で呼び出し

   let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
    let batteryApi = BatteryLevelApi()
    BatteryHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: batteryApi)

ここで困ったのは、最初に生成されたMessages.g.swiftがXcodeに読み込まれていなかったこと。

手動で読み込む必要があるのか、それとも何かを間違ったのかは不明

最後に

感想です。

Method ChannelとPigeonを比較すると

  • 最初の実装の手間はほぼ同じか、若干Method Channelの方が楽
  • 修正しやすさはPigeonの圧勝

という感じでした。

シンプルな型の値を単体で取得するだけならMethodChannelの方が楽です。
複雑な型を定義する必要があったり双方向に継続的な通信が必要ならPigeonの方が遥かに楽ですね。

長く続く可能性があるアプリならPigeonを使って実装しておいた方が良いと思います。

Discussion