0
3

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

Posted at

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

CameraXとは?

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

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

今回は、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は未実装なので、エラーになりますが、
気にせず進めてください。

@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を実装します。

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を動かすために必要なクラスは複数ありますが、
それぞれを個別に扱うのは大変です。

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

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

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の取得処理

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

CameraSelectorの取得処理

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

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の取得処理を実装します。

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の取得処理も一緒に実装します。

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の取得処理

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の取得処理

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

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

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

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を保持します。

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に組み込んで、動きを確かめてみましょう。

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

おわりに

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

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

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3