Android 카메라 적용기 - CameraX와 Camera2

윤찬·2025년 6월 25일

Android

목록 보기
5/37


카메라는 소셜 미디어와 공유할 동영상 및 이미지를 캡처하고 문서 및 QR 코드 스캔과 같은 유틸리티를 만드는 등 다양한 애플리케이션 사용 사례를 지원한다.

Anroid 앱에 카메라 기능을 추가하려는 경우 크게 세 가지 옵션들이 있지만 이중 Camera는 더 이상 지원을 안하고 중단되었다.

  • CameraX
  • Camera2
  • Cameara (지원 중단)

대부분 개발자는 CameraX를 사용하여 간단하게 카메라 기능을 사용할 수 있다.
하지만 카메라의 복잡한 기능들을 사용하기 위해서는 Camera2를 사용하는 것이 좋다.(예를 들어 수동 초점, 고속 연사, 10비트 컬러 등의 기능을 사용할 때)

간단히 얘기하면 CameraX 또한 Camera2 패키지 기반으로 동작하지만 간단한 구현방식으로 만들어 놓은 것(대신 세세한 기능 구현 x)이라고 생각하면 된다.

Camera 적용해보기

대부분의 개발자들이 단순히 사진과 동영상 캡처, 카메라 이미지 스트림 분석할 때 주로 사용하는 방법이다.

간단한 예시를 만들어보려고 했는데 안드로이드 공식 사이트에 있는 Codelab이 있기 때문에 이를 참조하여 실제로 한 번 사용해봤다.

Codelab은 현재 XML로 되어 있기 때문에 Compose로만 따로 변형해서 구현했다.

1. 라이브러리 추가

val camerax_version = "1.4.2"
    implementation("androidx.camera:camera-core:${camerax_version}")
    implementation("androidx.camera:camera-camera2:${camerax_version}")
    implementation("androidx.camera:camera-lifecycle:${camerax_version}")
    implementation("androidx.camera:camera-video:${camerax_version}")
    implementation("androidx.camera:camera-view:${camerax_version}")
    implementation("androidx.camera:camera-extensions:${camerax_version}")


2. 카메라 화면 표시하기

class MainActivity : ComponentActivity() {
    //권한 요청. 일단 권한 허용 안하면 종료하기로 설정
    val permissions = registerForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        if (REQUIRED_PERMISSIONS.any {
                permissions[it] == false
            }) {
            Toast.makeText(
                this,
                "do not permission camera feature",
                Toast.LENGTH_SHORT
            ).show()

            finish()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            CameraXExTheme {
            	//권한이 허용되지 않으면 권한 요청하기
                if (!allPermissionsGranted()) {
                    permissions.launch(REQUIRED_PERMISSIONS)
                }
				
                //카메라 화면 
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    CameraScreen(
                        modifier = Modifier.padding(innerPadding),
                        takePicture = { takePhoto(it) }
                    )
                }
            }
        }
    }
	
    //사진 찍기 함수
    private fun takePhoto(imageCapture: ImageCapture) {
        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.KOREA)
            .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, "Pictures/CameraX-Image")
            }
        }

        // Create output options object which contains file + metadata
        val outputOptions = ImageCapture.OutputFileOptions
            .Builder(
                contentResolver,
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                contentValues
            )
            .build()

        // Set up image capture listener, which is triggered after photo has
        // been taken
        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                }

                override fun
                        onImageSaved(output: ImageCapture.OutputFileResults) {
                    val msg = "Photo capture succeeded: ${output.savedUri}"
                    Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                    Log.d(TAG, msg)
                }
            }
        )
    }
	
    //카메라 관련 권한이 전부 허용이 됐는지 판별하는 함수
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it
        ) == PackageManager.PERMISSION_GRANTED
    }

    companion object {
    	//로그 기록 확인용
    	private const val TAG = "CameraXApp"
        //파일 이름 설정
        private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
        //권한 리스트
        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()
    }
}

