最短でJetpack ComposeにCameraXを導入する

この記事では、
最短でJetpack ComposeにCameraXを組み込むための方法について紹介します！

## CameraXとは？

CameraXとは、カメラ機能の開発が簡単にできるJetpackライブラリです。
端末依存などを気にせず、一貫性のあるAPIで開発を行うことができます。

とても便利なライブラリなのですが、
ドキュメントや、公式のサンプルをみると、Android Viewでの実装例しか載っておらず、
Jetpack Composeでの実装例がありません。

https://developer.android.com/media/camera/camerax?hl=ja

https://github.com/android/camera-samples

今回は、CameraXをJetpack Composeで動かしたい！という人のために、
Jetpack Composeでの実装例を紹介していきます。

## CameraXを動かすために必要なもの

具体的なコードの解説に入る前に、
まず、CameraXを動かすために必要なものを整理しましょう。


### PreviewView
カメラのプレビューが表示されるUIです。
内部的には、SurfaceViewか、TextureViewのどちらか使用されます。


### ProcessCameraProvider
アプリケーションのプロセス内の、任意の`LifecycleOwner`に
カメラのライフサイクルをバインドするために必要です。

`ProcessCameraProvider#bindToLifecycle`で、カメラのライフサイクルをバインドし、
`ProcessCameraProvider#unbind`か
`ProcessCameraProvider#unbindAll`で、解除します。

### Preview

画面上にカメラのプレビューを表示するために必要です。
`Preview.SurfaceProvider`を介して提供されるSurfaceに接続されます。

### CameraSelector

使用するカメラを選択します。

内部のfilter関数で、端末が使用可能なカメラから、
使用したいカメラをフィルタリングします。

今回は、フロントカメラとバックカメラの選択に使用しています。

### ImageCapture

写真を撮るために使用します。

### ImageAnalysis

画像の解析に使用します。今回は特別な設定を行なっていません。

## 実装例

### カメラのプレビューを表示するComposable関数を用意する

まず、カメラのプレビューを表示するために必要なComposable関数を作ります。
CameraXのComposable関数は標準で用意されていないので、
AndroidViewでラップする必要があります。

`calculatePreviewSize`は未実装なので、エラーになりますが、
気にせず進めてください。

```kotlin
@Composable
internal fun CameraPreview(
    modifier: Modifier = Modifier,
    update: (PreviewView) -> Unit,
) {
    val density = LocalDensity.current
    val configuration = LocalConfiguration.current

    val previewSize =
        remember(density, configuration) {
            val windowSize =
                with(density) {
                    IntSize(
                        width = configuration.screenWidthDp.dp.toPx().toInt(),
                        height = configuration.screenHeightDp.dp.toPx().toInt(),
                    )
                }
            calculatePreviewSize(windowSize) // 未実装
        }

    AndroidView(
        modifier = modifier,
        factory = { context ->
            PreviewView(context).apply {
                layoutParams =
                    android.view.ViewGroup.LayoutParams(
                        previewSize.width,
                        previewSize.height,
                    )
            }
        },
        update = update,
    )
}
```

LocalConfigurationから、端末の画面サイズを取得し、
そこからPreviewViewのサイズを決定しています。

次に、`calculatePreviewSize`を実装します。

```kotlin
private fun calculatePreviewSize(windowSize: IntSize): IntSize {
    val windowWidth = windowSize.width
    val windowHeight = windowSize.height

    val aspectRatio = 3f / 4f

    val newWidth: Int
    val newHeight: Int

    if (windowWidth < windowHeight) {
        newWidth = windowWidth
        newHeight = (windowWidth * (1f / aspectRatio)).roundToInt()
    } else {
        newWidth = (windowHeight * (1f / aspectRatio)).roundToInt()
        newHeight = windowHeight
    }

    return IntSize(newWidth, newHeight)
}
```

縦横比が3:4になるように計算しています。
横長の画面の場合は、4:3になります。

### CameraXの処理をひとまとめにするユーティリティクラスの作成

CameraXを動かすために必要なクラスは複数ありますが、
それぞれを個別に扱うのは大変です。

なので、ひとまとめにして扱えるユーティリティクラスを作成します。

次のクラスを作成してください。

```kotlin
internal class CameraProvider {
    data class CameraPreviewConfig(
        @AspectRatio.Ratio val aspectRatio: Int,
        @ImageOutputConfig.RotationValue val rotation: Int,
    )
}

@Composable
internal fun rememberCameraProvider(): CameraProvider {
    return remember {
        CameraProvider()
    }
}
```

`CameraPreviewConfig`は、アスペクト比と回転角を保持します。

`CameraProvider`に、CameraXを使うのに必要な処理を書いていきます

#### ProcessCameraProviderの取得処理

```kotlin:CameraProvider
    private fun getProvider(context: Context): ProcessCameraProvider {
        return ProcessCameraProvider.getInstance(context).get()
    }
```

#### CameraSelectorの取得処理

CameraSelectorを生成する際に、フロントとバックどちらのカメラを使用するか指定します。
フロント/バックの指定には、CameraSelectorの定数が用意されていますが、
そのままだと使いづらいので、独自のラッパークラスを作りましょう。

```kotlin
internal sealed interface LensFacing {
    data object Front : LensFacing

    data object Back : LensFacing

    companion object {
        fun from(value: Int): LensFacing {
            return when (value) {
                CameraSelector.LENS_FACING_FRONT -> Front
                CameraSelector.LENS_FACING_BACK -> Back
                else -> throw IllegalArgumentException("Unknown lens facing: $value")
            }
        }
    }

    @CameraSelector.LensFacing
    fun toValue(): Int {
        return when (this) {
            Front -> CameraSelector.LENS_FACING_FRONT
            Back -> CameraSelector.LENS_FACING_BACK
        }
    }

    fun toInverse(): LensFacing {
        return when (this) {
            Front -> Back
            Back -> Front
        }
    }
}

```


