前回は、1つのコードをiPhone・Android・Web・Mac・Windowsの5つに出しました。そのとき分かったのは、「1つのコードで動く」のはアプリの中身だけだということです。通信の許可のようなOSへのお願いごとは、器ごとに作法が違いました

今回はその一段深いところへ行きます。Dartから直接触れないOSの機能に、自分で橋を架ける話です。しかもその橋の架け方には複数のやり方があり、選ぶもので結果が変わります。同じ題材を3通りで作って、実際に比べました😊

🌉 この記事でわかること
・ネイティブを呼ぶ3つの構成(MethodChannel/Pigeon/dart:ffi+C++)の違いを図解
・同じ題材を3構成で書いたDart・Swift・Kotlin・C++の実装コード
往復時間の実測(iOS・Android・macOS・Windowsの4つ)。橋だけならFFIが約300倍速い
・型安全性・呼び分けの置き場所・スレッド・ビルド配線の違い
どれを選ぶかの判断材料

環境:Flutter 3.44.2 / Dart 3.12.2 / Pigeon 25.5.0。Mac(Xcode 26.5・Android SDK 36)で iOSシミュレータ・Androidエミュレータ・macOS を、Windows 11+Visual Studio でWindows版を確認しました。スマホの実機は使っていません。

「1つのコード」で済まない所がある

スポンサーリンク

Flutterは前回書いたとおり、OSの部品を借りずに自分で画面を描きます。だから見た目は1つのコードで済みます。ところが「端末の名前を知りたい」「OSらしいダイアログを出したい」「バッテリー残量を読みたい」となると話が変わります。これらはOSしか知らない情報で、Dartからは手が届きません。

そこで橋を架けます。今回はどの構成でも、同じ4つの命令を渡します。

  • deviceInfo():端末名とOSバージョン(=橋が正しく架かった証拠)
  • showNativeUi()その器らしいUIを出す(iOSはアラート、Androidはトースト、macOSはシート、WindowsはMessageBox)
  • batteryLevel():バッテリー残量
  • ping()何もせず0を返すだけ。橋の往復コストだけを測るためのもの

3つの構成(図解)

橋の架け方は1つではありません。今回比べたのはこの3つです。

A. 手書き MethodChannel Dart(命令名は文字列) ↓ 非同期メッセージ チャンネル(文字列で照合) Swift / Kotlin / C++ 器ごとに handler を書く 呼び分けが器の数だけ散る 綴り間違いは実行時に発覚 B. Pigeon(コード生成) 定義ファイル 1枚(Dart) ↓ 4言語を自動生成 チャンネル(生成コードの中) Swift / Kotlin / C++ 生成された型を実装する 実装漏れはコンパイルエラー 呼び分けは各言語に残る C. dart:ffi + C++ Dart(関数を直接呼ぶ) ↓ 同期・直接呼び出し C++ ディスパッチャ(1か所) ↓ 器ごとの実装へ振り分け Swift(@_cdecl) / Kotlin(JNI) Windows は Win32 を直呼び 呼び分けはC++の1か所に集約 スレッドは自分で面倒を見る 同じ4つの命令を、3構成すべてに実装して比べた deviceInfo(端末情報) / showNativeUi(OSらしいUI) / batteryLevel(電池) / ping(空呼び出し) A・B は Flutter のメッセージ経路(非同期)を通る。C だけが経路を通らず、関数を直接呼ぶ。 Windows の C は間に別言語を挟まない = 4器の中で唯一「橋が1本短い」。
AとBは同じ経路(メッセージ)を通る。Cだけが経路を通らず、C++の関数を直接呼ぶ

構成A:手書きのMethodChannel

いちばん素朴なやり方です。チャンネル名も命令名も文字列で、引数はMapで渡します。

// Dart側
const _channel = MethodChannel('com.eight.bridge_core/native');

Future<int> batteryLevel() async => await _channel.invokeMethod<int>('batteryLevel') ?? -1;

