개발/Android

[Android] Jetpack Compose에서 CameraX 사용 방법

wom-bat 2024. 11. 14. 20:25

CameraX는 Jetpack 라이브러리 중 하나로, 카메라 앱을 더욱 쉽게 개발할 수 있도록 설계되었습니다. 새로운 앱에 카메라 기능을 추가할 계획이시라면 CameraX를 활용하는 것이 좋습니다. CameraX는 대부분의 Android 기기에서 잘 작동하며, Android 5.0(API 레벨 21)까지도 호환되어 호환성 문제를 줄여줍니다. 또한, 일관성 있고 사용이 쉬운 API를 제공해 개발 효율성을 높입니다.

이번 포스팅에서는 CameraX 아키텍처와 Compose로 구현된 앱에서 어떤 식으로 활용할 수 있는지 알아보겠습니다.

 

Camera 아키텍처

CameraX를 사용하면 추상화 UseCase를 통해 장치의 카메라와 연결할 수 있습니다. 대표적으로 아래 4가지 Usecase를 활용할 수 있습니다.

  • Preview: previewView와 같은 미리보기를 표시할 영역을 허용합니다.
  • Image analysis: 머신러닝 등의 분석을 위해 CPU에서 액세스 할 수 있는 버퍼를 제공합니다.
  • Image capture: 사진을 캡처하고 저장합니다.
  • Video capture: VideoCapture로 동영상 및 오디오를 캡처합니다.

위 Usecase 들은 서로 결합해서 사용하거나 동시에 활성화하여 활용할 수 있습니다. 예를 들면 카메라에 표시되는 이미지를 사용자가 Preview로 볼 수 있게 하면서 이미지를 분석하여 사진 속 인물이 웃는 경우에만 이미지를 캡처할 수 있습니다.

CameraX는 CameraController 또는 CameraProvider로 구성할 수 있습니다.

 

CameraController

CameraController는 단일 클래스로 대부분의 CameraX 핵심 기능을 제공합니다. 가장 간단한 방법이고 설정 코드가 거의 필요하지 않습니다. Camera Controller를 확장하는 클래스는 LifecycleCameraController입니다.

val previewView: PreviewView = viewBinding.previewView
var cameraController = LifecycleCameraController(baseContext)
cameraController.bindToLifecycle(this)
cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
previewView.controller = cameraController

CameraController의 기본 UseCase는 Preview, ImageCapture, ImageAnalysis입니다. ImageCaputre 또는 ImageAnalysis를 사용 중지하거나 VideoCapture를 사용하려면 setEnabledUseCases() 메서드를 사용하세요.

 

CameraProvider

CameraProvider는 간단하게 사용할 수 있으면서도, 개발자가 다양한 설정을 직접 조정할 수 있어 커스텀이 필요한 서비스에 적합합니다. 예를 들어 이미지의 회전 설정이나 ImageAnalysis의 출력 이미지 형식 지정이 가능합니다.

또한, CameraPreview에서는 맞춤 Surface를 사용할 수 있으며, CameraController와 달리 PreviewView에 종속되지 않습니다. UseCase는 set()으로 설정하고 build()로 구성하며, cameraProvider.bindToLifecycle()을 통해 애플리케이션의 onResume()이나 onPause()에서 별도의 메서드 호출 없이 카메라와 수명 주기를 연결할 수 있습니다.

아래는 Surface와 상호작용하는 CameraProvider의 간단한 예제 코드입니다.

val preview = Preview.Builder().build()
val viewFinder: PreviewView = findViewById(R.id.previewView)

// The use case is bound to an Android Lifecycle with the following code
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview)

// PreviewView creates a surface provider and is the recommended provider
preview.setSurfaceProvider(viewFinder.getSurfaceProvider())

 

Compose에서 CameraX 사용

이번 포스팅에서는 CameraController를 사용하여 Preview와 ImageCapture Usecase를 다루어 보겠습니다. 간단한 UseCase이지만 추후 확장성을 고려하여 인터페이스와 구현체를 분리하여 작성해 보겠습니다.

 

Android 프로젝트에서 domain 모듈과 camera 모듈을 추가한 뒤 domain 모듈에 인터페이스와, camera 모듈에 인터페이스 구현체 클래스를 작성합니다.

 

CameraCapture Interface

domain 모듈의 CameraCapture 인터페이스는 카메라 기능을 손쉽게 구성할 수 있도록 설계하였습니다.

import android.net.Uri
import androidx.camera.view.PreviewView
import androidx.lifecycle.LifecycleOwner

interface CameraCapture {
    fun initialize(lifecycleOwner: LifecycleOwner)
    fun getPreviewView() : PreviewView
    fun takePicture(onImageCaptured: (Uri) -> Unit)
    fun unBindCamera()
}
  • initialize: 카메라 설정을 시작하는 함수입니다.
  • getPreviewView: Compose에서 카메라 PreviewView를 화면에 표시하는 데 사용됩니다.
  • capturePicture: 사진을 촬영하고 처리하는 함수입니다.
  • unBindCamera: 카메라 자원을 안전하게 해제하여 메모리 누수를 방지합니다.