//카메라 화면
@Composable
fun CameraScreen(
    modifier: Modifier = Modifier,
    takePicture: (ImageCapture) -> Unit,
) {
    val context = LocalContext.current
    val lifecycle = LocalLifecycleOwner.current
    val imageCapture = ImageCapture.Builder().build()
    var currentZoomRatio by remember { mutableStateOf(1f) }
    val scope = rememberCoroutineScope()
	
    //카메라 컨트롤러 라이프사이클에 맞게 적용하기
    val cameraController = remember {
        LifecycleCameraController(context).apply {
            bindToLifecycle(
                lifecycle,
            )
        }
    }
	
    //카메라 화면을 보여주는 PreviewView
    val previewView = remember {
        PreviewView(context).apply {
            scaleType = PreviewView.ScaleType.FILL_END
            implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            controller = cameraController
        }
    }
	
    //카메라 컨트롤과 정보들을 받는 곳
    val cameraControl = remember { mutableStateOf<CameraControl?>(null) }
    val cameraInfo = remember { mutableStateOf<CameraInfo?>(null) }
	
    //렌즈 위치(전면, 후면)
    var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }

	//카메라 선택
    val cameraSelector = remember(lensFacing) {
        CameraSelector.Builder().requireLensFacing(lensFacing).build()
    }
    
    var zoomJob by remember { mutableStateOf<Job?>(null) }

    DisposableEffect(Unit) {
        //카메라 줌 이벤트를 하면 카메라 줌 설정하기
        val scaleGestureDetector = ScaleGestureDetector(context,
            object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
                override fun onScale(detector: ScaleGestureDetector): Boolean {
                    val control = cameraControl.value ?: return false
                    val info = cameraInfo.value ?: return false

                    val scale = detector.scaleFactor
                    currentZoomRatio *= scale

                    val minZoom = info.zoomState.value?.minZoomRatio ?: 1f
                    val maxZoom = info.zoomState.value?.maxZoomRatio ?: 4f

                    val clamped = currentZoomRatio.coerceIn(minZoom, maxZoom)
					control.setZoomRatio(clamped)

                    return true
                }
            })

		//위에 구현한 이벤트를 아래에 등록하기
        previewView.setOnTouchListener { view: View, event ->
            scaleGestureDetector.onTouchEvent(event)
            view.performClick()
        }
		
        //종료 시 이벤트 삭제
        onDispose {
            previewView.setOnTouchListener(null)
        }
    }
	
    //카메라 위치가 바뀔 때마다 이벤트 등록하기
    LaunchedEffect(cameraSelector) {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

        cameraProviderFuture.addListener({
            // Used to bind the lifecycle of cameras to the lifecycle owner
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            // Preview
            val preview = androidx.camera.core.Preview.Builder()
                .build()
                .also {
                    it.surfaceProvider = previewView.surfaceProvider
                }

            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()

                // Bind use cases to camera
                val camera = cameraProvider.bindToLifecycle(
                    lifecycleOwner = lifecycle, cameraSelector, preview, imageCapture
                )

                cameraControl.value = camera.cameraControl
                cameraInfo.value = camera.cameraInfo
            } catch (exc: Exception) {
                Log.e("Chan", "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(context))
    }
	
    //카메라가 나타나는 화면
    Box(modifier = modifier.fillMaxSize()) {
        AndroidView(
            modifier = Modifier
                .fillMaxSize(),
            factory = { previewView },
            onRelease = {
                cameraController.unbind()
            }
        )
		
        //전면, 후면 전환
        IconButton(
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp),
            onClick = {
                lensFacing =
                    if (lensFacing == CameraSelector.LENS_FACING_BACK) CameraSelector.LENS_FACING_FRONT else CameraSelector.LENS_FACING_BACK
            }
        ) {
            Icon(
                modifier = Modifier.size(24.dp),
                imageVector = Icons.Filled.Refresh,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.primary
            )
        }
		
        //클릭 시 사진 저장하기
        Box(
            modifier = Modifier
                .padding(bottom = 36.dp)
                .size(75.dp)
                .clip(CircleShape)
                .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
                .align(Alignment.BottomCenter)
                .clickable(onClick = { takePicture(imageCapture) }),
            contentAlignment = Alignment.Center
        ) {
            Box(
                modifier = Modifier
                    .size(50.dp)
                    .clip(CircleShape)
                    .background(MaterialTheme.colorScheme.primary)
                    .padding(12.dp),
            )
        }
    }
}

실행 화면

코드랩만 보고도 간단하게 사진을 찍는 기능을 구현하고 줌인 아웃기능 또한 사용할 수 있었다.
물론 세세한 기능들도 많이 있고 코드가 하나의 파일만으로 구현되어 있어서 정리가 제대로 되지 않았기 때문에 리팩토링하고 다른 기능들도 사용하면서 cameraX 라이브러리들을 조금씩 익혀봐야겠다.

profile
좋은 개발자가 되기까지

0개의 댓글