Future<void> showNativeUi(String message) =>
    _channel.invokeMethod<void>('showNativeUi', {'message': message});

受け取る側は、器ごとに書きます。iOS(Swift)はこう。

// iOS: SceneDelegate から設置する(後述のつまずき①)
let channel = FlutterMethodChannel(name: "com.eight.bridge_core/native",
                                   binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { call, result in
  switch call.method {
  case "batteryLevel":
    UIDevice.current.isBatteryMonitoringEnabled = true
    let lv = UIDevice.current.batteryLevel      // シミュレータは -1.0
    result(lv < 0 ? -1 : Int(lv * 100))
  case "showNativeUi":
    let msg = (call.arguments as? [String: Any])?["message"] as? String ?? ""
    let alert = UIAlertController(title: "ネイティブから", message: msg, preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .default))
    controller?.present(alert, animated: true)
    result(nil)
  default:
    result(FlutterMethodNotImplemented)
  }
}

Android(Kotlin)も同じ形。この「文字列で分岐する塊」を、器の数だけ書きます。しかもコンパイラは何も守ってくれません。'batteryLevl' と綴りを間違えても、実行して初めて例外で落ちます😳

構成B:Pigeon(定義1枚から4言語を生成)

Flutter公式のコード生成ツールです。定義を1枚書くと、Dart・Swift・Kotlin・C++ が生成されます

// pigeons/native_api.dart ——★これ1枚だけ書く
@HostApi()
abstract class NativeApi {
  int ping();
  int batteryLevel();
  DeviceInfoData deviceInfo();      // 戻り値もクラスで表現できる
  void showNativeUi(String message);
}

class DeviceInfoData {
  DeviceInfoData({required this.model, required this.os});
  final String model;
  final String os;
}

あとは dart run pigeon --input pigeons/native_api.dart。生成されたprotocol(Swift)/interface(Kotlin)/抽象クラス(C++)を実装するだけです。

// iOS: 生成された protocol を実装する。命令名の文字列は出てこない
class PigeonNativeApi: NSObject, NativeApi {
  func ping() throws -> Int64 { 0 }

  func batteryLevel() throws -> Int64 {
    UIDevice.current.isBatteryMonitoringEnabled = true
    let lv = UIDevice.current.batteryLevel
    return lv < 0 ? -1 : Int64(lv * 100)
  }

  func deviceInfo() throws -> DeviceInfoData {
    DeviceInfoData(model: UIDevice.current.model, os: "iOS " + UIDevice.current.systemVersion)
  }
  // showNativeUi を書き忘れると ★コンパイルエラー★
}

ここが効きます。実装漏れがコンパイル時に止まる。綴り間違いは、そもそも書けません。

構成C:dart:ffi + C++ディスパッチャ

3つ目は毛色が違います。Flutterのメッセージ経路を一切使いません。DartからC言語の関数を直接呼び、呼び分けはC++の1か所にまとめます。

// Dart側: チャンネル名もMapも出てこない。関数を引くだけ
final DynamicLibrary _lib = Platform.isAndroid
    ? DynamicLibrary.open('libbridge_core.so')
    : DynamicLibrary.process();     // iOS/macOSは実行ファイルに同梱される

final int Function() batteryLevel =
    _lib.lookupFunction<Int32 Function(), int Function()>('bridge_battery_level');
// C++: 呼び分けは【このファイル1か所】だけ
namespace platform {
int battery_level();                 // 器ごとに1つだけ実装される
void show_native_ui(const char* message);
}

extern "C" int bridge_battery_level(void) { return platform::battery_level(); }
extern "C" void bridge_show_native_ui(const char* m) { platform::show_native_ui(m); }

その先は器ごとです。iOS・macOSはSwiftをC言語の関数として公開します。@_cdecl を付けるだけで、C++から普通の関数として呼べます。

