本文へスキップ
すべての記事

2026年のCameraX:カメラセッションをリークさせずにPreviewとImageAnalysisを束縛する

AndroidのCameraX実践ガイド。PreviewとImageAnalysisをライフサイクルに束縛する方法、正しいbackpressure戦略、そして回転が引き起こすクラッシュについて。

MFKAPPS 1 分で読めます

私のアプリのうち2つは、カメラを何かに向けて1秒未満で答えを得ることを前提にしている。バーコードをスキャンするStockyと、請求書を読み取るSublyだ。両者の裏にあるビジョンモデル——ML KitのバーコードスキャナーとテキストレコグナイザーIについては別の記事で扱っている。この記事はその両方の下にあるレイヤー、CameraXについてだ。プレビューを表示している画面が生きている間だけ、1フレームも余分に開いたままにならずに開いていなければならない部分だ。

CameraXは動かすのは簡単だが、微妙に間違えるのも簡単だ。デモコードは数行でプレビューとアナライザーを束縛し、初回実行ではうまく動く。バグが出てくるのはその後だ——回転時、画面間の素早い行き来、スキャン途中でアプリを一時停止する端末で。ここでは実際に正しくしておくべきことをまとめる。

activityではなくライフサイクルに束縛する

CameraXを生のCamera2の代わりに使う理由はAPIの見た目ではなくbindToLifecycleにある。ユースケースと一緒にLifecycleOwnerを渡せば、CameraXはそのownerがSTARTEDに達したときにカメラを起動し、そうでなくなったときに解体する——間違えようのある手動のonPause/onResumeカメラ配線は不要だ。

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

cameraProviderFuture.addListener({
    val cameraProvider = cameraProviderFuture.get()
    cameraProvider.unbindAll()

    val preview = Preview.Builder().build().also {
        it.surfaceProvider = previewView.surfaceProvider
    }

    val analysis = ImageAnalysis.Builder()
        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
        .build()
        .also { it.setAnalyzer(analysisExecutor, ::analyzeFrame) }

    cameraProvider.bindToLifecycle(
        lifecycleOwner,
        CameraSelector.DEFAULT_BACK_CAMERA,
        preview,
        analysis,
    )
}, ContextCompat.getMainExecutor(context))

束縛の前にあるこのunbindAll()は念のための保険的なコードではなく、最も一般的なCameraXのクラッシュに対する修正そのものだ。ProcessCameraProvider.getInstance()ListenableFutureを返すため、束縛はリスナー上で非同期に行われる。古いセッションがきれいに解放される前に画面が再生成される(回転、コンフィグ変更、すでに束縛済みのスキャン画面への戻り)と、同じカメラを奪い合う2組のユースケースができてしまうか、あるユースケースがすでに別のライフサイクルに束縛されていると告げるIllegalStateExceptionが発生する。先にunbindAll()を呼ぶことで、すべての束縛が冪等になる——onResumeを何回通過したかはもう問題にならない。

プレビューと解析は同じフレームの2つの消費者

PreviewImageAnalysisはパイプラインの段階ではない——カメラが同時に供給する2つの独立したユースケースだ。プレビューはユーザーにPreviewView上でライブ映像を見せる。アナライザーはフレームごとに独自のImageProxyを受け取り、その上で推論を実行する。両方を一緒に束縛することが、「ライブスキャンオーバーレイ付きカメラ」を2台のカメラではなく1回の呼び出しにする理由だ。またこれは、アナライザーの速度が画面上でユーザーが見るものに影響しないことも意味する——遅いモデルがプレビューをカクつかせることはなく、これはスキャナーがどれだけ「反応が良い」と感じられるかにとって、見た目以上に重要だ。

backpressure戦略は些細な設定ではない

最近のCameraXのリリースではImageAnalysisのデフォルトはSTRATEGY_KEEP_ONLY_LATESTだが、明示的に設定しておく価値はある。代替のSTRATEGY_BLOCK_PRODUCERは、アナライザーが追いつけないときにフレームをキューに入れるが、バーコードやOCRのモデルが30fpsのカメラに追いつくことはめったにないからだ。KEEP_ONLY_LATESTは、アナライザーがまだ処理中の間、最新のフレーム以外をすべて破棄する。これはまさに望む挙動だ——パイプラインが遅れて1秒前のフレームを処理する代わりに、ユーザーはライブプレビューを見ながら、モデルが実際に見る余裕があったフレームからの結果を得られる。

毎回ImageProxyを閉じること、さもなければフィードは静かに止まる

これはテストで最も気づきにくいバグだ。クラッシュしないからだ——ただ止まるだけ。ImageAnalysisは、KEEP_ONLY_LATESTの下でさえ、前のImageProxyが閉じられるまでアナライザーに新しいフレームを配信しない。nullな画像に対する早期のreturnfinallyより前で投げられる例外、あるいは非同期で実行されコールバック内でしかproxyを閉じないルックアップ——これらのどれもが1つのImageProxyを永遠に開いたままにしてしまい、そのフレーム以降、アナライザーは静かに黙り込む。エラーもログもなく、ただスキャンをやめたスキャナーが残るだけだ。

fun analyzeFrame(image: ImageProxy) {
    try {
        val media = image.image ?: return
        val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
        scanner.process(input).addOnSuccessListener { /* handle result */ }
    } finally {
        image.close()
    }
}

クローズ処理はハッピーパスの末尾ではなくfinallyに置くこと。セットアップ全体の中で、見落としやすく後から調べるのに高くつくのはこの1行だけだ。

束縛の前に権限を確認する、コールバックの中ではなく

CameraXはカメラ権限を代わりにリクエストしてくれないし、権限なしで束縛しても優雅には失敗しない——CAMERAがまだ許可されていない場合、bindToLifecycleは画面をクラッシュさせるSecurityExceptionを投げる。修正は順序の問題だ。ProcessCameraProviderのリスナーが実行される前にContextCompat.checkSelfPermissionを確認し、ActivityResultContracts.RequestPermissionを通過させておくこと。リスナーの中でやってはいけない。ユーザーが一度拒否した後にしか実行されない権限確認や、非同期のprovider futureと競合する権限確認は、新規インストール時にしか再現しないクラッシュレポートを生む原因になる。

まとめ

CameraXが生のCamera2に対して優位に立つ理由はただ一つ——実際にライフサイクルの所有権を渡しさえすれば、「カメラのライフサイクル管理」を解決済みの問題に変えてくれることだ。LifecycleOwner経由で束縛し、回転やナビゲーションが決してユースケースを二重に束縛できないよう毎回の束縛前にunbindAll()を呼び、デフォルトに頼らずbackpressure戦略を明示的に設定し、finallyの中で毎回ImageProxyを閉じる。この4点を正しく行えば、カメラは退屈なものになる——ユーザーが電話をバーコードや請求書に向けるたびに毎回動作しなければならない機能にとって、それこそが求めるべき姿だ。