前回は、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
いちばん素朴なやり方です。チャンネル名も命令名も文字列で、引数は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枚のカードが並び、それぞれが別の橋を使っています。

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

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. MethodChannel | B. Pigeon | C. FFI+C++ |
|---|---|---|---|
| 橋だけ(ping) | |||
| iOS | 18.6 µs | 21.7 µs | 0.18 µs |
| Android | 55.4 µs | 54.9 µs | 0.15 µs |
| macOS | 18.8 µs | 15.7 µs | 0.20 µs |
| Windows | 19.0 µs | 19.5 µs | 0.06 µs |
| ネイティブまで(batteryLevel) | |||
| iOS | 13.8 µs | 16.2 µs | 0.20 µs |
| Android | 230.9 µs | 233.3 µs | 166.1 µs |
| macOS | 58.7 µs | 60.4 µs | 38.6 µs |
| Windows | 21.3 µs | 19.1 µs | 0.29 µs |
※デバッグビルドでの計測。数µsのばらつきがあるので、細かい上下より桁を見てください。
読み取れたことが3つあります。
- AとBの速度はほぼ同じです。Pigeonは型安全な皮であって、通り道(非同期のメッセージ経路)は同じもの。生成コードにしても速くはなりません
- 橋そのものはFFIが桁違い。Windowsでは19µs → 0.06µsで、約300倍の差が出ました。メッセージの梱包もイベントループの往復も無いためです
- ただし端まで行かせると差が縮みます。Androidでは、FFIでも166µs。遅さの正体は橋ではなく、橋の向こう側——JNIでJVMにスレッド参加するコストが支配的でした。macOSも同様に、電池を読むIOKitの呼び出しが重い
つまり「FFIにすれば速くなる」ではなく、「橋の通行料は安いが、渡った先の仕事は変わらない」が正確な理解です。この分解ができたのが、今回いちばんの収穫でした😊

つまずき集
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経由でそのまま UIAlertController や Toast を触ると落ちます。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では起きません)。
直し方は簡単で、定義ファイルのクラス名を DeviceInfo → DeviceInfoData に変えるだけ。ただし、「生成されたコードだから安心」とは言い切れないことは覚えておく価値があります。生成器の成熟度はターゲット言語ごとに違い、C++(=Windows)が最も踏まれていないようです。
結局どれを選ぶか
4器ぶん書いてみて分かった性格の違いを、表にまとめます。
| A. MethodChannel | B. Pigeon | C. 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が普段どれだけの面倒を肩代わりしてくれているかが、よく分かりました👍
それでは、今回はここまで。最後までありがとうございました😊