カメラ機能を「とりあえず動く」状態から「本番品質」まで引き上げる作業は、Rork でアプリを作るときに最も時間が溶ける工程のひとつではないかと思います。私自身、過去 1 年で 4 本のカメラを使うアプリを App Store に出してきましたが、Expo Camera で組んだ最初のプロトタイプを本番投入できる構成に置き換えるたびに、半日から 2 日分のデバッグが発生していました。
特に厄介なのが、Vision Camera v4 を選んだ瞬間に立ちふさがる「EAS Custom Dev Client・Worklet・iOS の AVAudioSession・Android の Camera2 API 設定」の四つどもえです。どれかひとつでも間違うと、シミュレータでは動くのに実機で黒画面になったり、QR は読めるのに音声付きの動画録画でクラッシュしたりします。
ここではRork のプロジェクトに Vision Camera v4 を導入し、QR/バーコードのリアルタイム検出・TFLite を使った ML 推論・本番品質の写真と動画撮影まで、ひと通り動く構成を順を追って組み上げていきます。途中で出てくる落とし穴は、私が実際にぶつかって解決したものだけを残しているので、そのまま参考にしていただけるはずです。
なぜ Expo Camera ではなく Vision Camera v4 を選ぶのか
Rork のテンプレートには Expo Camera が含まれていることが多く、QR スキャナーや簡単な写真撮影だけならそのままで十分です。それでも本記事で Vision Camera v4 を扱うのは、本番アプリで必要になる「フレーム単位の処理」が Expo Camera では届かないからです。
私が個人的に判断軸にしているのは次の三点です。
フレームプロセッサで毎フレームを JS 側に渡せるかどうか :QR・OCR・ML 推論をリアルタイムで動かすには必須です。Vision Camera v4 は Worklet 経由で実装でき、UI スレッドを止めません
撮影パラメータを細かく制御できるかどうか :HDR・ISO・露出・フォーカス距離など、写真品質を左右する設定を ImagePicker レベルで動かしたい場合は v4 が明確に楽です
コーデックと解像度の組み合わせを実機ごとに切り替えられるかどうか :低スペック端末で 4K@60fps を強制すると即落ちますが、v4 は getCameraDevice から formats を取得して動的に選べます
逆に「QR を 1 回だけ読みたい」「シャッターを 1 枚切りたい」だけのアプリなら、Expo Camera のほうが導入が一瞬で済みます。トレードオフは明確で、フレームレベルの処理が要らないなら無理に v4 を入れない方が、ビルド時間とトラブルが減ります。
Rork のプロジェクトに Vision Camera v4 を入れる:EAS Custom Dev Client の組み立て
Vision Camera v4 はネイティブモジュールを含むため、Expo Go では動きません。Rork から書き出したプロジェクトを EAS Custom Dev Client に切り替える必要があります。ここが最初の関門です。
# 1. Vision Camera と関連ライブラリを追加
npx expo install react-native-vision-camera
npx expo install react-native-worklets-core
npx expo install vision-camera-code-scanner
# 2. EAS Build の設定(eas.json)に Custom Dev Client プロファイルを追加
# 3. 開発ビルドを作成
eas build --profile development --platform ios
eas build --profile development --platform android
ここで react-native-worklets-core を入れ忘れると、フレームプロセッサが起動した瞬間に JSI is not available というメッセージで落ちます。私はこれに最初に踏み抜かれました。Vision Camera v4 は Worklet を前提に動くので、必ずセットで入れます。
app.json には次のプラグイン設定を入れます。マイク使用権限の文言は App Store 審査で必ず読まれるので、丁寧に書いておきましょう。
{
"expo" : {
"plugins" : [
[
"react-native-vision-camera" ,
{
"cameraPermissionText" : "QRコードや書類のスキャンのためにカメラを使用します。" ,
"enableCodeScanner" : true ,
"enableLocation" : false ,
"enableMicrophonePermission" : true ,
"microphonePermissionText" : "音声付き動画の録画のためにマイクを使用します。"
}
]
]
}
}
EAS の eas.json 側では、Vision Camera を含むビルドは「ビルド時間が伸びる」ことを織り込んで、最大ビルド時間を 60 分に上げておくと安全です。私は最初の数回、デフォルト 30 分でタイムアウトしました。
カメラ権限と AVAudioSession:iOS 特有の落とし穴を先に潰す
Vision Camera v4 を本番投入するときに必ず通る落とし穴が、iOS の AVAudioSession 設定です。動画録画で音声を有効にすると、デフォルトのオーディオセッションカテゴリのままでは、他アプリで音楽を再生中だった場合に強制停止される挙動になります。
// useCameraPermissions.ts
import { useEffect, useState } from "react" ;
import { Camera } from "react-native-vision-camera" ;
export function useCameraPermissions () {
const [ cameraStatus , setCameraStatus ] = useState < string | null >( null );
const [ micStatus , setMicStatus ] = useState < string | null >( null );
useEffect (() => {
( async () => {
// カメラ権限
let camPerm = await Camera. getCameraPermissionStatus ();
if (camPerm !== "granted" ) {
camPerm = await Camera. requestCameraPermission ();
}
setCameraStatus (camPerm);
// マイク権限(音声録画する場合のみ)
let micPerm = await Camera. getMicrophonePermissionStatus ();
if (micPerm !== "granted" ) {
micPerm = await Camera. requestMicrophonePermission ();
}
setMicStatus (micPerm);
})();
}, []);
return { cameraStatus, micStatus };
}
これだけでも動きますが、「他アプリの音楽を止めずに録画したい」場合は、audio: false で動画を録画したうえで、別途 AVAudioRecorder で mixWithOthers を有効にして録音し、後段でマージする設計が安定します。最初は割り切って audio: true にして、ユーザーから要望が出てから対応する方が現実的です。
Android 側は RECORD_AUDIO 権限のほかに、MODIFY_AUDIO_SETTINGS を app.json の permissions に追加しておかないと、一部の機種で音声録画時に低音だけが録音されないという珍現象に遭遇します。これは Pixel 7 シリーズで実際にぶつかりました。
フレームプロセッサで QR・バーコードをリアルタイム検出する
ここから本記事の本題です。Vision Camera v4 のフレームプロセッサは、毎フレームを Worklet 経由で JS 側に渡してくれる仕組みで、ここに任意の処理を差し込めます。QR/バーコードを読むだけなら vision-camera-code-scanner の組み込みで充分です。
// QRScannerScreen.tsx
import { useState } from "react" ;
import { StyleSheet, View, Text } from "react-native" ;
import {
Camera,
useCameraDevice,
useCodeScanner,
} from "react-native-vision-camera" ;
import { useCameraPermissions } from "./useCameraPermissions" ;
export default function QRScannerScreen () {
const device = useCameraDevice ( "back" );
const { cameraStatus } = useCameraPermissions ();
const [ lastValue , setLastValue ] = useState < string | null >( null );
const codeScanner = useCodeScanner ({
codeTypes: [ "qr" , "ean-13" , "code-128" , "data-matrix" ],
onCodeScanned : ( codes ) => {
// 同じコードを連続検出するので、debounce 相当の処理を入れる
const value = codes[ 0 ]?.value;
if (value && value !== lastValue) {
setLastValue (value);
// ここで API 呼び出しなどの副作用を実行する
}
},
});
if (cameraStatus !== "granted" ) {
return (
< View style = { styles.center } >
< Text >カメラへのアクセスを許可してください</ Text >
</ View >
);
}
if ( ! device) {
return (
< View style = { styles.center } >
< Text >利用可能なカメラが見つかりません</ Text >
</ View >
);
}
return (
< View style = { StyleSheet.absoluteFill } >
< Camera
style = { StyleSheet.absoluteFill }
device = { device }
isActive = { true }
codeScanner = { codeScanner }
/>
{ lastValue && (
< View style = { styles.banner } >
< Text style = { styles.bannerText } >検出: { lastValue } </ Text >
</ View >
) }
</ View >
);
}
const styles = StyleSheet. create ({
center: { flex: 1 , justifyContent: "center" , alignItems: "center" },
banner: {
position: "absolute" ,
bottom: 60 ,
left: 20 ,
right: 20 ,
backgroundColor: "rgba(0,0,0,0.7)" ,
padding: 16 ,
borderRadius: 12 ,
},
bannerText: { color: "white" , fontSize: 16 },
});
onCodeScanned は同じ QR を見続けると毎フレーム発火します。ユーザーがコードに向けてスマホを構え続けるだけで API リクエストが連発されてしまうので、必ず直前の値と比較するか、useRef でクールダウンタイマーを持たせてください。
ここでよくある勘違いが「codeTypes に多くを入れるほど検出精度が上がる」というものです。実際は逆で、対象を絞ったほうが検出が速く、誤検出も減ります。アプリ内で扱うコードが QR だけなら ["qr"] だけにしましょう。
ML 推論をフレームプロセッサで動かす:TFLite と MediaPipe の選び方
QR を超えて「画像内の物体を認識する」「OCR で文字を抽出する」レベルになると、外部の ML エンジンが必要になります。私が試した範囲では、選択肢は次の二つに集約されます。
react-native-fast-tflite :TensorFlow Lite モデルを Worklet で直接動かせるライブラリ。モデルファイルを自前で用意する場合に最適
react-native-mlkit (v15 以降):Google MLKit のラッパー。文字認識・顔検出・物体追跡など、定番モデルが組み込み済みでとにかく速い
文字認識(OCR)だけなら、99% のケースで MLKit を選んだほうが楽です。モデルのダウンロード・アップデート・端末ごとの最適化を Google が引き受けてくれるので、自前実装に比べて明確にバグが少ない印象でした。
// OCRScreen.tsx — 紙の書類をリアルタイムで読む
import { useCameraDevice, useFrameProcessor, Camera } from "react-native-vision-camera" ;
import { runOnJS } from "react-native-worklets-core" ;
import { scanText } from "@react-native-ml-kit/text-recognition" ;
import { useState } from "react" ;
import { StyleSheet, Text, View } from "react-native" ;
export default function OCRScreen () {
const device = useCameraDevice ( "back" );
const [ recognized , setRecognized ] = useState ( "" );
// フレーム処理は Worklet 内で動く(JS スレッドを止めない)
const frameProcessor = useFrameProcessor (( frame ) => {
"worklet" ;
// 重いので 5 フレームに 1 回だけ実行する
if (frame.timestamp % 5 !== 0 ) return ;
// フレームを JPEG として保存し、JS スレッドで MLKit に渡す
// (v4 では frame.toArrayBuffer() で生バッファを取得することも可能)
const imageBuffer = frame. toArrayBuffer ();
runOnJS (processOCR)(imageBuffer);
}, []);
const processOCR = async ( buffer : ArrayBuffer ) => {
try {
// 実装の詳細は ML Kit の文字認識 API に依存
const result = await scanText ({ imageData: buffer });
setRecognized (result.text);
} catch (e) {
// OCR 失敗時はサイレントに無視(次のフレームで再試行される)
console. warn ( "OCR failed" , e);
}
};
if ( ! device) return < View />;
return (
< View style = { StyleSheet.absoluteFill } >
< Camera
style = { StyleSheet.absoluteFill }
device = { device }
isActive = { true }
frameProcessor = { frameProcessor }
/>
< View style = { styles.textBox } >
< Text style = { styles.text } > { recognized || "テキストを向けてください" } </ Text >
</ View >
</ View >
);
}
const styles = StyleSheet. create ({
textBox: {
position: "absolute" ,
bottom: 80 ,
left: 20 ,
right: 20 ,
backgroundColor: "rgba(0,0,0,0.7)" ,
padding: 16 ,
borderRadius: 12 ,
},
text: { color: "white" , fontSize: 14 },
});
ここで一番大事なのは、毎フレーム重い処理を実行しないこと です。OCR は 1 フレームあたり 100〜400 ミリ秒かかるので、すべて処理しようとすると、すぐにフレームが詰まって Worklet 側で OOM が発生します。私は最初に frame.timestamp % 5 !== 0 のような節制ロジックを入れず、Pixel 6 で実機が再起動するレベルの暴走を経験しました。
なぜ Worklet を介してこの構造にするかというと、JS スレッド側で MLKit を呼び出すと UI が確実にカクつくからです。Vision Camera v4 のフレームプロセッサは UI スレッドとは別の Worklet スレッドで動くので、ここでフレームを間引いてから runOnJS で JS に渡せば、UI のスムーズさを保ったまま重い処理を実行できます。
写真撮影:本番品質に持っていく設定の組み合わせ
写真撮影は camera.takePhoto({ ... }) だけで済むように見えて、実際は「どの設定を有効にするか」で品質が大きく変わります。私が最終的に落ち着いた組み合わせは次の通りです。
const photo = await camera.current?. takePhoto ({
// 高画質モード(露出・ホワイトバランスが安定)
qualityPrioritization: "quality" ,
// フラッシュは UI から制御
flash: flashMode, // "off" | "on" | "auto"
// HDR は屋外撮影で品質が上がるが、屋内では逆効果になることも
enableShutterSound: false ,
// EXIF メタデータを残す
enableAutoStabilization: true ,
});
// EXIF を読んで画像方向を補正してから保存する
const exifInfo = await ImageEditor. cropImage (photo.path, {
offset: { x: 0 , y: 0 },
size: { width: photo.width, height: photo.height },
});
qualityPrioritization: "speed" のままにしておくと、シャッター音と同時に撮影が完了する代わりに、暗所でかなりノイジーな写真になります。ユーザーが「あとで見返したい」と思う写真を撮るアプリなら quality 一択です。逆に、QR や書類スキャンのように「素早く確実に取れること」が優先される場面では speed のほうが体感がよくなります。
EXIF 情報の取り扱いには注意が必要です。iPhone は撮影時に画像を「縦向き」のピクセル配列で保存しないことがあり、EXIF の Orientation タグだけで方向を表現します。これを無視して画像を投稿先サーバーに送ると、横倒しの写真が表示されることがあります。アップロード前に必ず Orientation を読んで、ピクセル配列を回転してから送ってください。
動画録画:コーデック・HDR・解像度の判断軸
動画録画は写真以上に「どの組み合わせを選ぶか」で容量と品質が大きく変わります。判断軸は次の三つに集約できます。
コーデック :H.264(互換性が広い)・HEVC(同画質で容量半分・iOS は標準)・AV1(最新で対応端末が限られる)。SNS 投稿が主用途なら HEVC、業務向けなら H.264 が安全です
HDR :屋外の明暗差が激しい場面で威力を発揮しますが、HDR で録った動画はそのまま LDR ディスプレイで見ると色が浮きます。Apple ProRes と HDR を併用する場合は、再生側のグレーディングまで含めて設計が必要です
解像度・fps :4K@60fps はバッテリーと熱で 5〜10 分が限界です。汎用的には 1080p@30fps が現実解。スポーツや子どもの撮影に最適化するなら 1080p@60fps を選びます
// VideoRecorder.tsx
import { Camera, useCameraDevice, useCameraFormat } from "react-native-vision-camera" ;
const device = useCameraDevice ( "back" );
// 端末ごとに利用可能なフォーマットから、用途に合うものを選ぶ
const format = useCameraFormat (device, [
{ videoResolution: { width: 1920 , height: 1080 } },
{ fps: 30 },
{ videoHdr: false }, // 互換性を優先
]);
const startRecording = async () => {
await camera.current?. startRecording ({
fileType: "mp4" ,
videoCodec: "h265" , // HEVC で容量を抑える
onRecordingFinished : ( video ) => {
// video.path を Photos などに保存
},
onRecordingError : ( error ) => {
console. error ( "recording error" , error);
},
});
};
Android で videoCodec: "h265" を選ぶと、機種によっては再生互換性に問題が出ます。「動画ファイルを別アプリで開いたら再生できない」というクレームに繋がることがあるので、Android では H.264 にフォールバックするロジックを入れておく方が安全です。
パフォーマンス調整:fps・preset・format の決め方
Vision Camera v4 の力を引き出すには、useCameraFormat の使い方が肝です。私が最終的に採用しているテンプレートは次のとおりで、ローエンド端末でも安定して動くことを目標にしています。
import { useCameraFormat, useCameraDevice } from "react-native-vision-camera" ;
import { Platform } from "react-native" ;
const device = useCameraDevice ( "back" );
const format = useCameraFormat (device, [
// 解像度の優先順位
{ videoResolution: { width: 1920 , height: 1080 } },
// fps(30 を基本、可能なら 60 を狙う)
{ fps: 30 },
// HDR は OFF(互換性とパフォーマンスを優先)
{ videoHdr: false },
// ピクセルフォーマット(YUV が安全)
{ pixelFormat: Platform. OS === "ios" ? "yuv" : "yuv" },
]);
このリストは「優先順位の配列」として動きます。最初の項目を満たすフォーマットがなければ、次の条件に緩和して再検索する仕組みです。fps を 60 で取り合いに行くと、低スペック端末で「動かない・黒画面」になるので、30 を最優先にするのがバランスがよい設定でした。
よくある間違いと対処:本番投入前に必ず確認するポイント
ここまで書いた構成でも、実機テストで必ず一度はぶつかる落とし穴があります。私が経験した順に並べてみました。
フレームプロセッサが動かないが例外も出ない :react-native-worklets-core が未インストール、または babel.config.js に react-native-worklets-core/plugin を追加し忘れているケースが大半です。metro のキャッシュをクリアしてから再ビルドすると気付きます
iOS 実機で黒画面になる :Info.plist の NSCameraUsageDescription が空文字または未設定だと、ダイアログが出ずに権限拒否扱いになります。EAS のプラグイン経由で記述している場合も、expo prebuild で実際の値が入っているか確認します
Android で録画ファイルが破損する :startRecording の途中でアプリがバックグラウンドに行くと、ファイルが壊れます。AppState の変化を監視して、background に入ったら stopRecording を呼ぶ防御を入れます
HDR を有効にしたら一気にカクつく :HDR は端末によって対応するフォーマットが極端に少なく、自動でフォーマット選択されるとローエンド端末で fps が 12 まで落ちることがあります。HDR は機能としてオプション扱いにし、ユーザーが明示的に有効にしたときだけ ON にするのが安全です
OCR の精度が日本語で極端に悪い :MLKit の text-recognition は日本語専用モデルを別途指定する必要があります。@react-native-ml-kit/text-recognition で「Japanese」モジュールを別ビルドに含めるよう設定すると、別人のように精度が上がります
これらは公式ドキュメントには断片的にしか書かれておらず、私自身も対応するまでに延べ 10 時間以上のデバッグ時間を投じました。同じ落とし穴を避けるための時間節約に役立てていただけたら嬉しいです。
長時間のセッションでバッテリーと発熱をどう抑えるか
オフィスで 1 分動くカメラと、結婚式で 1 時間動かし続けるカメラは別物です。Vision Camera v4 のフレームパイプライン自体は軽量ですが、その上に積むもの(エンコード・ML 推論・アップロード)はすべて積算で効いてきます。私自身、画面を離れてもカメラが止まらず、15 分で 12% もバッテリーを溶かしたアプリを出してしまったことがあります。
長時間動かすカメラで、今はどのアプリでも守っている方針が四つあります。
見えていないときはカメラを止める :isActive は画面のフォーカス状態に追従させます。useIsFocused(React Navigation)と組み合わせ、離れた瞬間に false にするだけで消費が大きく変わります
プレビュー中は解像度を落とす :録画前のプレビューは 1280×720 で描き、ユーザーが録画を始めた瞬間に 1920×1080 へ上げます。キャプチャ経路とプレビュー経路の解像度は独立して指定できます
フレーム処理は剰余ではなく時間で間引く :frame.timestamp % 5 は開発機では動きますが、端末がフレームを不規則に落とすと破綻します。時間ベースで判定すると、サーマルスロットリングが起きても素直に追従します
バッファを抱え込まない :frame.toArrayBuffer() で得られるバッファは大きく、JS 側で参照を握り続けると GC が遅れてメモリが右肩上がりになります。runOnJS に渡したら、両側でスコープから外して手放します
時間ベースの間引きは、次のように書いています。
import { useFrameProcessor } from "react-native-vision-camera" ;
import { useSharedValue } from "react-native-worklets-core" ;
const lastRun = useSharedValue ( 0 );
const frameProcessor = useFrameProcessor (( frame ) => {
"worklet" ;
const now = Date. now ();
if (now - lastRun.value < 200 ) return ; // 5fps 相当まで間引く
lastRun.value = now;
// ここで OCR / 物体検出など重い処理を実行する
}, []);
これらは計測しないと見えません。Sentry や Firebase Performance でセッション時間・最高温度・OOM を拾うようにしておくと、温度の問題を星 1 のレビューで知る前に気付けます。
本番前に必ず通す実機テストマトリクス
実機に代わるものはありません。Vision Camera v4 には iOS シミュレータでは動かない部分(実カメラがない)があり、Android は OEM ごとに挙動が変わります。特に Samsung のカメラ HAL は気難しい印象です。私がリリース前に最低限通している端末は次のとおりです。
iPhone 現行+一世代前 :本命ターゲット。iPhone 14 はまだ大きなシェアを占めます
iPhone SE / 旧機種 :遅い A シリーズが、上位機では隠れる fps の下限を露わにします
Pixel 現行 :Google のリファレンス Android。多くの問題はまずここで再現します
Samsung Galaxy ミドルレンジ(A シリーズ) :コーデック不整合が最も出やすいカメラ HAL
Xiaomi など低価格 Android :低 RAM とサーマル制限で fps 低下がすぐ見えます
各端末で必ず回すシナリオを表にまとめておきます。
シナリオ 確認すること つまずくと起きること
QR を読む → 画面を離れる → 戻る カメラがきれいに再開するか 戻ると黒画面のまま固まる
音声つきで 5 分録画 別アプリでファイルが再生できるか 録画途中でプロセスがクラッシュ
録画中に強制バックグラウンド ファイルが壊れないか 破損して再生不能なファイルが残る
ML 推論中に機内モード 破綻せず縮退するか 例外で画面全体が落ちる
ストレージを 90% まで埋める 録画がエラーで止まるか 黙って失敗し保存されない
自動化は難しく、私の知る限り物理端末での手動確認が唯一信頼できる方法です。ここを飛ばすと、後で「自分の Galaxy S22 で動かない」というバグ報告として必ず返ってきます。
ストア審査でカメラアプリが見られるポイント
カメラを多用するアプリは、App Store / Play Store の審査で余計に厳しく見られます。私が実際に見たリジェクト理由で多いものを挙げます。
権限テキストが汎用的 :「カメラへのアクセスが必要です」では足りません。「即時決済のために QR を読み取ります」のように、ユーザーにとって何のためかを書きます
同意前にカメラが起動する :権限付与の前に <Camera /> がマウントされると、プライバシー観点でリジェクトされ得ます。権限チェックの内側にマウントを包みます
背景でのカメラ利用 :背景カメラを宣言していて、審査側が正当な用途を再現できないと弾かれます。本当に必要でない限り有効にしません
画像アップロードのプライバシー開示漏れ :写真が端末外に出るなら、プライバシーポリシーと App Privacy ラベルの双方に反映が必要です。審査では宣言と実挙動が突き合わされます
提出前に 30 分の審査リハーサルを組みます。権限文言を読み直し、App Privacy 項目を確認し、「カメラ拒否」をシミュレートして縮退を確かめます。私が見たカメラ起因のリジェクトのほとんどは、この 30 分で防げたものでした。
フィーチャーフラグで段階的に出す
カメラ機能は、ふつうの React Native コードより影響範囲が広いです。悪いリリースは数千台でカメラを使えなくし、その「修正」はネイティブの再ビルドを要することが多く、OTA では救えません。
そこで私は、Vision Camera の変更を必ずリモート制御のフィーチャーフラグの裏で出すようにしています。
コーデックと解像度の既定値をリモート設定にする :特定 OS で HEVC がクラッシュし始めたら、数分で Android を H.264 に切り替えます
フレームプロセッサの重い処理をゲートする :OCR をカメラ本体ごと落とさずにリモートで止められます。まずカメラを出し、OCR は 5% のユーザーで有効にしてクラッシュ率を見てから広げます
ロールバックを前提に移行する :新しいカメラ画面に問題が出たら、旧ルート(Expo Camera 画面)をフラグで戻します。私は v4 に移行しても、最低一マイナーバージョンは旧画面をコードに残しています
Web チームには当たり前でも、モバイルではまだ十分に普及していない作法だと感じます。最初の一件のインシデントで、導入の手間は何倍にもなって返ってきます。
なぜフレームプロセッサはこの形なのか
Vision Camera v4 のフレームプロセッサの形は、恣意的な API 設計ではありません。なぜこうなっているかを理解しておくと、一見「魔法のように見えるバグ」の一群を避けられます。
カメラがフレームを捉えると、そのデータはネイティブメモリにあります。iOS では CMSampleBuffer、Android では Image です。これを通常のブリッジ越しに JavaScript へ運ぶと、毎フレーム全ピクセルをブリッジでコピーすることになります。1080p・30fps なら、毎秒およそ 250MB のコピーです。ブリッジは追いつかず、「フレームプロセッサが UI を止める」古典的な問題が起きます。
Worklet はこれを回避します。JavaScript をカメラスレッド上で直接動かし、ネイティブのフレームバッファとメモリを共有するのです。コピーがありません。代わりに、worklet は通常の JS 状態に手を伸ばせません。独自スコープを持つサンドボックスです。runOnJS(callback)(args) という書き方が存在するのは、worklet の世界とアプリ本体をつなぐ制御された扉だからです。
この理解は設計に直結します。毎フレーム動かしたいもの(Skia でのオーバーレイ描画、露出補正の計算、高速 OCR)は worklet の中に置きます。ネットワーク・永続化・React 状態の更新が要るものは runOnJS の後に置き、間引きます。この層を混同して「worklet の中から setState を呼べる」つもりで書くと、再現しづらいクラッシュになります。
カメラライブラリを選ぶときの判断フロー
React Native のカメラライブラリは今や半ダース近くあります。私が使っている判断フローはこうです。
QR を一度読む・写真を一枚撮るだけ → Expo Camera。ビルドの簡単さが勝ちます
音声つき録画+基本的な写真+少しのエフェクト → 2026 年なら expo-camera/next で十分にまかなえます
フレームのリアルタイム解析(OCR・物体検出・AR マーカー)→ Vision Camera v4 が現実的な唯一の選択肢です
プレビューへの GPU エフェクトやカスタム描画 → Vision Camera v4 + react-native-skia が事実上の定番です
深度センサーや赤外線・LiDAR などカスタムハードウェア連携 → Vision Camera + 自前ネイティブモジュールが近道です
私が一番やられた失敗は、過剰設計でした。「あとで ML を使うかも」で Vision Camera を選び、結局フレームプロセッサを使わなかったプロジェクトに、二日分のビルド設定を足してしまったのです。迷うなら Expo Camera で始め、後から移行する方が、前払いの複雑さを避けられます。
ケーススタディ:レシート読み取りアプリ
具体例として、今年出した家計簿アプリでこれらがどう組み合わさったかを書いておきます。レシートにカメラを向けると合計金額を自動で抜き出す、という機能です。
カメラ画面の役割は三つでした。合計らしき数字を探し続ける連続 OCR、手動確定のための「タップで撮影」、認識テキストの小さなライブプレビューです。実装はこうです。
useFrameProcessor を 5 フレームに 1 回へ間引いた Vision Camera v4
MLKit の text-recognition(Latin + Japanese)を、間引いたフレームに対し runOnJS 経由で実行
直近の認識合計をデバウンスした React 状態に持ち、バナー表示
「撮影」ボタンで takePhoto を qualityPrioritization: "quality" で呼び、高解像度写真と認識合計を保存
最初の版は毎フレーム OCR で、iPhone 15 以外では 90 秒以内に落ちました。二版目で間引きを入れ、1 時間動くようになりましたが、端末がはっきり熱くなりました。三版目で「同じ合計が 3 秒続いたら処理を止める」判定を足したところ、平均消費電力がおよそ 40% 下がり、発熱も止まりました。
どれもドキュメントを読んで分かったものではありません。実際にユーザーが使う様子を見て、Sentry で電力とサーマルのイベントを計測して初めて見えました。初めての Vision Camera プロジェクトに一つだけ助言するなら、最小版を出し、計測を仕込み、現場のトレースに最適化箇所を教えてもらうこと、です。
関連する深掘り記事
カメラ機能を中心としたアプリを本気で作るなら、撮影後のパイプライン側にも目を向けると効果が大きいです。あわせて読むなら次の記事が直接つながります。
カメラの実装はネイティブ寄りの落とし穴が多く、どうしても紙の書籍で体系的にネイティブ層を理解しておくと立て直しが速くなります。
次の一歩
ここまでお伝えした構成があれば、QR スキャン・OCR・写真と動画の本番投入はカバーできます。次に手を伸ばすなら、撮影パイプラインを「録画 → 自動アップロード → ML 解析 → 結果通知」までつなげる仕組みを組むのが、ユーザー体験として一番効きます。今日この記事を読んだあとにできる一歩は、お手元のプロジェクトで react-native-vision-camera と react-native-worklets-core を入れて、まず QR スキャナーの最小実装を実機で動かしてみることです。実機で動いた瞬間に「あ、これ本番で使える」という感触が掴めるはずです。