Closed13

cameraxをComposeでいい感じに取り扱いたい人の戦闘ログ

てべすてんてべすてん

モチベーション

  • camerax使いにくすぎる & Composeで書きたいのでうまいことラップしたい
  • camerax触ったことある人もない人も学習コストが低くなるように己の思想を極力入れない

結論

色々用意することで以下のような感じでシンプルにcameraxを使えそう。

var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
val scope = rememberCoroutineScope()

CameraPreview(
  onBind = {
    val preview = previewUseCase()
    val analysis = imageAnalysisUseCase(executor) {}
    imageCapture = imageCaptureUseCase()
    cameraProvider.bindToLifecycle(
      lifecycleOwner,
      CameraSelector.DEFAULT_BACK_CAMERA,
      preview,
      analysis,
      imageCapture,
    )
  },
)

// ボタンなどのクリック時
scope.launch {
  val bitmap = imageCapture.takePicture().toBitmap()
}
用意するコード
カメラのプレビューを表示するComposable
@Composable
fun CameraPreview(
    onBind: OnBindScope.() -> Unit,
    onInitPreviewView: PreviewView.() -> Unit = {},
) {
    var bindingState by remember {
        mutableStateOf<BindingState>(BindingState.Initial)
    }
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    AndroidView(
        factory = {
            PreviewView(it)
                .apply {
                    scaleType = PreviewView.ScaleType.FILL_CENTER
                    layoutParams =
                        ViewGroup.LayoutParams(
                            ViewGroup.LayoutParams.MATCH_PARENT,
                            ViewGroup.LayoutParams.MATCH_PARENT,
                        )
                }.apply(onInitPreviewView)
        },
        update = { previewView ->
            bindingState =
                when (bindingState) {
                    is BindingState.Initial ->
                        try {
                            val cameraProvider =
                                ProcessCameraProvider
                                    .getInstance(
                                        context,
                                    ).get()
                            cameraProvider.unbindAll()

                            OnBindScope(
                                cameraProvider = cameraProvider,
                                previewView = previewView,
                                context = context,
                                lifecycleOwner = lifecycleOwner,
                            ).onBind()

                            BindingState.Success
                        } catch (e: Exception) {
                            BindingState.Failed
                        }

                    is BindingState.Finish -> bindingState
                }
        },
    )
}

private sealed interface BindingState {
    data object Initial : BindingState

    interface Finish : BindingState

    data object Failed : Finish

    data object Success : Finish
}

data class OnBindScope(
    val cameraProvider: ProcessCameraProvider,
    val previewView: PreviewView,
    val context: Context,
    val lifecycleOwner: LifecycleOwner,
)
UseCaseを使いやすく

// UseCase helpers
fun OnBindScope.previewUseCase(builder: Preview.Builder.() -> Preview.Builder = { this }) =
    previewUseCase(
        builder = builder,
        previewView = previewView,
    )

fun previewUseCase(
    builder: Preview.Builder.() -> Preview.Builder = { this },
    previewView: PreviewView,
) = Preview.Builder()
    .builder()
    .build()
    .apply {
        setSurfaceProvider(previewView.surfaceProvider)
    }

fun imageAnalysisUseCase(
    executor: Executor,
    builder: ImageAnalysis.Builder.() -> ImageAnalysis.Builder = { this },
    analyzer: ImageAnalysis.Analyzer,
) = ImageAnalysis.Builder()
    .builder()
    .build()
    .apply {
        setAnalyzer(executor, analyzer)
    }

fun imageCaptureUseCase(builder: ImageCapture.Builder.() -> ImageCapture.Builder = { this }) =
    ImageCapture.Builder()
        .builder()
        .build()

takePictureをsuspend関数でラップしたやつ
suspend fun ImageCapture.takePicture(executor: Executor): ImageProxy =
    suspendCancellableCoroutine { continuation ->
        takePicture(
            executor,
            object : ImageCapture.OnImageCapturedCallback() {
                override fun onCaptureSuccess(image: ImageProxy) {
                    super.onCaptureSuccess(image)
                    continuation.resume(image)
                }

                override fun onError(exception: ImageCaptureException) {
                    super.onError(exception)
                    continuation.cancel(exception)
                }
            },
        )
    }