// Swift: @_cdecl でC ABIに晒す(Xcodeの相互運用設定に頼らないので堅い)
@_cdecl("swift_show_native_ui")
public func swift_show_native_ui(_ message: UnsafePointer<CChar>) {
  let text = String(cString: message)
  // ★DartはUIスレッドで走る=プラットフォームのメインスレッドではない。必ず渡し直す
  DispatchQueue.main.async { /* UIAlertController / NSAlert を出す */ }
}

AndroidはKotlinをJNIで呼びます。ここが今回いちばん骨のある部分でした。

// C++ → Kotlin(JNI)
static JavaVM* g_vm; static jclass g_bridge;

extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
  g_vm = vm;
  JNIEnv* env; vm->GetEnv((void**)&env, JNI_VERSION_1_6);
  // ★ここでクラス参照をキャッシュする。後から別スレッドで FindClass しても
  //   アプリのクラスは見つからない(JNIの古典的な罠)
  jclass local = env->FindClass("com/eight/bridge_core_example/NativeBridge");
  g_bridge = (jclass)env->NewGlobalRef(local);
  return JNI_VERSION_1_6;
}

namespace platform {
int battery_level() {
  JNIEnv* env; bool attached = false;
  if (g_vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) {
    g_vm->AttachCurrentThread(&env, nullptr);   // ★DartのスレッドはJavaのスレッドではない
    attached = true;
  }
  jmethodID mid = env->GetStaticMethodID(g_bridge, "batteryLevel", "()I");
  int level = env->CallStaticIntMethod(g_bridge, mid);
  if (attached) g_vm->DetachCurrentThread();
  return level;
}
}

そしてAndroidだけは、Kotlin側に起動時のひと仕事が残ります。DartがライブラリをdlopenするだけではJNIの初期化関数が呼ばれないため、Kotlinから一度だけ読み込ませる必要があるのです。

// Kotlin: ここが「完全にC++起点」にできない理由
object NativeBridge {
    private var activity: Activity? = null

    fun bootstrap(a: Activity) {
        activity = a                      // Toastにも電池にも Context が要る
        System.loadLibrary("bridge_core") // ← これで JNI_OnLoad が走る
    }

    @JvmStatic fun batteryLevel(): Int { /* BatteryManager から読む */ }
    @JvmStatic fun showNativeUi(message: String) {
        activity?.runOnUiThread { Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() }
    }
}

いっぽうWindowsは、間に別の言語を挟みません。C++がそのままWin32を呼ぶので、4つの器の中でここだけ橋が1本短いのです。

// Windows: C++ から Win32 を直呼び
namespace platform {
int battery_level() {
  SYSTEM_POWER_STATUS s;
  if (GetSystemPowerStatus(&s) && s.BatteryLifePercent != 255)
    return (int)s.BatteryLifePercent;
  return -1;                       // バッテリーが無い(デスクトップPC)
}
}

4つの器で動かす

3構成を1つのアプリに全部載せて、iOS・Android・macOS・Windowsで動かしました。同じ画面に3枚のカードが並び、それぞれが別の橋を使っています。

iOS・Android・macOS・Windowsで3構成すべてが動作している画面の比較
左からiOS・Android・macOS・Windows。3構成(A/B/C)とも端末情報とバッテリーを取得できている。iOSとWindowsのバッテリーが「取得できません(-1)」なのは後述

showNativeUi() は、同じDartのコードから器ごとに違う見た目が出ます。ここは目で見て気持ちがいいところです👍

同じ命令から、iOSはアラート、Androidはトースト、macOSはシート、WindowsはMessageBoxが出ている
同じ showNativeUi("...") から、iOSはアラート、Androidはトースト、macOSはシート、WindowsはMessageBox。Dart側のコードは1行も違いません

そしてバッテリーが正直な落とし穴でした。Flutter公式がPlatform Channelの例として使うのが、よりによってバッテリー残量なのですが——

  • iOSシミュレータには電池がありません。3構成とも -1 を返します
  • Windowsのデスクトップ機にも電池がありません。同じく -1
  • Androidエミュレータは 100%(残量も充電状態も自由に変えられます)、MacBookは実際の残量

