【Flutter】MethodChannelは直接使うことなかれ。型安全で楽に実装できる「Pigeon」の使い方のほぼ全て。【Swift/Objective-C/Kotlin/Java/C++】

2022/10/29 19:27公開
2025/01/12 00:38最終更新

Flutterでネイティブとやり取りしたい時、一番最初に知るのはMethodChannelを使った方法だと思います。しかし、MethodChannelは直接使うと非常に使いづらいです。引数はおろか、メソッド名すらString型で渡す必要があり、IDEの入力支援も受けられず、タイプミスしたら実行するまでエラーが吐かれず、引数の個数や名前、型も全部自由で何でも渡せてしまいます。

これでは非常に開発効率が悪いです。そこで、Flutterの公式パッケージである Pigeon を使うとこれらの問題を解決できます。

Table of Contents
  1. Pigeonは何をしてくれる?
  2. 使う準備
  3. インターフェースの作成
  4. Pigeon実行用シェルスクリプトの作成
  5. Flutter側の実装
    1. Flutter側からネイティブコードを呼ぶ場合の例
    2. ネイティブ側からFlutter側のコードを呼ぶ場合
  6. Android (Kotlin) 側の実装
    1. IDEの準備
    2. Flutter側からネイティブコードを呼ぶ場合
    3. ネイティブ側からFlutter側のコードを呼ぶ場合
  7. iOS (Swift) 側の実装
    1. 準備
    2. Flutter側からネイティブコードを呼ぶ場合(Swiftで生成)
    3. Flutter側からネイティブコードを呼ぶ場合(Objective-Cで生成)
    4. メソッド名がおかしい場合
    5. BooleanやInt、Longなどの型が返せない場合
    6. ネイティブ側からFlutter側のコードを呼ぶ場合
  8. Windows側での実装
    1. 準備
    2. Flutter側からネイティブコードを呼ぶ場合
    3. ネイティブ側からFlutterコードを呼ぶ場合

Pigeonは何をしてくれる?

Pigeonは、Flutterエンジンとネイティブ間で呼び出し合う関数のテンプレートを1回書くだけで、それぞれのプラットフォームの言語で、関数の中身を書くための抽象クラスと、そこに書いた実装を呼び出すための関数を生成してくれます(「インターフェースの作成」節を参照)。その抽象クラスを各プラットフォームで継承し、実際の実装を書くことができます。あとはその継承したクラスを使い、生成されたセットアップ用関数で関数の呼び出し待機状態にすることで、生成された関数で呼び出せるようになります。

関数のテンプレート(インターフェース)では、返り値と引数の名前と型を指定できます。非同期関数も利用できます。ただし、サポートされているデータ型には制限がありますので、目を通しておくことをお勧めします。

使う準備

プロジェクトルートで以下のコマンドを実行します。

flutter pub add dev:pigeon

次に、Flutterとネイティブでやり取りするインターフェースを定義するDartファイルを作ります。プロジェクトルートにpigeon.dartを作り、中身を記述していきます。

まず、ネイティブ側の生成コードで使用する言語を選択します。iOSではSwiftとObjective-C、AndroidではKotlinとJavaが選択でき、どちらかを選んで生成する形となっています。新しい言語は対応が後回しになっている機能があるため古い言語(Objective-C、Java)で生成することを推奨します。どちらを選んでも任意の言語から呼び出すことが可能です。

設定が必要なプロパティの基本的な例は以下の通りですが、選択した言語によって設定する必要のあるプロパティが異なります。

言語 設定が必要なプロパティ
Swift swiftOut
Objective-C objcOptionsObjcOptions.prefix)、objcHeaderOutobjcSourceOut
Kotlin kotlinOutkotlinOptionsKotlinOptions.package
Java javaOutjavaOptionsJavaOptions.package
C++ cppOptionsCppOptions.namespace)、cppHeaderOutcppSourceOut

