ネイティブとFlutterの通信:MethodChannel、Pigeon、FFIのどれを使うか

メソッドチャネル、Pigeon、直接のFFI。ネイティブとFlutterの境界をまたいでデータやイベントをやり取りする方法と、どれを選ぶべきかを解説します。

ネイティブとFlutterの境界を越えるものはすべて、少数の仕組みのいずれかを通って移動します。手軽だが型がないものから、高速だが低レベルなものまで幅があり、選択を誤ると多くの手戻りを生みます。気づかないうちにずれていく文字列ベースのチャネルや、普通のメソッドチャネルで十分だった場面でのFFIバインディングなどです。このページでは、選択肢と、それぞれがどんな場面に適しているかを整理します。

プラットフォームチャネルとDart isolateが実際にどう動くかをまだ読んでいない場合は、そちらから始めてください。以下のトレードオフは、この2つの概念から直接導かれます。

概要:どの仕組みをいつ使うか

仕組みは3つの系統に分かれます。

  • チャネル(MethodChannel、EventChannel、BasicMessageChannel):組み込みの非同期メッセージパッシングのプリミティブです。デフォルトでは型がありません。
  • Pigeon:チャネルの上に、型安全で多言語対応のコントラクトを載せるコード生成ツールです。
  • 直接のFFI(ffigen、jnigen):チャネルとシリアライズを完全に迂回し、ネイティブコードを同期的に直接呼び出します。
仕組み型安全同期 / 非同期コード生成最適な用途
MethodChannelなし(手動)非同期なし使い捨てのプロトタイプのみ。Pigeonを推奨
EventChannelなし(手動)非同期(ストリーム)なし生のネイティブ → Dartストリーム。Pigeonの@EventChannelApiを推奨
Pigeonあり非同期ありDart/Swift/Kotlinにまたがり、時間とともに進化する構造化されたAPI
ffigenあり(Cヘッダから)同期ありC/C++/Rustライブラリの呼び出し。パフォーマンス重視、大きなバッファ
jnigenあり(Java/Kotlinから)同期あり既存のJava/Kotlin/Android SDKのAPIを直接呼び出す

判断はたいてい、次の2つの問いを順に考えることで決まります。

  1. 計算のための自己完結したネイティブコードライブラリをリンクするのか(C ABI経由のC/C++/Rustライブラリや、直接呼び出したいJVMクラス)、それとも、シリアライズがボトルネックだと計測で確認されたホットパスに取り組んでいるのか。そうであれば、直接のFFI(ffigen / jnigen)を使います。判断基準は「ネイティブライブラリが関わっているか」ではなく、リンクしてプロセス内で呼び出す計算かどうかです。ほとんどのプラットフォーム統合(カメラ、位置情報、センサー)もネイティブライブラリをラップしていますが、それらは非同期でステートフル、かつメインスレッドに縛られているため、チャネルで扱うべきです。FFIがデフォルトの境界にならない理由を参照してください。
  2. それ以外に境界を越えるものか(メソッド呼び出し、またはネイティブ → Dartのイベントストリーム)。そうであれば、Pigeonを使います。呼び出しには@HostApi/@FlutterApiを、ストリームには@EventChannelApiを使います。

どの分岐も生のMethodChannelやEventChannelに行き着かないことに注目してください。これは意図的なもので、私たちは生のチャネルの使用を推奨していません。生のチャネルは、Pigeonがその上にコードを生成する型のないプリミティブです。Pigeonは、リクエスト/レスポンス型の呼び出し(@HostApi/@FlutterApi)と、ネイティブ → Dartのストリーミング(EventChannelをベースにした型付きのStream<T>を発行する@EventChannelApi)の両方をカバーしています。単一のメソッドであれイベントのストリームであれ、何かが境界を越えるなら、Pigeonはほぼ同じ手間で型付きのコントラクトとして同じ役割を果たします。生のチャネルが強いる文字列の名前とdynamicなペイロードこそ、Pigeonが防ぐために存在する「気づかないずれ」そのものです。すぐに削除するつもりの簡単なスパイクなら素のチャネルでも構いませんが、残すつもりのものにはPigeonを使ってください。

経験則としては、境界を越えるものには呼び出しでもストリームでもデフォルトでPigeonを選び、FFIはネイティブライブラリとやり取りする場合や、計測で確認されたホットパスに取り組む場合にのみ使います。プラットフォームのサービスを呼び出す汎用的な手段としては使いません。

MethodChannel

