KotenOCR Android版の開発記録 — FlutterからKotlinへの移行とその理由
古典籍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をブロックします。 回避策として テンソルをファイルに書き出してパスだけを返す方式を採用しました。他にもBasicMessageChannelとBinaryCodecの組み合わせや、dart:ffiによる直接メモリ共有など、より効率的な方法がありえました。 全体のベンチマーク結果 Pixel 9a (Android 16)、6642×4990画像、15領域検出: ステップ Dart版 Kotlin最適化版 高速化 画像変換+前処理 14,549ms 5,885ms 2.5x 検出 (RTMDet推論) 13,010ms 10,630ms 1.2x 認識 (PARSeq×15) 45,675ms 10,266ms 4.4x 合計 約73秒 約27秒 2.7x Kotlin最適化で73秒が27秒になりました。ただし、iOS版(約12秒)と比較するとまだ差があります。 ...