Android アプリ開発「MATRIX」

Androidアプリの開発に役立つサンプル集

【Android開発】Camera Xでカメラアプリを作ってみよう(言語:Kotlin)~ 後編 ~

今回は Android Studio と Kotlin 言語を使って静止画と動画が撮影できる基本的なカメラアプリの作り方をご紹介します。

ベースは Android Studio の公式サイトの「Camera X」サンプルコードを参考にしていますが、時間をかけて調べなければわからない部分も解決済みとなっていますので、開発にあまり時間をかけたくない方や、カメラアプリ開発の初級者の方におすすめします。

※こちらは後編の記事となります。最初からご覧になりたい方は下記より前編からご覧ください。

android-java.hatenablog.jp

~ 目次 ~

7.MainActivity.kt ファイルの編集

① 権限チェックコード追加

ユーザーに権限を取得するコードを記述します。このカメラアプリに必要な権限は以下の3つです。

  • カメラ
    Manifest.permission.CAMERA
  • 録音
    Manifest.permission.RECORD_AUDIO
  • 外部ストレージ書き込み
    Manifest.permission.WRITE_EXTERNAL_STRAGE
    ※WRITE_EXTERNAL_STRAGE は Android Pie (API Level 28) Version 9 以下の端末で必要になる権限です。

権限チェック処理(1/3)、起動した直後に必要な権限をチェックします。※このコードは onCreate() の { } の中に記述します。

if (allPermissionsGranted()) {
    //プレビュー開始
    startPreview()
} else {
    //必要な権限がない場合は権限の確認処理
    ActivityCompat.requestPermissions(
        this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
    )
}

権限チェック処理(2/3)、これは権限チェック処理(1/3)から呼ばれるメソッドです。必要な権限を取得すると「true」になります。※このコードは onCreate() の { } の外に記述します。

private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
    ContextCompat.checkSelfPermission(
        baseContext, it) == PackageManager.PERMISSION_GRANTED
}

権限チェック処理(3/3)、権限取得ダイアログに反応するファンクションです。ユーザーが必要な権限すべてを許可すると「startPreview()」が呼び出されてプレビュー画面が表示されますが、それ以外の場合はアプリを終了します。※このコードも onCreate() の { } の外に記述します。

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if (requestCode == REQUEST_CODE_PERMISSIONS) {
        if (allPermissionsGranted()) {
            //プレビュー開始
            startPreview()
        } else {
            //必要な権限が取得できない場合はアプリを終了
            finish()
        }
    }
}

companion object { } の中にファイル名のフォーマット、リクエストコードと、取得する必要がある権限を記述します。

companion object {
    //ファイル名フォーマット
    private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
    //リクエストコード
    private const val REQUEST_CODE_PERMISSIONS = 10

    //必要な権限(2または3権限)
    private val REQUIRED_PERMISSIONS =
        mutableListOf (
            Manifest.permission.CAMERA ,
            Manifest.permission.RECORD_AUDIO
        ).apply {
            if ( Build.VERSION.SDK_INT <= Build.VERSION_CODES.P ) {
                add( Manifest.permission.WRITE_EXTERNAL_STORAGE )
            }
        }.toTypedArray()
}

② プレビュー用コード追加

背面カメラからの映像をレイアウトファイルの「preview(androidx.camera.view.PreviewView)」に表示してプレビューします。

private fun startPreview() {

    val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

    cameraProviderFuture.addListener({

        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            val preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewBinding.preview.surfaceProvider)
                }

            //静止画撮影用
            imageCapture = ImageCapture.Builder().build()

            
            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
                .build()
            //動画撮影用
            videoCapture = VideoCapture.withOutput(recorder)

            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                cameraProvider.unbindAll()

                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCapture,
                    videoCapture
                )

            } catch (e: Exception) {
                Log.d("Camera X sample","エラーが発生しました", e)
            }
        }, ContextCompat.getMainExecutor(this))
    }

③ 静止画撮影コード追加

