古典籍OCRアプリ「KotenOCR」のAndroid版を、まずFlutterで開発し、その後Kotlin Nativeへ移行しました。その過程で得た知見と、AIコーディングツール(Claude Code)を使った開発でのフレームワーク選定について考えたことを記録します。

背景

KotenOCRは、くずし字(古典日本語)をONNX Runtimeでオンデバイス認識するアプリです。iOS版はSwiftで開発済みで、Android版を追加する必要がありました。

Flutterなら既存のiOS版と共通のコードベースで開発できる可能性があると考え、まずFlutter版を選択しました。

Flutter版で直面した課題

1. 画像処理の速度

6642×4990ピクセル(約3300万画素)の画像をRGBA配列に変換する処理を計測しました:

実装時間
Dart (imageパッケージのgetPixel())14,549ms
Kotlin (BitmapFactory + getPixels)約500ms

今回のケースでは約30倍の差がありました。ただし、これはimageパッケージのgetPixel()を1ピクセルずつ呼び出す実装が原因です。毎回Pixelオブジェクトが生成されるため、3300万回のオブジェクト生成が発生していました。

後から分かったこと: imageパッケージv4.xではimage.toUint8List()image.bufferで内部バッファに直接アクセスでき、ピクセル単位のループを避けられます。またdart:ffiを使えば、ネイティブのC関数をゼロコピーで呼び出すことも可能でした。今回はこれらのアプローチを試す前にKotlinブリッジを作成しました。

2. flutter_onnxruntimeでの並列推論

iOS版ではSwiftのwithThrowingTaskGroupで4並列認識を実装し、高速化を実現しています。Flutter版でもFuture.waitによる並列実行を試みました:

await Future.wait(batch); // 4つの認識を並列実行

結果は**高速化なし(0.98x)**でした。

原因を調査したところ、今回使用したflutter_onnxruntimeプラグインでは、method channelのハンドラがデフォルトのプラットフォームスレッドで動作しており、makeBackgroundTaskQueue()(Flutter 3.0以降で利用可能)を使用していないため、ネイティブ側で逐次処理されていました。

別のアプローチとして: dart:ffiベースのonnxruntimeパッケージ(method channelを使わない)を使えば、Dart Isolateとの組み合わせで真の並列推論が可能だった可能性があります。また、flutter_onnxruntimeをforkしてbackground task queueを追加する方法もありました。今回はこれらを試さず、Kotlinブリッジで解決しました。

3. Kotlin coroutinesによる並列化

Kotlinブリッジを作成し、Dispatchers.Default.limitedParallelism(4)で認識処理を並列化したところ、認識ステップが5.46倍高速化されました。

val dispatcher = Dispatchers.Default.limitedParallelism(concurrency)
return runBlocking {
    boxes.map { box ->
        async(dispatcher) {
            val tensor = cropAndPreprocess(rgbaPixels, imageWidth, imageHeight, box)
            recognizeSingle(ortEnv, sess, tensor)
        }
    }.awaitAll()
}

4. Method Channelでの大きなデータ転送

12MB程度のFloat32テンソルをmethod channel経由で返そうとした際、アプリがフリーズする問題がありました。method channelに厳密なサイズ上限があるわけではありませんが、StandardMethodCodecでのシリアライズがプラットフォームスレッドで実行されるため、大きなデータではUIをブロックします。

回避策として テンソルをファイルに書き出してパスだけを返す方式を採用しました。他にもBasicMessageChannelBinaryCodecの組み合わせや、dart:ffiによる直接メモリ共有など、より効率的な方法がありえました。

全体のベンチマーク結果

Pixel 9a (Android 16)、6642×4990画像、15領域検出:

ステップDart版Kotlin最適化版高速化
画像変換+前処理14,549ms5,885ms2.5x
検出 (RTMDet推論)13,010ms10,630ms1.2x
認識 (PARSeq×15)45,675ms10,266ms4.4x
合計約73秒約27秒2.7x

Kotlin最適化で73秒が27秒になりました。ただし、iOS版(約12秒)と比較するとまだ差があります。

5. UIのプラットフォーム固有の調整

  • トリミングのジェスチャー競合 — AndroidのエッジスワイプバックがFlutter側のクロッパーのドラッグ操作と衝突
  • ステータスバーとの重なり — SafeAreaの挙動調整が必要
  • Adaptive Icon — Android固有のアイコン仕様への対応

最終的なアーキテクチャの振り返り

最適化を重ねた結果、Flutter版の構成は以下のようになっていました:

  • 画像デコード → Kotlin (BitmapFactory)
  • 前処理(リサイズ+正規化)→ Kotlin (ByteBuffer)
  • ONNX推論 → Kotlin (OrtSession直接呼び出し)
  • 並列認識 → Kotlin (Coroutines)
  • UI → Flutter (Dart)

振り返ると、Dart側での最適化(バッファ直接アクセス、FFIベースのONNXパッケージ、Isolate並列化)をもっと探求すべきでした。Kotlinブリッジは確実に動く解決策でしたが、Flutter内で完結する方法も存在していた可能性があります。

とはいえ、処理の大部分がKotlinになった時点で、最初からKotlinで統一した方がmethod channelの架橋コストを考慮する必要がなく、シンプルだったとも感じます。

AIコーディングツールを使った開発での所感

今回の開発ではClaude Codeを使用しました。その経験からいくつか感じたことがあります。

Flutterの利点

  • 1言語(Dart)で開発できる — 学習コストが低い
  • Hot Reload — UIの試行錯誤が速い
  • クロスプラットフォーム — iOS/Android同時開発で工数削減

