見出し画像

【Androidアプリ開発】カメラアプリをJetpack ComposeとCameraXで作ってみた


本記事の概要

※ Androidエンジニア向けの技術的な内容となります。

皆さま、お久しぶりです。
この度、カメラアプリをJetpack ComposeとCameraXで作ってみました📷
リリースというよりは学習目的です。

CameraXとは、Camera2を簡易に実装出来るようにしたラッパーです。
いざ検索すると、Viewシステム(XML形式のUI)向けがほとんどで現状「Jetpack Compose」(宣言的 UI)向けの情報は少ないです。

そこで、勉強をし記事にしてみました。
カメラアプリ開発の参考になればと思います。

なお、この記事では「超ざっくり」してますが、現在執筆中のAndroid入門本(電子書籍)には技術的にも詳しく解説します📚

環境

●開発環境
・MacBook Air 2019
macOS Sonoma 14.3
・Android Studio
Android Studio Iguana | 2023.2.1

検証実機端末:Android 10(APIレベル29)

学習用なので、パッケージ名はプロジェクト作成時のデフォルトである「com.example.myapplication」とします。

パーミッション

マニフェスト

カメラの使用はユーザーの許可が必要です。
まずは、マニフェストXMLにパーミッション定義を追記します。

AndroidManifest.xml

……
</application>
<!--「カメラ」「Pie以下のWRITE_EXTERNAL_STORAGE」の権限 -->
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
……
</manifest>

application終了タグの後辺りに、追記します。
(applicationブロック内では無い点に注意)

Accompanist Permissions

これまで、権限確認の実装は煩雑なものでした。
しかし、Jetpack Composeでは「AccompanistのPermissionsライブラリ」があり、とても簡単に実装出来ます。

●Accompanist
Accompanist」は、Jetpack Compose で未提供の機能を補完する為のライブラリ群です。
Compose API のラボとなっており、公式採用を目的としてます。

【公式】

build.gradle.kts(:app)のdependencies内に以下を追記します。

// AccompanistのPermissionsライブラリ
implementation ("com.google.accompanist:accompanist-permissions:0.28.0")

※Gradleスクリプトは、GroovyでなくKTS形式を例とします

追記後は「Sync Now」を押下を忘れずに💡

●コード
コードのイメージは以下のようになります
MainActivity.kt

@OptIn(ExperimentalPermissionsApi::class) 
@Composable
fun MainCamera(
……
) {
    val permissionList = mutableListOf(Manifest.permission.CAMERA)
    if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
        permissionList.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }

    val multiplePermissionsState = rememberMultiplePermissionsState(permissionList) 

    when {
        multiplePermissionsState.allPermissionsGranted -> { 
            // 「全ての権限取得済み」なので、カメラを起動させる。
        }
        multiplePermissionsState.shouldShowRationale -> { 
            //1度、拒否した事がある」場合。「何故権限が必要か?」の説明を詳して、Permissionランチャーを起動させる。なお .shouldShowRationaleの分岐が無くても、1度拒否した事がある場合は、3つボタンダイアログが出る。
        }
        else -> {
            // それ以外(初回起動など)の場合。Permissionランチャーを起動させる。初回確認は2つボタンダイアログが出る。
        }
    }
}

・WRITE_EXTERNAL_STORAGE
本記事のカメラアプリでは「アプリの外部」に写真を保存します。
Android 9 Pie以下では「WRITE_EXTERNAL_STORAGE」の権限も必要なため付けてます。

・permissionList変数
「rememberMultiplePermissionsState」(複数パーミッション状態変数)を使うためListになります。

たった、これだけ。
Accompanist Permissionsを使えば超簡単。

初回起動時は撮影許可の確認ダイアログ

1度、拒否した事がある場合、「許可」「許可しない」に加え「許可しない(次回から表示しない)」ボタンも表示されます。

この仕様は、shouldShowRationale内から呼ぶとかは関係ありません。
実際に検証してみた所、allPermissionsGrantedとelseの2つの分岐だけでも、拒否済みの場合は、3つボタンのダイアログが出ます。