ここで大事なのは、「取れない」を -1 という値で誤魔化さないことです。ネイティブ側からエラーとして返し、Dart側で例外として受け取れば、アプリは正直に「この器では取得できません」と言えます。

実測:往復にかかる時間

条件を揃えるため、同じ命令を2000回呼んで平均を出しました(ウォームアップ300回、計測は2周して2回目を採用)。2種類測っています。

  • 橋だけping):行って戻るだけ。ただし構成CのpingはC++で完結し、SwiftやKotlinまでは行きません
  • ネイティブまでbatteryLevel):3構成とも必ずネイティブ言語まで到達します=端まで揃えた比較
A. MethodChannelB. PigeonC. FFI+C++
橋だけ(ping)
iOS18.6 µs21.7 µs0.18 µs
Android55.4 µs54.9 µs0.15 µs
macOS18.8 µs15.7 µs0.20 µs
Windows19.0 µs19.5 µs0.06 µs
ネイティブまで(batteryLevel)
iOS13.8 µs16.2 µs0.20 µs
Android230.9 µs233.3 µs166.1 µs
macOS58.7 µs60.4 µs38.6 µs
Windows21.3 µs19.1 µs0.29 µs

※デバッグビルドでの計測。数µsのばらつきがあるので、細かい上下より桁を見てください。

読み取れたことが3つあります。

  • AとBの速度はほぼ同じです。Pigeonは型安全な皮であって、通り道(非同期のメッセージ経路)は同じもの。生成コードにしても速くはなりません
  • 橋そのものはFFIが桁違い。Windowsでは19µs → 0.06µsで、約300倍の差が出ました。メッセージの梱包もイベントループの往復も無いためです
  • ただし端まで行かせると差が縮みます。Androidでは、FFIでも166µs。遅さの正体は橋ではなく、橋の向こう側——JNIでJVMにスレッド参加するコストが支配的でした。macOSも同様に、電池を読むIOKitの呼び出しが重い

つまり「FFIにすれば速くなる」ではなく、「橋の通行料は安いが、渡った先の仕事は変わらない」が正確な理解です。この分解ができたのが、今回いちばんの収穫でした😊

Windowsで3構成の往復時間を測った画面。FFIだけ0.06µsと桁違いに速い
Windows版。FFIの「橋だけ」は0.06µs=60ナノ秒。上2つは19µs前後で、ほぼ同じ

つまずき集

3構成×4器で、それなりに転びました😅 どれも「知っていれば5分、知らないと1時間」の類です。

① iOSは橋を架ける場所が変わっていた

教科書どおり AppDelegate でチャンネルを設置したら、アプリが起動直後にクラッシュしました。Flutter 3.4x のiOSテンプレートはSceneDelegate方式になっていて、AppDelegate の時点ではまだ画面が存在しません。落ちなかった場合も「実装が見つからない」という例外になります。

// ✗ AppDelegate では window / rootViewController がまだ無い
// ◯ SceneDelegate の scene(_:willConnectTo:) で設置する
class SceneDelegate: FlutterSceneDelegate {
  override func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
                      options: UIScene.ConnectionOptions) {
    super.scene(scene, willConnectTo: session, options: options)
    guard let controller = window?.rootViewController as? FlutterViewController else { return }
    NativeBridgeSetup.install(controller: controller)   // ここでMethodChannelとPigeonを設置
  }
}

② UIは「メインスレッドへ渡し直す」必要がある(FFIのとき)

これはFFI特有です。DartのコードはUIスレッドで走りますが、それはOSのメインスレッドではありません。FFI経由でそのまま UIAlertControllerToast を触ると落ちます。Swiftなら DispatchQueue.main.async、Kotlinなら runOnUiThread で渡し直します。

MethodChannelとPigeonは、これを黙ってやってくれていました。便利さの正体が分かる瞬間です。

③ Windowsで初めてビルドするときの関門が2つ