MethodChannelは、プラットフォームチャネルのプリミティブを最も直接的に使う方法です。Dartが名前付きのメソッドを引数付きで呼び出して結果を待ち、ネイティブ側はそのチャネルにハンドラを登録して応答します。EventChannelはストリーミング版(ネイティブが一連のイベントをDartのStreamにプッシュする)で、BasicMessageChannelは「メッセージを送り、メッセージを受け取る」という生の形式です。

// Dart side
const channel = MethodChannel('com.example.app/battery');

Future<int> batteryLevel() async {
  // Untyped: the method name and the return type are a convention,
  // not something the compiler checks against the native side.
  return await channel.invokeMethod<int>('getBatteryLevel') ?? -1;
}
let channel = FlutterMethodChannel(
  name: "com.example.app/battery",
  binaryMessenger: controller.binaryMessenger)

channel.setMethodCallHandler { call, result in
  switch call.method {
  case "getBatteryLevel":
    result(self.currentBatteryLevel())
  default:
    result(FlutterMethodNotImplemented)
  }
}

注意すべき点は次のとおりです。

  • 型がありません。 メソッド名は文字列で、引数はStandardMessageCodecでシリアライズされるdynamicなマップです。このコーデックが理解できるのはプリミティブ、リスト、マップだけです。両側がデータの形を手作業で合わせる必要があり、ずれてもコンパイル時には何も失敗しません。
  • エンジンごとです。 チャネルは1つのエンジンのbinary messengerに紐づくため、マルチエンジン構成(コアコンセプトを参照)では、必要な各エンジンにハンドラを登録します。
  • 非同期で、プラットフォームスレッド上で動きます。 ハンドラはデフォルトでホストのメインスレッドで実行されます。そこで重い処理をするとUIがカクつくので、別スレッドに逃がしてください。
  • エラーはPlatformExceptionとして境界を越えます。 ネイティブ側が失敗を通知すると(result(FlutterError…) / result.error(…))、Dart側ではキャッチ可能な例外として現れます。
  • 呼び出し量が多いと、呼び出しが跡形もなく消えることがあります。 私たちがインタビューしたあるチームは、トレーシングとアナリティクスのイベントをチャネル経由で送っていましたが、届いたのは約80%だけで、どちらの側でもエラーは出ていませんでした。大量のテレメトリをイベントごとのチャネル呼び出しで送らないでください。バッチにまとめるか、テレメトリはそれを生成する側に置いてください。

生のMethodChannelやEventChannelは使い捨てのスパイクには便利ですが、せいぜいそこまでです。コードを長く使うつもりになった時点で(メソッドが1つか2つを超える、あるいはフラットなマップより複雑なデータ構造を扱う場合)、手作業で型のない管理は負債になります。Pigeonなら、ほぼ同じ手間でそれをなくせます(呼び出しには@HostApi/@FlutterApi、ストリームには@EventChannelApi)。本番コードでは、素のチャネルはたいてい、代わりにPigeonを使うべきだったというサインです。

Pigeon(型安全なコード生成)

Pigeonは、Flutterチームがメンテナンスしているコード生成ツールです。境界をアノテーション付きのDartファイル(データクラスと抽象APIクラス)として一度だけ記述すると、Pigeonが対応するDart、Kotlin/Java、Swift/Objective-Cのグルーコードを出力します。内部では依然としてチャネルと標準コーデックを使っていますが、すべてのメソッドシグネチャとすべてのフィールドが3つの側すべてでコンパイラによってチェックされるようになります。

// pigeons/example.dart - a spec file, not shipped code
import 'package:pigeon/pigeon.dart';

class BatteryStatus {
  late int level;
  late bool isCharging;
}

@HostApi() // methods Dart calls ON the host (native)
abstract class BatteryHostApi {
  BatteryStatus getStatus();
}

@FlutterApi() // methods the host calls ON Flutter
abstract class BatteryFlutterApi {
  void onStatusChanged(BatteryStatus status);
}

Pigeonを実行すると、これはSwift/Kotlinで実装してDartから呼び出すBatteryHostApiと、逆方向用のBatteryFlutterApiになります。どちらも型のないマップではなく、型付きのBatteryStatusオブジェクトを使います。

方向は明示的に整理しておく価値があります。

  • @HostApi:Dart → ネイティブ。Dartが呼び出し、ネイティブ側が実装します。
  • @FlutterApi:ネイティブ → Dart。ネイティブ側が呼び出し、Dartが実装します。
  • @EventChannelApi:ネイティブ → Dartのストリーミング。EventChannelをベースにした型付きのStream<T>を生成するので、連続するイベントのストリーム(位置情報の更新、ダウンロードの進捗、センサーデータ)もエンドツーエンドで型安全になります。EventChannelを手書きする必要も、StandardMessageCodecをパースする必要もありません。

