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 行だけ。

code
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

  1. カメラのパフォーマンスの第一原則は、仕事を減らすこと:解像度を落とす、ROI を切り出す、GPU でできるなら GPU でやる
  2. 自分のコードがどの runtime で動いているかを把握する:同じ JS 関数でも、UI runtime では問題なく、async-runner ではアプリごと殺す
  3. クラッシュには自己紹介させる:まず診断ツールを整えてから、バグを捕まえにいく。そうしないと abort() called を前に霊視するしかなくなる
  4. 古いスマホは絶対に 1 台持っておく:シミュレーターでも新しいスマホでも再現しない。A12 こそが正体を暴く照魔鏡。開発者が十分に貧乏で、iPhone XR しか買えなくて助かりました

カメラはまさに難易度・地獄ですが、這い上がってきたあとには、React Native の下で実際に何が起きているのか、理解が一段どころではなく深まりました。

公開中のアプリ

iOS・Android で無料。

AniSeekr

AniSeekr

MAL・AniList・Bangumi など 6 つのサービスの視聴記録をまとめて、作品の聖地も巡れるアニメ伴走アプリ。

Solid(ar)ity

Solid(ar)ity

プライバシー重視の ID ウォレット。ゼロ知識証明と署名付き名刺を対面で交換、中央サーバーなし。