Windows版は、いつものWindows機に検証を頼みました。するとMacでは一生出会わない壁が2つ出ました。

  • 開発者モードが要る:プラグインを含むFlutterプロジェクトは、シンボリックリンクで依存を解決します。Windowsでこれを作るには開発者モードをオンにする必要があります。前回は純Dartのパッケージだけだったので踏みませんでした
  • /utf-8 が要るMSVCはソースを日本語WindowsのANSIコードページとして読みます。日本語コメントのUTF-8が誤読されて行末が壊れ、無関係な行で構文エラーが噴き出します。CMakeに /utf-8 を足せば解決。ClangはUTF-8が既定なので、Mac側では一切見えない問題でした

④ 「コード生成だから安全」は半分だけ本当

これが今回いちばん驚いた発見です。Pigeonが生成したC++のコードが、そのままではコンパイルできませんでした

// Pigeonが生成したコード(NativeApi::SetUp の内側)
ErrorOr<DeviceInfo> output = api->DeviceInfo();   // ✗ コンパイルエラー C2923

原因はC++の名前解決です。メンバ関数の DeviceInfo が、同名のクラス DeviceInfo を隠してしまうため、型として解釈できません。データクラス名とメソッド名が衝突すると、C++のジェネレータだけが壊れたコードを吐くのです(Swift・Kotlinでは起きません)。

直し方は簡単で、定義ファイルのクラス名を DeviceInfoDeviceInfoData に変えるだけ。ただし、「生成されたコードだから安心」とは言い切れないことは覚えておく価値があります。生成器の成熟度はターゲット言語ごとに違い、C++(=Windows)が最も踏まれていないようです。

結局どれを選ぶか

4器ぶん書いてみて分かった性格の違いを、表にまとめます。

A. MethodChannelB. PigeonC. FFI+C++
間違いに気づくのは実行時(例外)コンパイル時リンク時/実行時
呼び分けの置き場所器ごとに散る器ごとに散る(型は守られる)C++の1か所
往復の速さ十数〜数十µs同左(速くならない)1µs未満
非同期/スレッド自動でメインスレッド自動でメインスレッド同期。自分で渡し直す
C++で書くときの量多い(型の取り出しが冗長)多い(ErrorOr の作法)少ない
向いている用途小さな橋を1本だけ橋が増えて壊れやすくなったとき高頻度の呼び出し/C++資産の活用

eightの結論はこうです。

  • 命令が数個なら A で十分。素直で読みやすい
  • 橋が増えてきたら B。文字列と型を人間が守る作業をコンパイラに任せられる。速くはならないが、壊れにくくなる
  • 1フレームに何度も呼ぶ・C++の資産を使いたいなら C。ただしスレッドの面倒は自分で見る覚悟が要る

そして忘れてはいけないのが、どの構成でも「1つの命令のために、器の数だけネイティブ実装が要る」ことです。showNativeUi というたった1つの命令のために、eightはSwift・Kotlin・Swift(macOS)・C++の4つを書きました。これがFlutterの「1つのコードで動く」に付く注釈です。

まとめ

Flutterからネイティブを呼ぶ3つの構成を、同じ題材で作って比べました。

  • A(MethodChannel)は文字列とMapで殴る素朴な橋。間違いは実行時まで分からない
  • B(Pigeon)は定義1枚から4言語を生成。型は守られるが、速くはならない。C++の生成コードには穴もあった
  • C(FFI+C++)は呼び分けをC++の1か所に集約でき、橋の往復は約300倍速い。ただしスレッドは自分で面倒を見る
  • 遅さの正体は橋ではなく橋の向こう(AndroidのJNI、macOSのIOKit)。速くしたいなら、まずそこを見る
  • Androidだけは完全にC++起点にできない。Kotlin側に起動時のひと仕事が残る

「1つのコードで全部動く」の外側には、こういう世界が広がっています。橋を4本架けてみると、Flutterが普段どれだけの面倒を肩代わりしてくれているかが、よく分かりました👍

それでは、今回はここまで。最後までありがとうございました😊