[Android] Jetpack Compose에서 CameraX 사용 방법
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