QUEST LOG · June 2, 2026· UPDATED October 29, 2026
難易度・地獄:スマホカメラのパフォーマンス最適化
カメラはスマホで一番手ごわい部分です。毎秒何十枚ものフレームが、一枚残らず CPU・GPU・メモリのリソースを奪い合ってきます。
この一年で、カメラをがっつり使うアプリを 2 つ作りました。Solid(ar)ity は QR を読み取り、パスポートの MRZ(パスポートの一番下にある、あの読めない 2 行の文字です)を読む必要があります。もう一つの聖地巡礼アプリは、カメラ映像にアニメのスクリーンショットを重ねてリアルタイムで位置合わせをします。どちらも React Native + VisionCamera です。
作り終えての感想は一言だけ。カメラという道は、難易度・地獄です。
# 地獄の第一層:1 フレーム (Frame) ごとのコストが高い
VisionCamera の frame processor を使えば、フレームごとにコードを走らせられます。聞こえはいいのですが、計算してみてください。30fps なら 33 ミリ秒ごとにフル解像度の画像を 1 枚処理し終えなければならず、間に合わなければフレーム落ち、発熱、バッテリー消費です。
解決策:まず縮小する。
ほとんどの解析は、そもそもフル解像度なんて要りません。巡礼アプリでは画面の明るさと色温度を計算したいので、GPU resizer で画面を 64×64 まで落としてから平均値を取っています。OCR のように細部が必要なものでも、ROI(関心領域)だけを切り出して、画面全体を丸ごと渡したりはしません。
GPU でできることは、CPU に戻さない。 この一つだけで、ほかのどんなごちゃごちゃした最適化よりも多くのパフォーマンスを稼げました。
# 地獄の第二層:Xcode Cloud はシェーダーをコンパイルしてくれない
縮小に使っている VisionCameraResizer は Metal シェーダーです。ローカルでは問題なく動いていたのに、Xcode Cloud でビルドした途端に落ちました。CI 環境に Metal toolchain がないからです。
最終的な解決策は、シェーダーを事前に metallib にコンパイルして、プロジェクトと一緒にバージョン管理に入れておくこと。これで CI がその場でコンパイルする必要がなくなります。この手の問題で「シェーダーを事前コンパイルしろ」と教えてくれるエラーメッセージは一つもなく、一層ずつ自分で掘っていくしかありません。ただ、その代わりにローカルのビルド時間全体が長くなってしまったのですが……。
# 地獄の第三層:クラッシュするのに、理由を教えてくれない
ここが本当の地獄です。Solid(ar)ity のパスポート MRZ スキャンが、iPhone XR(A12)でランダムにクラッシュしていました。クラッシュレポートにあるのはこの 1 行だけ。
abort() called
例外の型もなし、メッセージもなし、JS のスタックもなし。何もありません。
長いこと追いかけて、ようやく犯人を 2 人見つけました。
犯人その 1:worklet の中の console.log
OCR の worklet は VisionCamera の async-runner runtime 上で動いていて、Reanimated の UI runtime ではありません。この runtime には JS のスケジューリング用のグローバル関数がないので、
console.log(...)が例外を投げる(内部でscheduleOnRNを通るため)- 例外が
runAsyncからどんどん上に伝播し、Nitro を突き抜けて、そのままstd::terminateでアプリごと落とす
そう、デバッグのために log を 1 行足したら、その log 自体がアプリをクラッシュさせる原因だったのです。今年遭遇した中で一番ばかげたバグでした。
犯人その 2:A12 で Apple Vision が C++ 例外を投げる
Vision の文字認識を .accurate モードで使うと、A12 のニューラルエンジン上で推論の途中に C++ 例外を投げます。NSException ではありません。なので Objective-C の @catch (NSException *) ではまったく捕まえられず、必ず catch (...) を使う必要があります。
# どうやって這い上がったか
1. クラッシュに自分から名乗らせる
アプリ起動時に、グローバルな std::set_terminate handler を仕込みました。捕まえられなかった例外はすべて、型とメッセージをクラッシュレポートの Application Specific Information に書き込み、さらに os_log を 1 本出します。これでクラッシュレポートはもう abort() called だけではなくなり、どの関数で爆発したのかを直接教えてくれるようになりました。この一手だけで、デバッグが数時間から数分になりました。
2. worklet は「絶対に外に漏れない」ように書く
worklet の本体はすべて try/catch/finally で包み、catch と finally では SharedValue に書き込むか、そのまま握りつぶすだけ。例外を投げる可能性があるものは絶対に呼びません。frame.dispose() ですら保護が必要です。
結果を JS に返したい場合は SharedValue に書き込み、JS 側で 100 ミリ秒ごとにポーリングします。見た目はダサいですが、すべての runtime 間で安全に使える唯一の経路です。
ここは本当に、React Native でネイティブをやることを選んだ痛みだと言うしかありません
3. ネイティブモジュールはフレームごとに例外を投げない
Nitro のメソッドが一時的な失敗(このフレームはピントが合っていない、文字が検出されなかった)に遭遇したら、空の結果を返して、呼び出し側にそのフレームをスキップさせます。毎フレーム境界をまたいで例外を投げていたら、いつかどこかのフレームで terminate されます。
# TLDR
- カメラのパフォーマンスの第一原則は、仕事を減らすこと:解像度を落とす、ROI を切り出す、GPU でできるなら GPU でやる
- 自分のコードがどの runtime で動いているかを把握する:同じ JS 関数でも、UI runtime では問題なく、async-runner ではアプリごと殺す
- クラッシュには自己紹介させる:まず診断ツールを整えてから、バグを捕まえにいく。そうしないと
abort() calledを前に霊視するしかなくなる - 古いスマホは絶対に 1 台持っておく:シミュレーターでも新しいスマホでも再現しない。A12 こそが正体を暴く照魔鏡。開発者が十分に貧乏で、iPhone XR しか買えなくて助かりました
カメラはまさに難易度・地獄ですが、這い上がってきたあとには、React Native の下で実際に何が起きているのか、理解が一段どころではなく深まりました。
公開中のアプリ
iOS・Android で無料。