suspend fun ImageCapture.takePicture(
    outputFileOptions: ImageCapture.OutputFileOptions,
    executor: Executor,
): ImageCapture.OutputFileResults =
    suspendCancellableCoroutine { continuation ->
        takePicture(
            outputFileOptions,
            executor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                    continuation.resume(outputFileResults)
                }

                override fun onError(exception: ImageCaptureException) {
                    continuation.cancel(exception)
                }
            },
        )
    }
てべすてんてべすてん

メインコンセプト

fatにならないように意識しながら組んでみる。
ポイントは初期化処理を onBind コールバックで持ってくることで、呼び出し側で指定できるようにしたところ。
同じCameraPreviewの呼び出しでも画面によって使用するUseCaseなどは違うのでそれらを都度 onBind で指定させることで画面ごとに違うんやでができるようになる。

プレビューを表示するコンポーネント
@Composable
fun CameraPreview(
    onBind: OnBindScope.() -> Unit,
) {
    var bindingState by remember {
        mutableStateOf<BindingState>(BindingState.Initial)
    }
    // ...

    AndroidView(
        factory = {
            PreviewView(it)
                .apply {
                    // TODO 基本的な設定
                }
        },
        update = { previewView ->
            bindingState =
                when (bindingState) {
                    is BindingState.Initial ->
                        try {
                            val cameraProvider =
                                ProcessCameraProvider
                                    .getInstance(
                                        context,
                                    ).get()

                            cameraProvider.unbindAll()
                            onBind()

                            BindingState.Success
                        } catch (e: Exception) {
                            BindingState.Failed
                        }

                    is BindingState.Finish -> bindingState
                }
        },
    )
}
利用側
@Composable
fun CameraPreviewSample() {
        CameraPreview(
            onBind = {
                // UseCaseはしやすいようにヘルパーを用意(後述)
                val preview = previewUseCase()  // onBindコールバック内のpreviewUseCase() はいい感じにsetSurfaceしてくれる
                val analysis = imageAnalysisUseCase(executor) {}  // コールバックが書きやすいでしょ?
                val imageCapture = imageCaptureUseCase(executor)
                // other UseCases ...

                cameraProvider.bindToLifecycle(
                    lifecycleOwner,
                    CameraSelector.DEFAULT_BACK_CAMERA,
                    preview,
                    analysis,
                    imageCapture,
                )
            },
        )
    // TODO ボタンクリック時などにimageCapture.takePictureとかする
}
てべすてんてべすてん

ダッサいコールバックをなんとかする

ImageCapture.takePictureが ダッサいコールバックになってるのでどうにかしたい。

戻り値コールバックとか正気か?
        imageCapture.takePicture(
            executor,
            object : ImageCapture.OnImageCapturedCallback() {
                override fun onCaptureSuccess(image: ImageProxy) {
                    super.onCaptureSuccess(image)
                    continuation.resume(image)
                }

                override fun onError(exception: ImageCaptureException) {
                    super.onError(exception)
                    continuation.cancel(exception)
                }
            },
        )

flow { } とかでいけそうかと思ったら意外とそうもいかず(emitがsuspendだったりするので)。

ChatGPTに聞いたら suspendCancellableCoroutine が良さげっぽいとのこと。

https://chat.openai.com/share/b4866042-cbeb-41ff-8bc7-23b88943952c

suspendCancellableCoroutine良き良き
suspend fun ImageCapture.takePicture(executor: Executor): ImageProxy =
    suspendCancellableCoroutine { continuation ->
        takePicture(
            executor,
            object : ImageCapture.OnImageCapturedCallback() {
                override fun onCaptureSuccess(image: ImageProxy) {
                    super.onCaptureSuccess(image)
                    continuation.resume(image)
                }

                override fun onError(exception: ImageCaptureException) {
                    super.onError(exception)
                    continuation.cancel(exception)
                }
            },
        )
    }