静止画を撮影するコードです。画像の保存先は任意で決めることができますが「DCIM」フォルダ内にすると撮影した画像がスムーズに認識されます。画像のフォーマットは「jpeg」です。

private fun takePhoto() {

    val imageCapture = this.imageCapture ?: return

    val name = SimpleDateFormat(FILENAME_FORMAT, Locale.ROOT)
        .format(System.currentTimeMillis())
    val contentValues = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
            put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/MyImage")
        }
    }

    val outputOptions = ImageCapture.OutputFileOptions.Builder(
        contentResolver,
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues
    ).build()

    imageCapture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(this),
        object : ImageCapture.OnImageSavedCallback {
            override fun onError(exception: ImageCaptureException) {
                Log.d("Camera X sample","撮影エラー(静止画)")
            }
            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                val msg = "撮影成功(静止画)"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
            }
        }
    )
}

④ 動画撮影コード追加

動画の撮影コードです。静止画の撮影コードより少し複雑になっています。動画の保存形式は「mp4」です。保存先は「Movies」フォルダ内をおすすめします。

private fun captureVideo() {

    val videoCapture = this.videoCapture ?: return
    viewBinding.startCapture.isEnabled = false

    val curRecording = recording
    if (curRecording != null) {
        curRecording.stop()
        recording = null
        return
    }

    val name = SimpleDateFormat(FILENAME_FORMAT, Locale.ROOT)
        .format(System.currentTimeMillis())
    val contentValues = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
        put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
            put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/MyVideo")
        }
    }

    val mediaStoreOutputOptions = MediaStoreOutputOptions
        .Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
        .setContentValues(contentValues)
        .build()
    recording = videoCapture.output
        .prepareRecording(this, mediaStoreOutputOptions)
        .apply {
            if (PermissionChecker.checkSelfPermission(this@MainActivity,
                Manifest.permission.RECORD_AUDIO) ==
                PermissionChecker.PERMISSION_GRANTED)
            {
                withAudioEnabled()
            }
        }
        .start(ContextCompat.getMainExecutor(this)) { recordEvent ->
            when (recordEvent) {
                is VideoRecordEvent.Start -> {
                    viewBinding.startCapture.apply {
                        text = getString(R.string.stop_capture)
                        isEnabled = true
                    }
                }
                is VideoRecordEvent.Finalize -> {
                    if (!recordEvent.hasError()) {
                        val msg = "撮影成功(動画)"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                    } else {
                        recording?.close()
                        recording = null
                        Log.d("Camera X sample","撮影エラー(動画)")
                    }
                    viewBinding.startCapture.apply {
                        text = getString(R.string.start_capture)
                        isEnabled = true
                    }
                }
            }
        }
}

⑤ 完成(MainActivity.kt)

MainActivity.kt の完成形です。

import android.Manifest
import android.content.ContentValues
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import smart.and.small.software.java.gr.jp.cameraxsample.databinding.ActivityMainBinding
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

 

class MainActivity : AppCompatActivity() {