ちなみに、以下のコードは上から順番に書いていくと自動補完が効きません。先に適当な名前でクラスを作り、その上から@ConfigurePigeonを書き始めると自動補完が効くようになります。

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(PigeonOptions(
  dartOut: "lib/messages.g.dart", // Dartファイルの生成先(必須)
  
  swiftOut: "ios/Runner/Messages.g.swift", // iOS用Swiftの出力先
  
  objcOptions: ObjcOptions(
    prefix: "FLT" // iOS用に生成されるObjective-Cのクラス名の接頭辞
  ),
  objcHeaderOut: "ios/Runner/messages.g.h", // iOS用Objective-Cヘッダーの出力先
  objcSourceOut: "ios/Runner/messages.g.m", // iOS用Objective-C実装の出力先
  
  kotlinOut: "android/app/src/main/kotlin/com/example/pigeon/Messages.g.kt", // Android用Kotlinの出力先 (※ com/example/pigeon の部分は実際のパスに変更)
  kotlinOptions: KotlinOptions(
    package: "com.example.pigeon", // 生成するAndroid用Kotlinのパッケージ名
  ),
  
  javaOut: "android/app/src/main/java/com/example/pigeon/Messages.g.java", // Android用Javaの出力先 (※ com/example/pigeon の部分は実際のパスに変更)
  javaOptions: JavaOptions(
    package: "com.example.pigeon" // 生成するAndroid用Javaのパッケージ名
  ),
  
  cppHeaderOut: "windows/runner/messages.g.h", // Windows用C++ヘッダーの出力先
  cppSourceOut: "windows/runner/messages.g.cpp", // Windows用C++ソースの出力先
  cppOptions: CppOptions(
    namespace: "example" // 任意の名前空間名(普通はアプリのパッケージ名)
  ),
))
class ExampleApi {} // この行は下でインターフェースを作成したら消して問題ありません

インターフェースの作成

次に、やり取りするメソッドを定義するクラスを作ります。このクラスをもとに、各プラットフォームのコードが生成されます。同じファイルの下に以下のように記述します。

@HostApi() // Flutter --(call)--> Native
abstract class ExampleHostApi {
  ///
  /// ドキュメントは全プラットフォームに反映されます
  ///
  void example();

  void openUrl(String url);

  StateResult queryState(); // Enum(列挙型)およびカスタムクラスも使用可

  @async
  String getToken(); // 非同期メソッド(※Futureの戻り値は直接使用できないが、生成されたコードでは戻り値がFutureでラップされる)
}

enum State {
  pending,
  success,
  error,
}

class StateResult {
  String? errorMessage;
  late State state; // 非null許容の場合はlateキーワードを付与
}

@FlutterApi() // Native --(call)--> Flutter
abstract class ExampleFlutterApi {
  void handleUri(String uri);
}

Pigeon実行用シェルスクリプトの作成

Pigeonで生成処理を走らせるシェルスクリプトを作っておくと便利です。プロジェクトルートにrun_pigeon.shという名前で以下のように書きます。

#!/bin/bash

dart pub run pigeon --input pigeon.dart

シェルスクリプトに実行権限がない場合があるので与え、実行します。

% chmod u+x ./run_pigeon.sh
% ./run_pigeon.sh

何も表示されなければ完了です。

Flutter側の実装

以下はどのプラットフォーム(ホスト)でも共通の内容です。

Flutter側からネイティブコードを呼ぶ場合の例

import "messages.g.dart"

ExampleHostApi().openUrl("https://example.com");
await ExampleHostApi().getToken();

ネイティブ側からFlutter側のコードを呼ぶ場合

lib/api/example_flutter_api_impl.dartのようなファイルに以下のように書き、Flutter側で実行するコードを記述しておきます。

class ExampleFlutterApiImplementation implements ExampleFlutterApi {
  @override
  void handleUri(String uri) {
    // ...
  }
}

次に、この実装を用いて、ネイティブ側からのメッセージ(メソッド呼び出し)をFlutter側で受け取れるようにセットアップします。ここではlib/main.dartで初期化していますが、任意の場所に書けます。

void main() {
  ExampleFlutterApi.setup(ExampleFlutterApiImplementation());
}

Android (Kotlin) 側の実装

IDEの準備

今回はKotlinで記述していく前提で話を進めます(Javaの場合は適宜読み替えてください)。まず、androidディレクトリをAndroid StudioもしくはIntelliJ IDEAで開いてください。

Gradle Syncの処理が完了するまで待ってから、Projectツールウィンドウから赤枠のドロップダウンより「Android」を選択します。「app」→「kotlin+java」を展開します。

image.webp

Flutter側からネイティブコードを呼ぶ場合

アプリのパッケージ名を右クリックし、「New」→「Kotlin Class/File」を選択します。

image.webp

任意の名前(例えばExampleHostApiImplementation)でクラスを作成します。このクラスに、Pigeonが生成したExampleHostApiクラスを継承させ、ネイティブ側での実装を記述します。

class ExampleHostApiImplementation() : ExampleHostApi {
    override fun openUrl(url: String) {
          // ...
    }

    override fun getToken(result: Result<String>) { // async function
        FirebaseMessaging.getInstance().token.addOnSuccessListener {
            result.success(it)
        }.addOnFailureListener {
            result.error(it)
        }
    }
}