これで呼び出し側からは以下のように呼べる👍

someScope.launch {
  val result :ImageProxy = imageCapture.takePicture(executor)
}

ファイルに保存するバージョンはこんな感じ

撮った写真をファイルに保存するバージョン
suspend fun ImageCapture.takePicture(
    outputFileOptions: ImageCapture.OutputFileOptions,
    executor: Executor,
): ImageCapture.OutputFileResults =
    suspendCancellableCoroutine { continuation ->
        takePicture(
            outputFileOptions,
            executor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                    continuation.resume(outputFileResults)
                }

                override fun onError(exception: ImageCaptureException) {
                    continuation.cancel(exception)
                }
            },
        )
    }

https://twitter.com/tbs__ten/status/1762869554269786307


「コールバック→coroutineで置き換えは suspendCancellableCoroutine が便利」←これをしっかり覚えておこう

てべすてんてべすてん

UseCase() のラップ

ことの発端はPreview UseCaseの setSurfaceProvider 呼び忘れがち問題。これを解決するために悩んだ結果、 onBind内で previewUseCase() 経由で取得すれば setSurfaceProvider 呼んでおいてあげるで な実装になった。
↑賛否両論はありそう。setSurfaceProvider知ってる他の人が見たらびっくりしかねない挙動ではある。(ただ万が一複数回setSurfaceProviderしても問題ない認識)

そのために CameraPreviewの onBind コールバックを以下のように変更して、

  @Composable
  fun CameraPreview(
+     onBind: OnBindScope.() -> Unit,
  ) {
    ...
        update = { previewView ->
          // 初期化時
        cameraProvider.unbindAll()
-       onBind()
+       OnBindScope(
+         previewView = previewView,
+       ).onBind()
        ...
        }
    ...
  }
  
  
+ data class OnBindScope(
+     val cameraProvider: ProcessCameraProvider,
+     val previewView: PreviewView,
+     val context: Context,
+     val lifecycleOwner: LifecycleOwner,
+ )

こんな拡張関数を生やす

fun OnBindScope.previewUseCase() =
    Preview.Builder()
      .build()
      .apply {
          setSurfaceProvider(previewView.surfaceProvider)
      }

するとOnBindScope内(=onBind内)でpreviewUseCaseを呼ぶと previewViewでsetSurfaceProvider(previewView.surfaceProvider)されたPreviewが取得できる😄

CameraPreview(
            onBind = {
                val preview = previewUseCase()
                cameraProvider.bindToLifecycle(... , preview)
                ...

previewUseCaseみたいな関数を UseCase helper と名付けておく。

UseCase のBuilderでカスタムできるようにもしておく

実際には Preview.Builder() を使って何かしら設定をしたいことがありそう(setCameraSelector()とか)。

なのでオプショナル引数builderを好き勝手いじれるようにしておく。

  fun OnBindScope.previewUseCase(
+   builder: Preview.Builder.() -> Preview.Builder = { this },
  ) =
      Preview.Builder()
+       .builder()
        .build()
        .apply {
          setSurfaceProvider(previewView.surfaceProvider)
        }

他のUseCaseのUseCase helper

previewだけ previewUseCase() 呼ぶ方式だと他のUseCaseと乖離しちゃいそうなので、他のUseCaseのUseCase helper も用意してみる。(こっちは単に実装が短くなったね程度にしか恩恵はない)

imageAnalysisUseCase
fun imageAnalysisUseCase(
    executor: Executor,
    builder: ImageAnalysis.Builder.() -> ImageAnalysis.Builder = { this },
    analyzer: ImageAnalysis.Analyzer,
) = ImageAnalysis.Builder()
    .builder()
    .build()
    .apply {
        setAnalyzer(executor, analyzer)
    }
imageCaptureUseCase
fun imageCaptureUseCase(builder: ImageCapture.Builder.() -> ImageCapture.Builder = { this }) =
    ImageCapture.Builder()
        .builder()
        .build()
このスクラップは2ヶ月前にクローズされました