CameraX

ライブラリの追加

build.gradle.kts(:app)のdependencies内に以下を追記します。

// CameraXライブラリ
val cameraxVersion = "1.2.1"
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation ("androidx.camera:camera-camera2:${cameraxVersion}")
implementation ("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation ("androidx.camera:camera-view:${cameraxVersion}")
implementation ("androidx.camera:camera-extensions:${cameraxVersion}")

Camera2のラッパーなのでcamera-camera2も含みます。

追記後は「Sync Now」を押下を忘れずに💡

ユースケース

●ユースケース
Preview:プレビュー
ImageCapture:写真撮影
ImageAnalysis:画像解析(本記事では割愛)
VideoCapture:動画撮影(本記事では割愛)

CameraXでは、各種ユースケースに機能を実装し、それを使っていきます。

●ライフサイクル
CameraProviderのbindToLifecycleメソッドで、各種ユースケースをライフサイクルにバインド(紐付け)させます。
これにより、ライフサイクル処理(スリープ時など)をCameraX側がしてくれるようになります。

Previewユースケース

「Previewユースケース」についての補足です。
PreviewViewはファインダーを表示するビューです(Surfaceプロバイダーを使い取得する)。
Jetpackライブラリーではあるのですが、現時点ではViewシステムのみ対応、Jetpack Compose では未提供となります。

・Viewシステムでの例

<androidx.camera.view.PreviewView
    android:id="@+id/viewFinder"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
val preview = Preview.Builder()
   .build()
   .also {
       it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
   }

よって、Jetpack Compose で使用するには、AndroidViewを使う必要があります。
・Jetpack Composeでの例

val previewView = remember { PreviewView(context) }
val preview = androidx.camera.core.Preview.Builder()
    .build()
    .also {
        it.setSurfaceProvider(previewView.surfaceProvider)
    }
// …… 中略
// コンポーザブル関数内のUI表示部分
AndroidView({ previewView }, modifier = Modifier.fillMaxSize())

その為、一旦、変数(例ではpreviewView)に格納してから使う必要があります。

究極のコード

頑張って書いた、究極とも言える、MainActivity.ktの全体コード(import文は除いてあります)です。

class MainActivity : ComponentActivity() {
    // 【シャッター音】メディア音の宣言
    private lateinit var sound: MediaActionSound

    // 保存先ディレクトリ関連 ----------------------------------------------------------------------
    private lateinit var outputDirectory: File

    private fun getOutputDirectory(): File {
        // Scoped storage(対象範囲別ストレージ)
        val outDir = getExternalFilesDir(null)?.path.let {
            File(it, resources.getString(R.string.app_name)).apply { mkdirs() }
        }
        return if (outDir != null && outDir.exists()) outDir else filesDir
    }

    // 撮影後のファイルパス表示関連 ----------------------------------------------------------------------
    private lateinit var photoUri: Uri
    private var capturedMsg = mutableStateOf("")

    private fun setCapturedMsg(uri: Uri) {
        photoUri = uri

        val msg = photoUri.toString()
        val msgTemp = msg.replace("file:///storage/emulated/0/", "内部ストレージ:")
        val msg2 = msgTemp.replace("%20", " ")

        capturedMsg.value = msg2
    }

    private fun getCapturedMsg(): String {
        return capturedMsg.value
    }

    // Executorフレームワーク(並行処理ユーティリティ)のインタフェース
    private lateinit var cameraExecutor: ExecutorService

    // ライフサイクル:Activity破棄時
    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
        sound.release() // 【シャッター音】シャッター音インスタンス解放
    }

    // ライフサイクル:Activity生成時
    override fun onCreate(savedInstanceState: Bundle?) {
        sound = MediaActionSound() // 【シャッター音】メディア音のインスタンス生成
        sound.load(MediaActionSound.SHUTTER_CLICK) // 【シャッター音】シャッター音のロード

        outputDirectory = getOutputDirectory() // 保存先ディレクトリ
        cameraExecutor = Executors.newSingleThreadExecutor() // 単一のワーカースレッドExecutor(takePictureで撮影する時に渡す)

        super.onCreate(savedInstanceState)
        setContent {
            MainCamera(
                outputDirectory = outputDirectory,
                executor = cameraExecutor,
                setCapturedMsg = ::setCapturedMsg,
                getCapturedMsg = ::getCapturedMsg,
                sound = sound // 【シャッター音】
            )
        }
    }
}