AI開発で変わる部分と変わらない部分

AIコーディングツールを使う場合、一部の利点の重みが変化すると感じました:

観点変化
言語の学習コストAIは複数言語を同等に扱えるため、「1言語で済む」利点は相対的に小さくなる
Hot ReloadAIはコードを生成できるが画面を見ることはできないため、人間が目視確認する際にHot Reloadは依然として有用
クロスプラットフォームAIが2つのネイティブ版を書けるとしても、バグ修正の二重適用、機能パリティの維持、2つのCI/CDパイプラインなど、維持コストは残る
パフォーマンスネイティブで書けばブリッジ層が不要になる場合がある。ただしこれはアプリの性質による

今回のようにONNX推論や大量ピクセル処理といった計算負荷の高い処理が中心のアプリでは、ネイティブの恩恵が大きいと感じました。一方で、UI中心のアプリや一般的なCRUDアプリであれば、Flutterのクロスプラットフォームの利点はAI開発でも十分に活きると思います。

Flutterでのプロトタイピングの価値

Flutter版の開発で得られた知見はKotlin版に直接活かせるものでした:

  1. OCRパイプラインの設計を素早く検証できた
  2. UIフローの状態遷移を確認できた
  3. ONNX Runtimeの入出力仕様やInt64型問題を発見できた
  4. パフォーマンスボトルネックの所在を特定できた

技術的知見

flutter_onnxruntime使用時の注意点

  1. OrtValue.fromListにInt64を渡す際はInt64List.fromList()を明示的に使う(通常のList<int>はInt32になる)
  2. 大きなバイナリデータのmethod channel転送はUIをブロックする。BinaryCodec、ファイルI/O、またはdart:ffiの利用を検討する
  3. Future.waitによる並列推論は、flutter_onnxruntimeの現在の実装では効果がない。FFIベースのパッケージ(onnxruntimeなど)やプラグインのforkで解決できる可能性がある
  4. 大きな画像はinSampleSizeでサブサンプルしてからネイティブに渡す

Kotlin + ONNX Runtimeでの並列推論

// 各推論のintra-opスレッドを1に抑制し、並列セッション間の競合を減らす
opts.setIntraOpNumThreads(1)

// 4並列で推論実行
val dispatcher = Dispatchers.Default.limitedParallelism(4)
boxes.map { box ->
    async(dispatcher) { recognize(box) }
}.awaitAll()

DEIMモデルのInt64入力

DEIM検出モデルは2つ目の入力(orig_target_sizes)にInt64型を要求します。型の不一致は推論エラーの原因になります:

// Kotlin: Long型を使用
val sizeValues = longArrayOf(inputHeight.toLong(), inputWidth.toLong())
// Dart: Int64Listを明示
final sizeTensor = await OrtValue.fromList(
  Int64List.fromList([inputHeight, inputWidth]), [1, 2]);

追記:API修正による再検証

本記事の技術的主張について検証を行ったところ、重要な発見がありました。

getPixel()からgetBytes()への修正

Flutter版の画像変換で使用していたimageパッケージのgetPixel()は、1ピクセルごとにPixelオブジェクトを生成するAPIでした。同パッケージにはgetBytes(order: ChannelOrder.rgba)という内部バッファに直接アクセスするAPIがあり、これに置き換えたところ:

実装時間
Flutter (getPixel loop)73,000ms
Flutter + Kotlinブリッジ27,000ms
Flutter (getBytes修正)3,967ms

1行の修正で73秒が4秒に改善されました。 Kotlinブリッジによる最適化(27秒)より大幅に速い結果です。

原因はDart言語やFlutterフレームワークの限界ではなく、imageパッケージのAPIの使い方でした。getPixel()は3300万回のオブジェクト生成を伴い、getBytes()は内部バッファのビューを返すだけなので、この差が生まれます。

Kotlin Native版との最終比較

同じ画像(6642×4990、Pixel 9a)でKotlin Native版も実装して比較しました:

実装時間備考
Flutter (旧: getPixel)73,000msAPIの使い方が原因
Flutter + Kotlinブリッジ27,000ms不要な最適化だった
Flutter (getBytes修正)3,967ms1行修正
Kotlin Native2,523msBitmap API + coroutines並列

Kotlin Nativeは2.5秒でFlutter修正版(4秒)の約1.6倍速いという結果でした。差は約1.5秒で、73秒→4秒の改善に比べると小さいです。

この経験から学んだこと

  1. パフォーマンス問題の原因を正確に特定することが重要。 今回、「Dartが遅い」と結論づけてKotlinブリッジを構築しましたが、真の原因はAPIの使い方でした
  2. 最適化の前に、まず同じ言語・フレームワーク内での改善を探るべき。 dart:ffi、バッファ直接アクセス、Isolateなど、Dart/Flutter内にも選択肢がありました
  3. ベンチマークと検証を繰り返すことで、思い込みに基づく判断を避けられる

今後

最終的に、KotenOCR Android版はKotlin + Jetpack Composeで実装することにしました。1.6倍の速度差に加えて、ネイティブUIのジェスチャー処理やプラットフォーム統合の面でもメリットがあると判断しました。

Flutter版での開発は、OCRパイプラインの設計検証、ボトルネックの特定、そしてAPIの適切な使い方の重要性を学ぶプロセスとして有益でした。


この開発はClaude Code(Anthropic)を使用して行いました。記事の技術的主張についても別途検証を行い、当初の断定的な表現を修正しています。