Pigeonは依然として非同期で(呼び出しはfutureを返します)、依然としてコーデックを通してシリアライズするため、コアコンセプトで説明したコストモデルは変わりません。変わるのは正しさと保守性です。フィールド名を変えたりメソッドを追加したりすると、ランタイムで気づかないうちに失敗するのではなく、更新されるまですべてのプラットフォームでコンパイルが失敗します。進化するAPIに近い境界であれば、これがデフォルトの選択肢です。そして、このフレームワークもこれをベースに構築されています(後述)。

直接のFFI:ffigenとjnigen

FFIはまったく異なるモデルです。プラットフォームにメッセージを送るのではなく、Dartがdart:ffiを通じて、呼び出し元のisolate自身のスレッド上でネイティブコードを直接かつ同期的に呼び出します。メソッド名もコーデックも、プラットフォームスレッドへの切り替えもありません。バインディングを生成するツールは2つあります。

  • ffigenはC/C++のヘッダを読み込み、Dartのバインディングを生成します。ネイティブライブラリ(暗号、画像・音声処理、C ABI経由のRust)を使う場合や、大きなバッファをコピーせずポインタで共有する場合の手段です。この方法でSwift/Objective-Cを呼び出すには、C互換のインターフェースを公開する(またはobjective_cの相互運用パッケージを使う)必要があります。
  • jnigenはJNI経由でJava/KotlinへのDartバインディングを生成し、Kotlin側でチャネルハンドラを書かずに、DartからAndroid SDKやその他のJVM APIを直接呼び出せるようにします。

FFIが真価を発揮するのは、境界がプラットフォーム統合ではなく計算である場合です。

  • シリアライズのオーバーヘッドがありません。 ポインタとプリミティブを渡すため、タイトなループや大きなデータでは、コーデックを通してコピーするよりも劇的に高速です。
  • 同期的で直接的です。 非同期の往復がないため、アルゴリズムの内部で便利です。

FFIがadd-to-appのデフォルトの境界にならない理由

「チャネルもコーデックもなしにネイティブコードを直接呼び出せる」という点を見て、FFIを主要な境界にすべきだと結論づけたくなるかもしれません。特に、jnigenは既存のKotlinを、ffigenは既存のObjective-Cを対象にできるのでなおさらです。しかし実際には、これはデフォルトとしては誤りです。add-to-appの境界が実際に運ぶものは、FFIが得意とするものとうまく噛み合わないからです。

  • add-to-appの境界はライブラリ呼び出しではなく、プラットフォーム統合です。あなたがしているのは既存のアプリにFlutterを配線することです。そのアプリの画面へのナビゲーション、セッションの読み取り、UIKitやAndroidのUIツールキット、Activityやライフサイクル、権限、アナリティクスに触れるサービスの呼び出しなどです。そのほぼすべてがメインスレッドに縛られ、ステートフルです。FFIはプラットフォームのメインスレッドではなく呼び出し元のisolate自身のスレッドで動くため、add-to-appに満ちているまさにこの種の作業では、結局メインスレッドに戻る必要があります。それはチャネルがすでにやってくれていることです。FFIは自己完結していてスレッドに依存しない計算のために作られており、この種のプロジェクトではそれはまれなケースです。

  • FFIはDart → ネイティブの呼び出しのために作られています。FFIの自然な方向は、Dartがネイティブに手を伸ばして関数を呼び出すことです。add-to-appの境界は双方向でイベント駆動です。ネイティブ側がナビゲーションイベント、ライフサイクルの変化、状態の更新をFlutterにプッシュします。FFIでのネイティブ → Dartのコールバックも存在します(Dart 3.1以降のNativeCallable.listener)が、プラットフォームごとに別々に手作業で配線する必要があり、@FlutterApi(Pigeon)や@EventChannelApiが標準で提供する型付きのコントラクトは何も得られません。

  • 単一のクロスプラットフォームなバインディングがありません。 jnigenはJVM専用で、ffigenはC ABIを扱います。Swift/Objective-Cに到達するには、C互換のインターフェースを公開するか、objective_cパッケージに頼る必要があります。つまり「FFIを使う」とは、実際には互いに無関係なプラットフォーム固有のバインディング層を2つ保守することを意味します。一方、1つのPigeon定義からは単一のスペックでDart、Swift、Kotlinが生成されます。これこそ、add-to-appの境界が最も必要とする「どこでも型安全」という性質です。

  • メモリとABIの管理責任を負うことになります。ネイティブメモリの確保、解放、ファイナライズ、そしてプラットフォームごとのネイティブABIの追跡があなたの責任になります。チャネルなら無料で安全に管理してくれる境界のために、実際のオーバーヘッドと新たな種類のクラッシュを抱えることになります。