이와 같은 구조로 CameraCapture 인터페이스를 사용하면 카메라 기능을 효율적으로 구현할 수 있습니다.

 

CameraCapture implementation

camera 모듈의 CameraCaptureImpl 클래스는 CameraCapture 인터페이스를 구현하여 Android의 CameraX를 활용해 카메라 기능을 간단하게 구성할 수 있도록 돕습니다. 아래 각 함수는 초기화, 미리보기 설정, 사진 촬영, 자원 해제 기능을 수행합니다.

@Singleton
class CameraCaptureImpl @Inject constructor(
    private val context: Context,
) : CameraCapture {
    private lateinit var previewView: PreviewView
    private lateinit var cameraController: LifecycleCameraController

    override fun initialize(lifecycleOwner: LifecycleOwner) {
        previewView = PreviewView(context)
        cameraController = LifecycleCameraController(context)
        cameraController.bindToLifecycle(lifecycleOwner)
        cameraController.cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
        previewView.controller = cameraController
    }

    override fun getPreviewView(): PreviewView {
        return previewView
    }

    override fun takePicture(onImageCaptured: (Uri) -> Unit) {
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, "${System.currentTimeMillis()}.jpg")
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
            }
        }
        val outputOptions = ImageCapture.OutputFileOptions.Builder(
            context.contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues
        ).build()

        cameraController.takePicture(outputOptions, ContextCompat.getMainExecutor(context),
            object : ImageCapture.OnImageSavedCallback {
                override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                    outputFileResults.savedUri?.let {
                        onImageCaptured(it)
                    }
                }

                override fun onError(exception: ImageCaptureException) {
                    Log.e("CameraCaptureManager", "Photo capture failed: ${exception.message}")
                }
            })
    }

    override fun unBindCamera() {
        cameraController.unbind()
    }
}

 

  • initialize: PreviewView와 LifecycleCameraController를 초기화하고, 카메라를 LifecycleOwner에 바인딩하여 카메라의 생명 주기를 관리하며 기본으로 전면 카메라를 선택합니다.
  • getPreviewView: 카메라 미리보기를 위해 초기화된 PreviewView를 반환합니다.
  • capturePicture: 이미지 캡처를 위한 설정을 정의하고, 캡처한 이미지를 MediaStore에 저장합니다. 성공 시 onImageCaptured 콜백을 통해 URI를 전달합니다.
  • unBindCamera: 카메라 컨트롤러를 해제하여 리소스를 정리합니다.

CameraViewModel

CameraViewModel 클래스는 카메라 기능을 위한 ViewModel입니다. 필자의 경우 Dagger Hilt를 사용해 CameraCapture 인터페이스를 주입받으며, LiveData를 통해 카메라 상태와 미리보기 화면을 관리할 수 있도록 하였습니다.

 

▶CameraCapute 의존성 주입을 위한 Module 정의

더보기
@Module
@InstallIn(SingletonComponent::class)
object CameraModule {
    @Provides
    @Singleton
    fun provideCameraCapture(
        @ApplicationContext context: Context,
    ): CameraCapture {
        return CameraCaptureImpl(context)
    }
}

 

@HiltViewModel
class CameraViewModel @Inject constructor(
    application: Application,
    cameraCapture: CameraCapture,
) : AndroidViewModel(application) {

    private val _cameraX = MutableLiveData<CameraCapture?>(null)
    val cameraX: LiveData<CameraCapture?> = _cameraX

    private val _preview = MutableLiveData<PreviewView?>(null)
    val preview: LiveData<PreviewView?> = _preview

    private val _showCameraView = MutableLiveData<Boolean>(false)
    val showCameraView: LiveData<Boolean> = _showCameraView

    init {
        _cameraX.value = cameraCapture
    }

    fun initialize(lifecycleOwner: LifecycleOwner) {
        cameraX.value?.let { capture ->
            capture.initialize(lifecycleOwner)
            _preview.value = capture.getPreviewView()
        }
    }

    fun takePicture() {
        cameraX.value?.takePicture { uri ->
        }
    }

    fun showCameraView() {
        _showCameraView.value = true
    }

    fun closeCameraView() {
        cameraX.value?.unBindCamera()
        _showCameraView.value = false
        _preview.value = null
    }

}

 

  • initialize: CameraCapture의 initialize() 메서드를 통해 카메라 초기화를 수행하며, PreviewView를 UI에 전달합니다.
  • takePicture: CameraCapture의 사진 촬영 기능을 실행하며, 필요한 경우 콜백을 통해 촬영된 이미지 URI를 반환할 수 있습니다.
  • showCameraView/closeCameraView: 카메라 미리보기 화면을 보여주거나 닫는 데 사용되며, 자원 해제를 통해 메모리 누수를 방지합니다.