@OptIn(ExperimentalPermissionsApi::class)
private fun takePhoto(
    imageCapture: ImageCapture,
    outputDirectory: File,
    executor: Executor,
    setCapturedMsg: (Uri) -> Unit
) {
    // ImageCaptureユースケースを安定させる
    val imageCapture = imageCapture ?: return

    // ファイル名フォーマットはタイムスタンプ
    val filenameFormat = "yyyy-MM-dd-HH-mm-ss-SSS"
    // 保存先ファイルのオブジェクト
    val photoFile = File(
        outputDirectory,
        SimpleDateFormat(filenameFormat, Locale.JAPAN).format(System.currentTimeMillis()) + ".jpg"
    )
    // キャプチャした画像を保存する為の出力オプション。
    val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
    // キャプチャの実行
    imageCapture.takePicture(
        outputOptions,
        executor,
        object : ImageCapture.OnImageSavedCallback {
        // 結果はImageCapture.OnImageSavedCallbackでコールバックされる
            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                // 成功時の処理
                // println("Photo capture Succeeded: ${output.savedUri}")
                val savedUri = Uri.fromFile(photoFile)
                setCapturedMsg(savedUri)
            }
            override fun onError(e: ImageCaptureException) {
                // 失敗時の処理
                // println("Photo capture Error: {$e}")
            }
        })
}

// ProcessCameraProviderのインスタンスを返す
private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
    suspendCoroutine { continuation ->
        ProcessCameraProvider.getInstance(this).also { cameraProvider ->
            cameraProvider.addListener({
                continuation.resume(cameraProvider.get())
            }, ContextCompat.getMainExecutor(this))
        }
    }

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainCamera(
    outputDirectory: File,
    executor: Executor,
    setCapturedMsg: (Uri) -> Unit,
    getCapturedMsg: () -> String,
    sound: MediaActionSound // 【シャッター音】
) {
    // 必要な権限を定義
    val permissionList = mutableListOf(Manifest.permission.CAMERA)
    // Android 9 Pie以下では「WRITE_EXTERNAL_STORAGE」の権限も必要
    if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
        permissionList.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }

    // multiplePermissionsStateのインスタンスを生成
    val multiplePermissionsState = rememberMultiplePermissionsState(permissionList)

    when {
        // 全ての権限取得済みの場合
        multiplePermissionsState.allPermissionsGranted -> {
            CameraView(
                outputDirectory = outputDirectory,
                executor = executor,
                setCapturedMsg = setCapturedMsg,
                getCapturedMsg = getCapturedMsg,
                sound = sound // 【シャッター音】
            )
        }
        // 1度、拒否した事がある場合
        multiplePermissionsState.shouldShowRationale -> {
            Column {
                Text("許可を与えてください(本来、1度、拒否された場合の説明も表示)")
                Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) {
                    Text("ボタン")
                }
            }
        }
        // それ以外(権限確認が未だなど)の場合
        else -> {
            Column {
                Text("許可を与えてください")
                Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) {
                    Text("ボタン")
                }
            }
        }
    }
}