次に、CameraSelectorの取得処理を実装します。

```kotlin:CameraProvider

    fun hasBackCamera(context: Context): Boolean {
        val provider = getProvider(context)
        return provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)
    }

    fun hasFrontCamera(context: Context): Boolean {
        val provider = getProvider(context)
        return provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)
    }
    
    private fun buildCameraSelector(
        context: Context,
        requireLensFacing: LensFacing,
    ): CameraSelector {
        val lensFacing =
            when {
                requireLensFacing is LensFacing.Front && hasFrontCamera(context) -> CameraSelector.LENS_FACING_FRONT
                requireLensFacing is LensFacing.Back && hasBackCamera(context) -> CameraSelector.LENS_FACING_BACK
                else -> throw IllegalStateException("Back and front camera are unavailable")
            }
        return CameraSelector.Builder().requireLensFacing(lensFacing).build()
    }

```

#### Previewの取得処理

Previewを生成するさいには、現在のアスペクトと回転角が必要なので、
`CameraPreviewConfig`の取得処理も一緒に実装します。

```kotlin:CameraProvider
    private fun getCameraProviderConfig(previewView: View): CameraPreviewConfig {
        val rotation = previewView.context.display?.rotation ?: Surface.ROTATION_0
        return CameraPreviewConfig(
            aspectRatio = AspectRatio.RATIO_4_3,
            rotation = rotation,
        )
    }

    private fun buildPreview(previewView: View): Preview {
        val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)

        return Preview.Builder()
            .setTargetAspectRatio(screenAspectRatio)
            .setTargetRotation(rotation)
            .build()
    }
```

#### ImageCaptureの取得処理

```kotlin:CameraProvider
    private fun buildImageCapture(previewView: View): ImageCapture {
        val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)
        return ImageCapture.Builder()
            .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
            .setTargetAspectRatio(screenAspectRatio)
            .setTargetRotation(rotation)
            .build()
    }
```

#### ImageAnalysisの取得処理

```kotlin:CameraProvider
    private fun buildImageAnalysis(previewView: View): ImageAnalysis {
        val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)
        return ImageAnalysis.Builder()
            .setTargetAspectRatio(screenAspectRatio)
            .setTargetRotation(rotation)
            .build()
    }
```


#### プレビューの表示処理実装

必要な関数が実装しおわったので、プレビューの表示処理を実装しましょう。


```kotlin:CameraProvider
    fun bindCameraToPreview(
        context: Context,
        lifecycleOwner: LifecycleOwner,
        previewView: PreviewView,
        requireLensFacing: LensFacing = LensFacing.Back,
    ): CameraController {
        val provider = getProvider(context)

        val cameraSelector = buildCameraSelector(context, requireLensFacing)
        val preview = buildPreview(previewView)
        val imageCapture = buildImageCapture(previewView)
        val imageAnalysis = buildImageAnalysis(previewView)

        provider.unbindAll()

        provider.bindToLifecycle(
            lifecycleOwner,
            cameraSelector,
            preview,
            imageCapture,
            imageAnalysis,
        )
        preview.setSurfaceProvider(previewView.surfaceProvider)

        return CameraController(
            imageCapture = imageCapture,
        )
    }
```

返り値のCameraControllerは、写真を撮るために使用します。
内部でImageCaptureを保持します。

```kotlin
internal class CameraController(
    private val imageCapture: ImageCapture,
) {
    companion object {
        private const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS"
        private const val PHOTO_TYPE = "image/jpeg"
        private const val DESTINATION_PATH = "Pictures/AppName"
    }

    fun takePicture(
        context: Context,
        executor: ExecutorService,
    ) {
        val name =
            SimpleDateFormat(FILENAME, Locale.US)
                .format(System.currentTimeMillis())
        val contentValues =
            ContentValues().apply {
                put(MediaStore.MediaColumns.DISPLAY_NAME, name)
                put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE)
                put(MediaStore.Images.Media.RELATIVE_PATH, DESTINATION_PATH)
            }
        val outputOptions =
            ImageCapture.OutputFileOptions
                .Builder(
                    context.contentResolver,
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    contentValues,
                )
                .build()

        imageCapture.takePicture(
            outputOptions,
            executor,
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exception: ImageCaptureException) {}

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {}
            },
        )
    }
}
```

takePictureが呼ばれると、
Picturesフォルダ配下に画像が保存されます。

### カメラプレビューを表示してみよう

これでCameraXを使えるようになったので、
最後にMainActivityに組み込んで、動きを確かめてみましょう。

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                val context = LocalContext.current
                val lifecycleOwner = LocalLifecycleOwner.current
                val cameraProvider = rememberCameraProvider()
                var cameraController: CameraController? by remember {
                    mutableStateOf(null)
                }

                CameraPreview(
                    update = {
                        cameraController =
                            cameraProvider.bindCameraToPreview(
                                context = context,
                                lifecycleOwner = lifecycleOwner,
                                previewView = it,
                                requireLensFacing = LensFacing.Back,
                            )
                    },
                )
            }
        }
    }
}
```

下記のような表示がされるはずです。

![Screenshot_20240720_214216.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320402/8209d535-dd62-d911-17e4-65b4a0dfa9a8.png)

### おわりに

CameraXをJetpack Composeに組み込む方法について、解説させていただきましたが、
いかがだったでしょうか？

こちらのRepositoryに、今回の実装を組み込んだアプリを公開しているので、
コードをじっくり読みたい人は参考にしてみてください。
https://github.com/shunm-999/GeminiChat