    private lateinit var viewBinding: ActivityMainBinding
    private var imageCapture: ImageCapture? = null
    private var videoCapture: VideoCapture<Recorder>? = null
    private var recording: Recording? = null
    private lateinit var cameraExecutor: ExecutorService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)

        //権限チェック(1/3)
        if (allPermissionsGranted()) {
            startPreview()   //プレビュー開始
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }

        //静止画撮影ボタン(クリックリスナー)
        viewBinding.takePhoto.setOnClickListener { takePhoto() }

        //動作撮影ボタン(クリックリスナー)
        viewBinding.startCapture.setOnClickListener { captureVideo() }

        cameraExecutor = Executors.newSingleThreadExecutor()
    }

    //権限チェック(2/3)
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    //権限チェック(3/3)
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startPreview()   //プレビュー開始
            } else {
                //必要な権限が取得できない場合はアプリを終了する
                finish()
            }
        }
    }

    //静止画撮影
    private fun takePhoto() {

        val imageCapture = this.imageCapture ?: return

        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.ROOT)
            .format(System.currentTimeMillis())
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, name)
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/Image")
            }
        }

        val outputOptions = ImageCapture.OutputFileOptions.Builder(
            contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues
        ).build()

        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exception: ImageCaptureException) {
                    Log.d("Camera X sample","撮影エラー(静止画)")
//                    Toast.makeText(baseContext, "Error", Toast.LENGTH_SHORT).show()
                }
                override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                    val msg = "撮影成功(静止画)"
                    Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                }
            }
        )
    }

    //動画撮影
    private fun captureVideo() {

        val videoCapture = this.videoCapture ?: return
        viewBinding.startCapture.isEnabled = false

        val curRecording = recording
        if (curRecording != null) {
            curRecording.stop()
            recording = null
            return
        }

        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.ROOT)
            .format(System.currentTimeMillis())
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, name)
            put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/Video")
            }
        }

        val mediaStoreOutputOptions = MediaStoreOutputOptions
            .Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
            .setContentValues(contentValues)
            .build()
        recording = videoCapture.output
            .prepareRecording(this, mediaStoreOutputOptions)
            .apply {
                if (PermissionChecker.checkSelfPermission(this@MainActivity,
                    Manifest.permission.RECORD_AUDIO) ==
                    PermissionChecker.PERMISSION_GRANTED)
                {
                    withAudioEnabled()
                }
            }
            .start(ContextCompat.getMainExecutor(this)) { recordEvent ->
                when (recordEvent) {
                    is VideoRecordEvent.Start -> {
                        viewBinding.startCapture.apply {
                            text = getString(R.string.stop_capture)
                            isEnabled = true
                        }
                    }
                    is VideoRecordEvent.Finalize -> {
                        if (!recordEvent.hasError()) {
                            val msg = "撮影成功(動画)"
                            Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                        } else {
                            recording?.close()
                            recording = null
                            Log.d("Camera X sample","撮影エラー(動画)")
                        }
                        viewBinding.startCapture.apply {
                            text = getString(R.string.start_capture)
                            isEnabled = true
                        }
                    }
                }
            }
    }

    //プレビュー開始
    private fun startPreview() {

        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({

            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            //プレビュー
            val preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewBinding.preview.surfaceProvider)
                }

            //静止画撮影
            imageCapture = ImageCapture.Builder().build()

            //動画撮影
            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
                .build()
            videoCapture = VideoCapture.withOutput(recorder)

            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                cameraProvider.unbindAll()

                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCapture,
                    videoCapture
                )

            } catch (e: Exception) {
                Log.d("Camera X sample","エラーが発生しました", e)
            }
        }, ContextCompat.getMainExecutor(this))
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
    }

    companion object {
        //ファイル名フォーマット
        private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
        //リクエストコード
        private const val REQUEST_CODE_PERMISSIONS = 10
        //取得する権限
        private val REQUIRED_PERMISSIONS =
            mutableListOf(
                Manifest.permission.CAMERA,
                Manifest.permission.RECORD_AUDIO
            ).apply {
                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
                    add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
                }
            }.toTypedArray()
    }
}

 8.動作テスト

アプリを実行すると背面カメラのプレビュー画面と2つのボタンが表示されます。

そして「PHOTO」ボタンを押すと静止画像が撮影され、「VIDEO」ボタンを押すと動画の撮影がスタートします。動画の撮影がスタートすると「VIDEO」ボタンのラベルが「STOP」に変わり、その「STOP」ボタンを押すと動画の撮影が停止します。

撮影した画像と動画は Android 標準の Google フォトアプリから閲覧することができます。

~ まとめ・備考 ~

今回は、カメラ映像のプレビューを表示して静止画と動画の撮影ができるまでを説明しましたが、今後は Camera X での「露出補正」「マニュアルズーム」「ピンチズーム」「マニュアルフォーカス」「タッチフォーカス」「マニュアル露出」など、カメラアプリに必要な機能の作り方をご説明する予定です。

END