Compose CameraView

이제 Compose 함수에서는 ViewModel을 통해 Camera를 제어할 수 있습니다. cameraController를 초기화하기 위해 composeView의 lifecycleOwner를 사용합니다.

@Composable
fun CameraView(
    cameraViewModel: CameraViewModel = hiltViewModel(LocalContext.current as ComponentActivity)
) {
    val lifecycleOwner = LocalLifecycleOwner.current

    val cameraX by cameraViewModel.cameraX.observeAsState()
    val previewView by cameraViewModel.preview.observeAsState()
    LaunchedEffect(cameraX) {
        cameraViewModel.initialize(lifecycleOwner)
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
    ) {
        IconButton(
            modifier = Modifier.align(Alignment.TopStart),
            onClick = {
                cameraViewModel.closeCameraView()
            },
        ) {
            Icon(
                Icons.Filled.ArrowBack,
                contentDescription = stringResource(id = R.string.pop_screen),
            )
        }
        previewView?.let { preview ->
            AndroidView(
                modifier = Modifier
                    .align(Alignment.Center)
                    .fillMaxSize(0.7f),

                factory = { preview }
            )

            Row(modifier = Modifier.align(Alignment.BottomCenter)) {
                IconButton(
                    onClick = {
                        cameraViewModel.takePicture()
                    }
                ) {
                    Icon(
                        ImageVector.vectorResource(id = R.drawable.ic_camera),
                        contentDescription = stringResource(id = R.string.take_photo_button)
                    )
                }
            }
        } ?: run {
            Text(
                text = stringResource(id = R.string.not_available_camera),
                modifier = Modifier.align(Alignment.Center),
                color = Color.White
            )
        }
    }
}

 

 

1. cameraViewmodel을 통해 camera를 초기화하고 previewView 가 준비되면 AndroidView를 통해 previewVIew를 표시합니다.

AndroidView(
	modifier = Modifier
		.align(Alignment.Center)
        .fillMaxSize(0.7f),
	factory = { preview }
)

 

2. 만약 previewView가 준비되지 않았다면 Text로 표시해 줍니다.

Text(
	text = stringResource(id = R.string.not_available_camera),
	modifier = Modifier.align(Alignment.Center),
	color = Color.White
)

 

3. 화면 중앙 하단에 IconButton을 배치하여 사진을 촬영할 수 있도록 합니다.

Row(modifier = Modifier.align(Alignment.BottomCenter)) {
	IconButton(
		onClick = { cameraViewModel.takePicture() }
	) {
			Icon(
				ImageVector.vectorResource(id = R.drawable.ic_camera),
				contentDescription = stringResource(id = R.string.take_photo_button)
			)
	}
}

 

4. 화면 좌측 상단 뒤로 가기 버튼에 Camera 객체의 자원 해제와 CameraView를 닫기 위한 기능을 구현합니다.

IconButton(
	modifier = Modifier.align(Alignment.TopStart),
	onClick = { cameraViewModel.closeCameraView() },
) {
		Icon(
        	Icons.Filled.ArrowBack,
			contentDescription = stringResource(id = R.string.pop_screen),
	)
}

 

5. showCamera는 외부에서 호출하였습니다.

@Composable
fun ShowCameraButton(
    modifier: Modifier,
    cameraViewModel: CameraViewModel = hiltViewModel(LocalContext.current as ComponentActivity),
) {
    IconButton(
        onClick = {
            cameraViewModel.showCameraView()
        },
        modifier = modifier
    ) {
        Surface(
            modifier = Modifier
                .background(Color.White)
                .padding(10.dp)
        ) {
            Icon(
                imageVector = ImageVector.vectorResource(R.drawable.ic_camera),
                contentDescription = "Save Picture"
            )
        }
    }
}

 

6. CameraView는 MainActivity에 표시하였습니다.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    private val cameraViewModel: CameraViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
		        MaterialTheme {
					      Surface(
                        modifier = Modifier.fillMaxSize(),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        val cameraViewModel by cameraViewModel.showCameraView.observeAsState(initial = false)
                        if (cameraViewModel) {
                            CameraView()
                        } else {
                            ...//다른 화면 표시
                        }
                    }
                }
            }
        }
    }
}

 

결과물:

1. 에뮬레이터 전면 카메라 (cameraController.cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA)

2. 에뮬레이터 후면 카메라 (cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA)

후면 카메라 미리보기

 

 

이번 포스팅에서는 Compose에서 간단히 구현할 수 있는 CameraX 예제를 살펴보았습니다.

읽어주셔서 감사합니다!

 


참고: https://developer.android.com/media/camera/camerax/architecture