@Composable
fun CameraView(
    outputDirectory: File,
    executor: Executor,
    setCapturedMsg: (Uri) -> Unit,
    getCapturedMsg: () -> String,
    sound: MediaActionSound // 【シャッター音】
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    // Previewユースケース
    val previewView = remember { PreviewView(context) }
    val preview = androidx.camera.core.Preview.Builder().build().also {
        it.setSurfaceProvider(previewView.surfaceProvider)
    }

    // カメラの選択
    // 「背面カメラ」選択の例
    val lensFacing = CameraSelector.LENS_FACING_BACK
    val cameraSelector = CameraSelector.Builder()
        .requireLensFacing(lensFacing)
        .build()

    // ImageCaptureユースケース
    val imageCapture: ImageCapture = remember { ImageCapture.Builder().build() }

    LaunchedEffect(lensFacing) {
        val cameraProvider = context.getCameraProvider()

        cameraProvider.unbindAll()
        cameraProvider.bindToLifecycle(
            lifecycleOwner,
            cameraSelector,
            preview,
            imageCapture
        )
    }

    // ファインダー
    Box(
        contentAlignment = Alignment.BottomCenter,
        modifier = Modifier.fillMaxSize()
    ) {
        // ファインダー
        AndroidView({ previewView }, modifier = Modifier.fillMaxSize()
        )

        IconButton(
            modifier = Modifier
                .size(250.dp)
                .padding(5.dp)
                .border(1.dp, Color.White),
            onClick = {
                sound.play(MediaActionSound.SHUTTER_CLICK) //【シャッター音】シャッター音を鳴らす
                takePhoto(
                    imageCapture = imageCapture,
                    outputDirectory = outputDirectory,
                    executor = executor,
                    setCapturedMsg = setCapturedMsg
                )
            },
            content = {
                Icon(
                    imageVector = Icons.Rounded.Add,
                    contentDescription = "Image Capture",
                    tint = Color.White,
                    modifier = Modifier
                        .size(200.dp)
                        .padding(30.dp)
                        .border(5.dp, Color.White)
                )
            }
        )
        // 撮影後の保存先パスの表示
        Text(getCapturedMsg(),
            modifier = Modifier.background(
                Color.White
            )
        )
    }
}

import文(50行ぐらい)は除いてます。
その状態で、およそ250行❗️

入門書は初学者を対象にしているので、如何に「簡潔に」するかを練りに練って書いた『究極のコード』なのです📝
合間を縫って書いたので、一週間かかりました〜。

この究極の「全体コード」はそのまま現在執筆中の入門書に掲載します。
厳密的にはimport文も含めますが、それでも、たったの300行程度です✨

出来上がったカメラアプリ

「パーミッション」にて、初回起動時の許可確認ダイアログは説明したので、その続きからとなります。

ファインダーが表示される。

撮影ボタン押下すると、下部に保存先パスが表示される。

押下の度に、どんどん写真が撮れます。
極限にコードを短くしたかった事もあり、撮影後の画像確認画面は特に実装してません。

Android 10以降から使える「Scoped storage(対象範囲別ストレージ)」を使用。
/storage/emulated/0/Android/data/パッケージ名/files
内に保存されます。

現在、Android入門本(電子書籍)を執筆中

現在、Android入門本(電子書籍)を執筆中です。
今回の全体コードの技術的に詳しい解説についても書きます。

早ければ、 5月中に出版なので、興味のある方は是非ご購読ください😉

今後の予定

【1】Android入門本の出版
まずは、Android入門本(電子書籍)の出版📚

【2】note書き溜め記事の公開
去年の10月辺りから忙しくなりnoteはお休みしてました。
ただ、それまでに書き溜めていた記事が結構あります。

大まかには、以下14記事。
・去年(2023年)のクリエイターフェスで4投稿までしたが、残り6投稿分。
・クロームブック(のLinuxコンテナ)等にAndroidアプリ開発環境構築の8記事(※Android入門本はmacOS前提です)

【3】Flutterに着手
その後、Flutter(AndroidとiOSアプリを同時に開発出来るフレームワーク)に着手ですかね📱

【4】IT以外の勉強
それと、IT関連以外にも勉強したい事がいくつかあります💡

【辛島信芳の著書】
IT技術などに興味のある方は、是非ご覧になってください。

     


この記事が気に入ったらサポートをしてみませんか?