【Androidアプリ開発】カメラアプリをJetpack ComposeとCameraXで作ってみた
本記事の概要
皆さま、お久しぶりです。
この度、カメラアプリをJetpack ComposeとCameraXで作ってみました📷
リリースというよりは学習目的です。
CameraXとは、Camera2を簡易に実装出来るようにしたラッパーです。
いざ検索すると、Viewシステム(XML形式のUI)向けがほとんどで現状「Jetpack Compose」(宣言的 UI)向けの情報は少ないです。
そこで、勉強をし記事にしてみました。
カメラアプリ開発の参考になればと思います。
なお、この記事では「超ざっくり」してますが、現在執筆中のAndroid入門本(電子書籍)には技術的にも詳しく解説します📚
(※2024年6月15日追記)
『 プログラマーにおくる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技術などに興味のある方は、是非ご覧になってください。
この記事が気に入ったらサポートをしてみませんか?