したがってadd-to-appプロジェクトでは、ナビゲーション、プラットフォームのサービス、状態の共有、ネイティブUIに触れるものはすべて、チャネルまたはPigeonを通します。FFIは、Flutterがネイティブライブラリ(C/C++/Rustのコーデックや暗号ルーチン)を呼び出す必要がある限られたケースや、シリアライズがボトルネックだと計測で確認されたホットパスのために取っておきます。そしてFFIはチャネルの境界を置き換えるのではなく、その横に並びます。

InlayがPigeonを使う部分と使わない部分

Inlayの境界は2つの異なるコード生成レイヤーで構成されており、実際にPigeonなのは下位のレイヤーだけです。2つのレイヤーは異なる問題を解決しているので、この区別は重要です。

トランスポートレイヤー:Pigeon

フレームワークの固定された汎用的なチャネルのコントラクトは、Pigeonで生成されています。コントラクトは2つあり、inlayプラグインの中にあります。

  • inlay_navigator:ナビゲーションのトランスポートです。ルートのプッシュ、戻る操作、ネイティブとFlutterのナビゲーションスタックの調整を担います。ペイロードは汎用的なエンベロープです(routeId、paramsのblob、path、そしてホストとモジュール間のスキーマのずれを検出するためのschemaFingerprintを持つPageSettings)。
  • key_value_storage:エンジン間の状態のトランスポートです。共有のKeyValueStorageに対する読み取り、書き込み、変更のブロードキャストを、汎用的なStorageEntry(key/value)レコードとして運びます。

これらは通常のPigeon定義(Dart → ネイティブには@HostApi、ネイティブ → Dartには@FlutterApi)で、フレームワークのpigeonスクリプトで再生成されます。このスクリプトはPigeonを実行したあと、Swiftの出力にパッチを当てます(このページ末尾のコールアウトを参照)。これらはあなたのドメインについては意図的に型を持ちません。Pigeonが運ぶのはルートIDと不透明なparamsのblobであり、フィールドを持つ具体的なProfileRouteではありません。

スキーマレイヤー:Pigeonではない独自のジェネレーター

Dart、Swift、Kotlinにまたがる型付きのProfileRoute/SettingsStoreクラスを提供するのはinlay_genであり、これは内部でPigeonを使っていません。Dartのanalyzerパッケージの上に直接構築された独立したジェネレーターです。アノテーション付きのスキーマクラスをパースし、型付きのルート/ストアクラスと、上で説明した汎用的なトランスポートのエンベロープへの、言語ごとの(デ)シリアライズのコードを出力します。

このモデルはPigeonに着想を得ています。スキーマファイルはスペックにすぎずアプリのコードからインポートされることはなく、1つの定義からすべてのプラットフォーム向けに対応する型が生成されます。ただし、実装はフレームワーク独自のものです。Pigeonはinlay_genから呼び出されることもインポートされることもなく、依存関係にも含まれていません。

つまり、2つのレイヤーは次のように積み重なっています。

  1. inlay_gen(独自)が型付きのルートとストアのクラスを生成し、それらのフィールドを汎用的なエンベロープにシリアライズします。
  2. Pigeonが、そのエンベロープをチャネル越しに運ぶトランスポートを生成します。

生成されたルートを呼び出したりストアのフィールドを読み取ったりするとき、あなたはinlay_genの出力を呼び出しています。それが型付きの値をPigeonのトランスポートに詰め込み、そのトランスポートがプラットフォームチャネルに乗ります。Dart、Swift、Kotlinでエンドツーエンドに型チェックされ、あなたのコードには手書きのチャネル名もdynamicなマップもありません。

フレームワークがPigeonのレイヤーで解消している落とし穴が1つあります。PigeonはpublicなSwiftの型を出力しませんが、プラグインのモジュール境界ではそれが必要です。InlayのPigeonビルドステップは、生成されたSwiftに必要なアクセス修飾子をパッチで追加します。これは内部の詳細で、フレームワーク自体のトランスポートチャネルを再生成する場合にのみ知っておけば十分です。Pigeonの出力をローカルのCocoaPodsでラップしている大規模なadd-to-appチームは、まさにこの問題に直面します。再生成のたびにpublic修飾子が気づかないうちに消え、パッチが自動化されるまで誰かが手作業で付け直すことになります。私たちがインタビューした中でも最大級のadd-to-appチームの1つは、この作業を、苛立たしくミスの起きやすい悩みの種として挙げていました。

プライバシーポリシー

このページの内容