そして、Flutter側からのメソッド呼び出しを受け取れるよう、初期化します。

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        ExampleHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ExampleHostApiImplementation()) // 初期化

        // ...
    }
}

ネイティブ側からFlutter側のコードを呼ぶ場合

ネイティブ側から呼ぶ(FlutterApi)場合は以下のようにします。binaryMessengerは、MainActivityの中に変数を作って保持しておくと使いやすいと思います。

ExampleFlutterApi(flutterEngine.dartExecutor.binaryMessenger).handleUri(uri) {}

iOS (Swift) 側の実装

準備

まず、XcodeでiOS用ワークスペースを開きます。Finderにてios/Runner.xcworkspaceをダブルクリックして開きます(もしくはターミナルでopen ios/Runner.xcworkspaceでも可)。

Xcodeが開いたら、左のProject navigatorで「Runner」→「Runner」と展開しておきます。

次に、Pigeonによって生成されたファイルをプロジェクトで使えるようにします。下の「Runner」を右クリックし、「Add Files to "Runner"...」をクリックします。

image.webp

生成されたmessages.g.swift(またはmessages.g.hmessages.g.m)を選択します。次に表示されたダイアログでは、「Reference files in place」を選択し、Finishをクリックします。

image.webp

続いて、Pigeonの生成言語でObjective-Cを選択し、かつ実装をSwiftで書く方は Runner-Bridging-Header.hに以下を追記します(ない場合は同じ名前で作成してください)。これをしないとSwift側からObjective-Cのコードを使用できません。messages.g.hの部分は、生成されたファイルの名前です。

#import "messages.g.h"

Flutter側からネイティブコードを呼ぶ場合(Swiftで生成)

以下は、生成コードとして Swift を選択した場合の書き方です。

「Runner」を右クリックして「New Empty File」から任意の名前(例えば「ExampleHostApiImplementation.swift」)でSwiftファイルを作成します。

class ExampleHostApiImplementation: ExampleHostApi {
    func openUrl(url: String) throws {
        <#code#>
    }
    
    func isDarkMode() throws -> Bool {
        return false;
    }
    
    func getToken(completion: @escaping (Result<String, any Error>) -> Void) {
        completion(.success("token"))
    } // Swiftの非同期関数を利用することは、Swift生成コードではPigeon v22.7.0現在できないようです。
}

Flutter側からネイティブコードを呼ぶ場合(Objective-Cで生成)

以下は、生成コードとして Objective-C を選択した場合の書き方です。

「Runner」を右クリックして「New Empty File」から任意の名前(例えば「ExampleHostApiImplementation.swift」)でSwiftファイルを作成します。Objective-Cで書く場合は、適宜読み替えてください。

class ExampleHostApiImplementation: NSObject, FLTExampleHostApi { // 継承するクラス名は、接頭辞としてConfigurePigeonのObjcOptions.prefixで設定したものが付きます。
  func openUrl(_ url: String, error: AutoreleasingUnsafeMutablePointer<FlutterError?>) { // @ObjCSelector("openUrl:")
      <#code#>
    }
  
  func isDarkModeWithError(_ error: AutoreleasingUnsafeMutablePointer<FlutterError?>) -> NSNumber? {
        return false as NSNumber
    }

        func getTokenWithCompletion(_ completion: @escaping (String?, FlutterError?) -> Void) {
        completion("token", nil)
    }

//    または
//    func token() async -> (String?, FlutterError?) {
//        return ("token", nil)
//    }
}

そして、Flutter側からのメッセージをネイティブ側で受け取れるよう、AppDelegate.swiftで初期化します。

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // ...
    // -- 追記 --
    guard let controller = window?.rootViewController as? FlutterViewController else {
      GeneratedPluginRegistrant.register(with: self)
      return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    ExampleHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ExampleHostApiImplementation())
    
    // -- 追記ここまで --
      
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

メソッド名がおかしい場合

と、ここで違和感に気づく場合があると思います。メソッド名がなんかおかしいです。実はObjective-CやSwiftでは「セレクター」と呼ばれるIDでメソッドを識別しており、それには特殊な命名規則が存在します。しかしPigeonでコードを生成すると、なぜか適切なセレクターが設定されません。詳しい命名規則は割愛しますが、セレクターは@ObjCSelectorアノテーションでカスタマイズすることが可能です。

pigeons/messages.dart

@ObjCSelector("openUrl:")
void openUrl(String url);

@ObjCSelector("findUserWithName:AgeOf:")
void findUser(String name, int age);

先程のrun_pigeon.shを実行します。

