Flutter公式のバインディングが存在しないサードパーティ製Cライブラリを、AIエージェント(Claude Code)に「Native Assets(Hooks)」でFlutter/Dartブリッジとして書かせ、iOS/Android両方の実機・シミュレータで実際に動かすまで検証しました。
実装自体はAIが一度で書けましたが、その後の実機検証で プラットフォーム固有の罠に2つ ハマりました。1つはAndroid側のネイティブリンクエラー、もう1つはiOS側でXcodeBuildMCPによるUI自動操作が効かなくなる問題です。どちらも解決できたので、その過程を実際のログ・エラーメッセージ・コードとともに残します。
「AIが一発で書いたコードほど、両プラットフォームで実際に動かして検証する価値がある」という話でもあります。
背景: Native Assets(Hooks)とは
近年のFlutterで使えるようになった Native Assets(Hooks) という機能を使うと、Dartパッケージの中に hook/build.dart というビルドフックを1つ書くだけで、Cソースをコンパイルしてネイティブライブラリとしてバンドルし、Dartの @ffi.Native<...>() から直接呼び出せるようになります。
これの何が嬉しいかというと、iOS側のCocoaPods(Podfile)もAndroid側のGradle/CMakeLists.txtも一切書かずに済む 点です。hook/build.dart の中で使う CBuilder(package:native_toolchain_c)が、ビルド対象のOS/アーキテクチャに応じてiOSならXcodeのclang、AndroidならNDKのclangを自動的に選んでコンパイルしてくれます。
参考にしたのはこちらの記事です。
この記事を読んで「実際に、公式バインディングのない本物のサードパーティCライブラリで試したらどうなるか」を検証したのが今回の内容です。
お題: stb_image.h
題材には stb_image.h(Sean Barrett作、public domain)を選びました。ゲーム開発などで広く使われる、ヘッダオンリーの画像デコードCライブラリです。Flutter向けの公式バインディングは存在しません。
作ったのは、画像のバイト列を渡すと stb_imageでデコードし、幅・高さ・平均RGB色をネイティブ側(C)で計算して返す というシンプルなDart APIです。
Future<ImageAnalysis?> analyzeImage(Uint8List bytes);
パッケージ構成はこうなりました。
stb_image_bridge/ ← 再利用可能なDartパッケージ(ネイティブブリッジ本体)
pubspec.yaml 依存: hooks, native_toolchain_c, code_assets, ffi / dev: ffigen
hook/build.dart Native Assetsのビルドフック
src/
stb_image.h vendoringした本物のstb_image.h(無改造)
stb_image_bridge.h 公開するC関数の宣言
stb_image_bridge.c stb_imageを呼び出す薄いラッパー実装
ffigen.yaml ffigenの設定
lib/
stb_image_bridge.dart ffigen自動生成(dart run ffigen --config ffigen.yaml)
stb_image_bridge_api.dart 人間(AI)が書いた使いやすいDart API
中身も実際にAIが書いたものをそのまま載せます。
C側: 公開する関数の宣言(src/stb_image_bridge.h)
#ifndef STB_IMAGE_BRIDGE_H
#define STB_IMAGE_BRIDGE_H
#include <stdint.h>
#if _WIN32
#define STB_BRIDGE_EXPORT __declspec(dllexport)
#else
#define STB_BRIDGE_EXPORT
#endif
typedef struct {
int32_t width;
int32_t height;
uint8_t averageRed;
uint8_t averageGreen;
uint8_t averageBlue;
int32_t ok;
} ImageInfo;
// Decodes an image (JPEG/PNG/BMP/etc, whatever stb_image supports) from
// `data` and computes its dimensions and average RGB color natively.
// `ok` is 0 if decoding failed (e.g. unsupported/corrupt data).
STB_BRIDGE_EXPORT ImageInfo stb_bridge_analyze_image(const uint8_t *data, int32_t length);
#endif // STB_IMAGE_BRIDGE_H
C側: stb_imageを呼び出す薄いラッパー実装(src/stb_image_bridge.c)
#include "stb_image_bridge.h"
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
ImageInfo stb_bridge_analyze_image(const uint8_t *data, int32_t length) {
ImageInfo info = {0, 0, 0, 0, 0, 0};
int width, height, sourceChannels;
// Force 3 channels (RGB) so the averaging loop below doesn't need to
// branch on the source image's actual channel count.
unsigned char *pixels = stbi_load_from_memory(
data, length, &width, &height, &sourceChannels, 3);
if (pixels == NULL) {
return info;
}
long pixelCount = (long)width * (long)height;
long sumRed = 0, sumGreen = 0, sumBlue = 0;
for (long i = 0; i < pixelCount; i++) {
sumRed += pixels[i * 3 + 0];
sumGreen += pixels[i * 3 + 1];
sumBlue += pixels[i * 3 + 2];
}
stbi_image_free(pixels);
info.width = width;
info.height = height;
info.averageRed = (uint8_t)(sumRed / pixelCount);
info.averageGreen = (uint8_t)(sumGreen / pixelCount);
info.averageBlue = (uint8_t)(sumBlue / pixelCount);
info.ok = 1;
return info;
}
stbi_load_from_memory がstb_image.h本体のデコード関数で、それ以外は「3チャンネルに強制してRGB平均を取るだけ」の薄いラッパーです。
ffigenの設定(ffigen.yaml)
# Run with `dart run ffigen --config ffigen.yaml`.
name: StbBridgeBindings
description: |
Bindings for `src/stb_image_bridge.h`.
Regenerate bindings with `dart run ffigen --config ffigen.yaml`.
output: "lib/stb_image_bridge.dart"
headers:
entry-points:
- "src/stb_image_bridge.h"
include-directives:
- "src/stb_image_bridge.h"
comments:
style: any
length: full
ffi-native:
dart run ffigen --config ffigen.yaml を実行すると、上のCヘッダーから lib/stb_image_bridge.dart が自動生成されます。ポイントとなる部分だけ抜粋するとこうなります。
@ffi.Native<ImageInfo Function(ffi.Pointer<ffi.Uint8>, ffi.Int32)>()
external ImageInfo stb_bridge_analyze_image(
ffi.Pointer<ffi.Uint8> data,
int length,
);
final class ImageInfo extends ffi.Struct {
@ffi.Int32()
external int width;
@ffi.Int32()
external int height;
@ffi.Uint8()
external int averageRed;
@ffi.Uint8()
external int averageGreen;
@ffi.Uint8()
external int averageBlue;
@ffi.Int32()
external int ok;
}
Cの typedef struct がそのままDartの ffi.Struct に、関数宣言がそのまま @ffi.Native<...>() 付きの external 関数になっています。手で DynamicLibrary.open(...) を書く必要はありません。
Dart側: 人間(AI)が書いた使いやすいAPI(lib/stb_image_bridge_api.dart)
生成されたバインディングは低レベル(生ポインタ・生構造体)なので、その上に使いやすいAPIをかぶせます。
import 'dart:ffi' as ffi;
import 'dart:typed_data';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'stb_image_bridge.dart' as bindings;
/// Result of decoding an image with the native `stb_image.h` bridge.
class ImageAnalysis {
const ImageAnalysis({
required this.width,
required this.height,
required this.averageColor,
});
final int width;
final int height;
/// The average RGB color of the image, computed natively in C.
final ({int red, int green, int blue}) averageColor;
}
/// Decodes [bytes] (JPEG/PNG/BMP/GIF/... — anything `stb_image.h` supports)
/// natively and returns its dimensions and average color, or `null` if the
/// bytes could not be decoded as an image.
Future<ImageAnalysis?> analyzeImage(Uint8List bytes) async {
final pointer = pkg_ffi.malloc<ffi.Uint8>(bytes.length);
try {
pointer.asTypedList(bytes.length).setAll(0, bytes);
final info = bindings.stb_bridge_analyze_image(pointer, bytes.length);
if (info.ok == 0) {
return null;
}
return ImageAnalysis(
width: info.width,
height: info.height,
averageColor: (
red: info.averageRed,
green: info.averageGreen,
blue: info.averageBlue,
),
);
} finally {
pkg_ffi.malloc.free(pointer);
}
}
Dartの Uint8List をネイティブのポインタにコピーして渡し、使い終わったら malloc.free で解放する — FFIでバイト列をやり取りするときの定型パターンです。
Flutter側のアプリは pubspec.yaml に stb_image_bridge: {path: ../stb_image_bridge} と書くだけ。CocoaPodsのPodfileもAndroidのGradleファイルも一切触っていません。
最後に、これらのCソースを実際にビルドする役目を持つ hook/build.dart はこれだけです。
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
final packageName = input.packageName;
final cbuilder = CBuilder.library(
name: packageName,
assetName: '$packageName.dart',
sources: ['src/$packageName.c'],
includes: ['src'],
libraries: ['m'], // 後述: Androidで必要になった
);
await cbuilder.run(input: input, output: output);
});
}
ポイントは、このファイルがiOS/Android共通でたった1つ ということです。CBuilder が input から実行対象のOS/アーキテクチャを判断してコンパイラを自動選択してくれるので、プラットフォームごとに別のビルド設定を書く必要はありませんでした。
なお、事前にDart MCPの pub_dev_search で objective_c / ffigen / jni / jnigen / swiftgen といった周辺パッケージの実在・最新バージョンを確認しています。ただ今回は純粋なC言語のライブラリだったため、Swift/Objective-C(objective_c/swiftgen)やKotlin/Java(jni/jnigen)向けの相互運用パッケージは結局使わずに済みました。これらは「プラットフォーム固有のSwift/Kotlin APIを直接呼びたい」場合に必要になるもので、移植性の高いC言語コードならもっとシンプルに書けます。
iOSで動かす
まずiOSシミュレータで動かします。
flutter build ios --debug --simulator --no-codesign # 初回はこれで一度ブートストラップ
# それ以降は xcodebuildmcp で直接ビルド可能
xcodebuildmcp simulator build-and-run --workspace-path ios/Runner.xcworkspace \
--scheme Runner --simulator-name 'iPhone 17'
初回だけ flutter build ios でのブートストラップが必要で、それ以降はXcodeBuildMCPで直接ビルドできました。これはCocoaPodsの pod install と同じパターンです。
赤一色(RGB 220,60,60)の画像と、青緑グラデーションの画像をそれぞれ解析させたところ、両方とも生成時に指定した値と完全に一致する結果 が返ってきました。ネイティブ側でstb_imageによるデコード→平均色計算→Dartへの返却、という一連の流れが実機(シミュレータ)上で確認できたことになります。
ここまでは順調でした。ところが、この結果をXcodeBuildMCPのUI自動操作で確認しようとしたところで、1つ目の罠にハマりました。
罠①: XcodeBuildMCPのUI自動操作がボタンを認識できない
いつも通り ui-automation snapshot-ui で画面をスナップショットして要素参照を取ろうとしたのですが、「0 likely targets」しか返ってこず、ui-automation tap が使えませんでした。
念のため、XcodeBuildMCPに同梱されている生の axe バイナリで直接確認してみます。
axe describe-ui --udid <UUID>
結果、このアプリのアクセシビリティツリーは空("children": [])でした。過去の検証(同じ手法で作った別のFlutterアプリ)ではXcodeBuildMCPのUI自動操作が問題なく動いていたため、原因はこのアプリ固有の何かだと考えられます。main() に SemanticsBinding.instance.ensureSemantics() を追加してみましたが、これでは解消しませんでした。正直なところ、根本原因は特定できていません。
回避策: XcodeBuildMCPに同梱されている axe バイナリの tap -x <x> -y <y> --udid <UUID> で座標を直接指定してタップしました。座標はスクリーンショットの物理ピクセル位置を3(Retinaスケール)で割ってポイント座標に変換しています。要素参照方式が使えない場面での代替手段として機能しました。
(なお、Dart MCPには widget_inspector/flutter_driver_command というFlutter VM Service経由でウィジェットを直接操作・検証できるツール群もあり、OS側のアクセシビリティ状態に依存しないぶん、こうした場面での代替手段になり得ます。今回は座標タップで解決済みのため深入りしませんが、気になる方は調べてみてください。)
Androidで動かす(同じコードのまま)
続いてAndroidです。stb_image_bridge パッケージは一切変更せず、Android実機/エミュレータで試しました。
flutter run -d emulator-5554 --debug
初回のAndroidビルドはNDK/CMakeが未インストールだったため、Gradleが自動ダウンロード・インストールするのに約10分かかりました(2回目以降は数十秒)。ここでも「初回だけ時間がかかる」というiOS側と同じパターンが見られました。
ビルド自体は成功しましたが、アプリを実行すると2つ目の罠にぶつかりました。
罠②: dlopen failed: cannot locate symbol "pow"
analyzeImage を呼ぶと、実行時に以下のエラーで失敗しました。
Failed to load dynamic library 'libstb_image_bridge.so': dlopen failed:
cannot locate symbol "pow" referenced by ".../libstb_image_bridge.so"
原因: stb_image.h はガンマ補正などの内部処理で pow()(libmの関数)を使います。iOS/macOSでは libm がlibSystemに含まれ暗黙的にリンクされるのに対し、AndroidのNDKでは明示的にリンクしないと実行時にシンボルが解決できません。 同じCソースでも、OSごとのリンク挙動の違いがここで表面化した形です。
修正は1行、CBuilder.library に libraries: ['m'] を追加するだけでした。
final cbuilder = CBuilder.library(
// ...
libraries: ['m'],
);
iOS側は元から暗黙的にlibmをリンクしているため、この変更による影響はありません。
ここで1つ注意点があります。ネイティブコードの変更(hook/build.dart の変更を含む)は、hot reload/hot restartでは反映されません。 flutter run をやり直す必要がありました。Dartコードだけの変更ならhot reloadで1秒未満・状態保持のまま反映できますが、ネイティブアセット側の変更はフルリビルドが必須という点は覚えておく価値があります。
修正後にAndroidで再度同じ2枚の画像を解析させたところ、iOSと完全に一致する結果 が返ってきました。同じ stb_image_bridge パッケージを、プラットフォーム差分のコードを一切書かずに両OSで動かせたことになります。
Android側の操作確認は adb shell input tap x y(物理ピクセル座標)で行いました。こちらもDart MCPの flutter_driver_command を試すことはできましたが、enableFlutterDriverExtension() の追加設定が必要だったため、今回はシンプルな adb での確認で済ませています。
まとめ
-
純粋なC言語のサードパーティライブラリなら、iOS/Android向けのブリッジをほぼ1つのソースファイルと1つの
hook/build.dartだけで書けました。 CocoaPodsのpodspecやAndroidのGradle/CMakeLists.txtを書く必要はありませんでした。 - 実装自体はAIが一度で書けましたが、実機検証をしないと踏めない罠が実際に2つありました。
- iOS: XcodeBuildMCPのUI自動操作がアクセシビリティツリーの欠落で効かなかった(根本原因未特定・座標タップで回避、
widget_inspector/flutter_driver_commandという代替手段も確認) - Android:
libmの暗黙リンクの有無というOSごとの違いでdlopenエラー(libraries: ['m']で解決)
- iOS: XcodeBuildMCPのUI自動操作がアクセシビリティツリーの欠落で効かなかった(根本原因未特定・座標タップで回避、
- どちらも「コードを読むだけ」では気づきにくく、実際にシミュレータ/実機で動かして初めて発覚した 問題でした。「AIが書いたから安心」ではなく、両プラットフォームで実際に動かして確認する価値は今回もあったと感じています。
- Kotlin/Java(
jni/jnigen)やSwift/Objective-C(objective_c/swiftgen)のAPIを直接呼ぶケースは今回試していません。純粋Cライブラリより一段複雑になる可能性があり、機会があれば別途検証したいところです。