ios/Runner/ExampleIOSApi.swift

func openUrl(_ url: String, error: OpaquePointer) {
        <#code#>
}
func findUser(withName name: String, ageOf age: Int, error: OpaquePointer) {
        <#code#>
    }

BooleanやInt、Longなどの型が返せない場合

BooleanやInt、Longなどの型を戻り値に設定すると、ネイティブのコードでは戻り値がNSNumberに設定されるため、Swiftの型はそのままでは返せません。そのため、as NSNumberでキャストします。

completion(true as NSNumber, nil)
completion(50 as NSNumber, nil)

ネイティブ側からFlutter側のコードを呼ぶ場合

Kotlin等と同じように呼ぶことができます。

ExampleFlutterApi(binaryMessenger).handleUri(uri)

binaryMessengerは、Kotlin同様、AppDelegateに変数を作って保持しておくと良いでしょう。

Windows側での実装

準備

Visual Studioが必要ですので、事前にインストールしておきます。ワークロードは「C++によるデスクトップ開発」を選択します。

まず、一度flutter run windowsを実行し、ソリューションファイルを生成します。

Visual Studioを開き、「プロジェクトやソリューションを開く」をクリックし、「(Flutterプロジェクトルート)\build\windows\x64\(アプリ名).sln」を開きます。右側のソリューションエクスプローラーで「(アプリ名)」→「Source Files」を展開しておきます。

CMakeLists.txtを開き、add_executableの中に生成されたファイルを追加します。

# ...
add_executable(${BINARY_NAME} WIN32
  "flutter_window.cpp"
  "main.cpp"
  "utils.cpp"
  "win32_window.cpp"
  "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
  "Runner.rc"
  "runner.exe.manifest"
  "messages.g.cpp" # 追加
)
# ...

Flutter側からネイティブコードを呼ぶ場合

ソリューションエクスプローラーでflutter_window.cppをダブルクリックして開きます。エディター側でCtrl+K、Ctrl+Oの順に押し、ヘッダーファイルに切り替えます。以下を追記します。「名前空間」とは、上でCppOptions.namespaceに設定した値です。

// A window that does nothing but host a Flutter view.
class FlutterWindow : public Win32Window {
// ...
 private:
  // The project to run.
  flutter::DartProject project_;

  std::unique_ptr<example::ExampleHostApi> exampleHostApi_; // 追記 (std::unique_ptr<名前空間::APIクラス名> APIクラス名のキャメルケース+アンダースコア)

  // The Flutter instance hosted by this window.
  std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};

再度Ctrl+K、Ctrl+Oの順に押して実装ファイルに切り替え、以下を追記します。

#include "flutter_window.h"

#include <flutter/binary_messenger.h> // 追記

#include <memory> // 追記
#include <optional>

#include "flutter/generated_plugin_registrant.h"
#include "messages.g.h" // 追記

// 追記ここから
namespace {
    using example::ExampleHostApi;
    using example::ErrorOr;
    using example::FlutterError;

    class ExampleHostApiImplementation : public ExampleHostApi {
    public:
        ExampleHostApiImplementation() {}
        virtual ~ExampleHostApiImplementation() {}

       void OpenUrl(
           const std::string& url
       ) {
           // 何らかの処理
       }
       
       void GetToken(
           std::function<void(ErrorOr<std::string> reply)> result
       ) {
           // 何らかの処理
           result(std::string("token"));
       }
    };
}
// 追記ここまで

FlutterWindow::FlutterWindow(const flutter::DartProject& project)
    : project_(project) {}

FlutterWindow::~FlutterWindow() {}

// ...

そして、Flutter側からのメッセージをネイティブ側で受け取れるよう、初期化します。

// ...
bool FlutterWindow::OnCreate() {
  if (!Win32Window::OnCreate()) {
    return false;
  }

  RECT frame = GetClientArea();

  // The size here must match the window dimensions to avoid unnecessary surface
  // creation / destruction in the startup path.
  flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
      frame.right - frame.left, frame.bottom - frame.top, project_);
  // Ensure that basic setup of the controller was successful.
  if (!flutter_controller_->engine() || !flutter_controller_->view()) {
    return false;
  }
  RegisterPlugins(flutter_controller_->engine());
  SetChildContent(flutter_controller_->view()->GetNativeWindow());

  exampleHostApi_ = std::make_unique<ExampleHostApiImplementation>(); // 追記
  ExampleHostApi::SetUp(flutter_controller_->engine()->messenger(), exampleHostApi_.get()); // 追記

  return true;
}

// ...

ネイティブ側からFlutterコードを呼ぶ場合

wip