카메라 앱에서 크기 조절이 가능한 노출 영역 지원

1. 소개

최종 업데이트: 2022년 10월 27일

크기 조절이 가능한 노출 영역을 지원하는 이유

지금까지는 앱이 전체 수명 주기 동안 동일한 창에서 유지되었을 수 있습니다.

그러나 폴더블 기기 같은 새로운 폼 팩터와 멀티 윈도우, 다중 디스플레이 같은 새로운 디스플레이 모드를 사용할 수 있게 되어 이제 앱이 동일한 창에서 유지될 수 없습니다.

특히 큰 화면과 폴더블 기기를 타겟팅하는 앱을 개발할 때 고려할 가장 중요한 사항을 살펴보겠습니다.

  • 앱이 세로 모드 창에서 유지된다고 가정하지 않습니다. 고정된 방향 요청은 Android 12L에서 계속 지원되지만, 이제 기기 제조업체는 선호하는 방향을 위해 앱의 요청을 재정의하는 옵션을 선택할 수 있습니다.
  • 앱의 고정된 크기나 가로세로 비율을 가정하지 않습니다. resizeableActivity = "false"로 설정해도 API 수준 31 이상의 큰 화면(600dp 이상)에서 멀티 윈도우 모드로 앱을 사용할 수 있습니다.
  • 화면 방향과 카메라 간의 고정된 관계를 가정하지 않습니다. Android 호환성 정의 문서에서는 카메라 이미지 센서가 '카메라의 긴 쪽이 화면의 긴 쪽과 정렬되도록 방향을 설정해야 한다(MUST)'고 명시합니다. API 수준 32부터 폴더블 기기에서 방향을 쿼리하는 카메라 클라이언트는 기기/접기 상태에 따라 동적으로 변할 수 있는 값을 수신할 수 있습니다.
  • 인셋 크기가 변할 수 없다고 가정하지 않습니다. 새 작업 표시줄이 애플리케이션에 인셋으로 보고되며, 동작 탐색과 함께 사용하면 작업 표시줄을 동적으로 숨기고 표시할 수 있습니다.
  • 앱이 카메라에 대한 독점적인 액세스 권한을 가지고 있다고 가정하지 않습니다. 앱이 멀티 윈도우 모드인 동안 다른 앱이 카메라, 마이크 같은 공유 리소스에 독점적으로 액세스할 수 있습니다.

모든 시나리오에서 카메라 앱이 제대로 작동하도록 만들기 위해 크기 조절이 가능한 노출 영역에 맞게 카메라 출력을 변환하는 방법과 Android에서 다양한 사용 사례를 처리할 수 있게 제공하는 API를 사용하는 방법을 알아보겠습니다.

빌드할 항목

이 Codelab에서는 카메라 미리보기를 표시하는 간단한 앱을 빌드합니다. 방향을 잠그고 크기 조절 불가능을 선언하는 기본 카메라 앱으로 시작하여 Android 12L에서 어떻게 작동하는지 확인합니다.

그런 다음 모든 시나리오에서 항상 미리보기가 잘 표시되도록 소스 코드를 업데이트합니다. 결과적으로 카메라 앱이 구성 변경을 올바르게 처리하고 미리보기에 맞춰 노출 영역을 자동으로 변환하게 만듭니다.

1df0acf495b0a05a.png

학습할 내용

  • Android 노출 영역에 Camera2 미리보기가 표시되는 방식
  • 센서 방향, 디스플레이 회전, 가로세로 비율 간의 관계
  • 카메라 미리보기의 가로세로 비율과 디스플레이 회전에 맞게 노출 영역을 변환하는 방법

필요한 항목

  • Android 스튜디오 최신 버전
  • Android 애플리케이션 개발에 관한 기본 지식
  • Camera2 API에 관한 기본 지식
  • Android 12L을 실행하는 기기 또는 에뮬레이터

2. 설정

시작 코드 가져오기

Android 12L의 동작을 이해하려면 방향을 잠그고 크기 조절 불가능을 선언하는 카메라 앱으로 시작합니다.

Git을 설치했다면 아래 명령어를 실행하면 됩니다. Git이 설치되어 있는지 확인하려면 터미널이나 명령줄에 git --version을 입력하여 올바르게 실행되는지 확인합니다.

git clone https://github.com/googlecodelabs/android-camera2-preview.git

Git이 없는 경우 다음 버튼을 클릭하여 이 Codelab을 위한 모든 코드를 다운로드할 수 있습니다.

첫 번째 모듈 열기

Android 스튜디오에서 /step1 아래에 있는 첫 번째 모듈을 엽니다.

Android 스튜디오에서 SDK 경로를 설정하라는 메시지를 표시합니다. 문제가 발생하면 IDE 및 SDK 도구 업데이트 권장사항을 따르는 것이 좋습니다.

302f1fb5070208c7.png

최신 Gradle 버전을 사용하라는 메시지가 표시되면 계속 진행하며 업데이트합니다.

기기 준비

이 Codelab의 게시 날짜를 기준으로 Android 12L을 실행할 수 있는 실제 기기는 제한적입니다.

기기 목록 및 12L 설치와 관련한 안내는 https://developer.android.com/about/versions/12/12L/get에서 확인하세요.

가능한 경우 실제 기기를 사용하여 카메라 앱을 테스트하되, 에뮬레이터를 사용하려는 경우에는 큰 화면(예: Pixel C)과 API 수준 32를 사용해야 합니다.

프레임에 맞출 피사체 준비

카메라 앱을 작업할 때 설정, 방향, 크기 조정의 차이를 제대로 알아보기 위해 카메라로 가리킬 수 있는 표준 피사체를 사용하고 싶습니다.

이 Codelab에서는 다음 정사각형 모양 이미지의 인쇄 버전을 사용합니다. 66e5d83317364e67.png

화살표가 상단을 가리키지 않거나 정사각형이 다른 도형이 된다면 . . 무언가 수정해야 합니다.

3. 실행과 관찰

기기를 세로 모드로 놓고 모듈 1에서 코드를 실행합니다. Camera2 Codelab 앱을 사용하는 동안 앱이 사진을 찍고 동영상을 녹화할 수 있게 허용해야 합니다. 확인할 수 있듯이 미리보기가 올바르게 표시되고 화면 공간이 효율적으로 사용됩니다.

이제 기기를 가로 모드로 회전합니다.

46f2d86b060dc15a.png

전혀 좋지 않습니다. 이제 오른쪽 하단에 있는 새로고침 버튼을 클릭합니다.

b8fbd7a793cb6259.png

조금 더 낫지만 아직 최적은 아닙니다.

위의 그림은 Android 12L의 호환성 모드 동작입니다. 세로 모드에서 방향을 잠그는 앱은 기기가 가로 모드로 회전되고 화면 밀도가 600dp보다 높을 때 레터박스 처리될 수 있습니다.

이 모드는 원래의 가로세로 비율을 유지하는 한편, 대부분의 화면 공간이 사용되지 않으므로 최적의 사용자 환경을 제공하지 않습니다.

또한 이 경우에는 미리보기가 90도 잘못 회전됩니다.

이제 기기를 세로 모드로 되돌리고 화면 분할 모드를 시작합니다.

중앙 구분선을 드래그하여 창 크기를 조절할 수 있습니다.

크기 조절이 카메라 미리보기에 미치는 영향을 확인합니다. 왜곡되어 표시되나요? 가로세로 비율이 동일하게 유지되나요?

4. 빠른 해결 방법

호환성 모드는 방향을 잠그고 크기 조절이 불가능한 앱에 관해서만 트리거되므로, 이를 방지하기 위해 매니페스트에서 플래그를 업데이트하는 것이 좋습니다.

다음을 사용하세요.

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

이제 앱을 빌드하고 가로 모드로 다시 실행합니다. 다음과 같이 표시됩니다.

f5753af5a9e44d2f.png

화살표가 상단을 가리키지 않으며 정사각형이 아닙니다.

앱이 멀티 윈도우 모드나 다른 방향으로 작동하도록 설계되지 않았기 때문에 창 크기의 변화를 예상하지 않으며 이에 따라 방금 경험한 문제가 발생합니다.

5. 구성 변경 처리

먼저 구성 변경을 직접 처리하려고 한다고 시스템에 알립니다. step1/AndroidManifest.xml을 열고 다음 줄을 추가합니다.

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

이제 노출 영역 크기가 변경될 때마다 CameraCaptureSession을 다시 만들도록 step1/CameraActivity.kt도 업데이트해야 합니다.

232 줄로 이동하여 createCaptureSession() 함수를 호출합니다.

step1/CameraActivity.kt

override fun onSurfaceTextureSizeChanged(
    surface: SurfaceTexture,
    width: Int,
    height: Int
) {
    createCaptureSession()
}

여기에는 주의할 사항이 있습니다. 즉, onSurfaceTextureSizeChanged는 180도 회전(크기 변경 없음) 후에는 호출되지 않습니다. onConfigurationChanged를 트리거하지도 않으므로 DisplayListener를 인스턴스화하고 180도 회전을 확인하는 옵션만 사용할 수 있습니다. 기기에는 정수 0, 1, 2, 3으로 정의되는 세로, 가로, 세로 반전, 가로 반전의 4개 방향이 있으므로 회전 차이 2가 있는지 확인해야 합니다.

다음 코드를 추가합니다.

step1/CameraActivity.kt

/** DisplayManager to listen to display changes */
private val displayManager: DisplayManager by lazy {
    applicationContext.getSystemService(DISPLAY_SERVICE) as DisplayManager
}

/** Keeps track of display rotations */
private var displayRotation = 0

...

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    displayManager.registerDisplayListener(displayListener, mainLooperHandler)
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    displayManager.unregisterDisplayListener(displayListener)
}

private val displayListener = object : DisplayManager.DisplayListener {
    override fun onDisplayAdded(displayId: Int) {}
    override fun onDisplayRemoved(displayId: Int) {}
    override fun onDisplayChanged(displayId: Int) {
        val difference = displayManager.getDisplay(displayId).rotation - displayRotation
        displayRotation = displayManager.getDisplay(displayId).rotation

        if (difference == 2 || difference == -2) {
            createCaptureSession()
        }
    }
}

이제 어떤 경우든 캡처 세션이 다시 생성됩니다. 이제 카메라 방향과 디스플레이 회전 간의 숨겨진 관계를 알아보겠습니다.

6. 센서 방향 및 디스플레이 회전

Google은 사용자가 기기를 '자연스럽게' 사용하는 경향이 있는 방향을 자연스러운 방향이라고 합니다. 예를 들어 노트북의 경우 가로 모드가, 휴대전화의 경우 세로 모드가 자연스러운 방향일 가능성이 높습니다. 태블릿의 경우는 두 방향 모두 자연스러운 방향일 수 있습니다.

이 정의에서 시작해 다른 두 개념을 정의할 수 있습니다.

1f9cf3248b95e534.png

카메라 센서와 기기의 자연스러운 방향 사이의 각도를 카메라 방향이라고 부릅니다. 카메라 방향은 카메라가 기기에 물리적으로 마운트된 방식에 따라 다를 수 있으며 센서가 항상 화면의 긴 쪽과 정렬되어야 합니다(CDD 참고).

폴더블 기기의 경우 도형이 물리적으로 변환될 수 있으므로 긴 쪽을 정의하기가 어려울 수 있다는 점을 감안하여 API 수준 32부터는 이 필드가 더 이상 정적 필드가 아닙니다. 대신 CameraCharacteristics 객체에서 이 필드를 동적으로 가져올 수 있습니다.

또 다른 개념은 기기 회전으로, 기기가 자연스러운 방향에서 물리적으로 어느 정도 회전하는지 측정합니다.

일반적으로 방향 4개만 처리하려고 하므로 90의 배수인 각도만 고려하여 Display.getRotation()에서 반환된 값에 90을 곱해 이 정보를 가져오면 됩니다.

기본적으로 TextureView는 이미 카메라 방향을 보정하지만 디스플레이 회전을 처리하지는 않으므로 미리보기가 잘못 회전됩니다.

대상 SurfaceTexture를 회전하기만 하면 이 문제를 해결할 수 있습니다. surfaceRotation: Int 매개변수를 허용하고 노출 영역에 변환을 적용하도록 CameraUtils.buildTargetTexture 함수를 업데이트하겠습니다.

step1/CameraUtils.kt

fun buildTargetTexture(
    containerView: TextureView,
    characteristics: CameraCharacteristics,
    surfaceRotation: Int
): SurfaceTexture? {

    val previewSize = findBestPreviewSize(Size(containerView.width, containerView.height), characteristics)

    val surfaceRotationDegrees = surfaceRotation * 90

    val halfWidth = containerView.width / 2f
    val halfHeight = containerView.height / 2f

    val matrix = Matrix()

    // Rotate to compensate display rotation
    matrix.postRotate(
        -surfaceRotationDegrees.toFloat(),
        halfWidth,
        halfHeight
    )

    containerView.setTransform(matrix)

    return containerView.surfaceTexture?.apply {
        setDefaultBufferSize(previewSize.width, previewSize.height)
    }
}

그런 다음 CameraActivity의 138 줄을 다음과 같이 수정하여 호출할 수 있습니다.

step1/CameraActivity.kt

val targetTexture = CameraUtils.buildTargetTexture(
textureView, cameraManager.getCameraCharacteristics(cameraID))

이제 앱을 실행하면 다음과 같은 미리보기가 표시됩니다.

1566c3f9e5089a35.png

이제 화살표가 상단을 가리키지만 컨테이너는 여전히 정사각형이 아닙니다. 마지막 단계에서 해결하는 방법을 살펴보겠습니다.

뷰파인더 크기 조정

마지막 단계는 카메라 출력의 가로세로 비율과 일치하도록 노출 영역을 조정하는 것입니다.

기본적으로 TextureView는 전체 창에 맞게 콘텐츠 크기를 조정하므로 이전 단계의 문제가 발생합니다. 이 창은 가로세로 비율이 카메라 미리보기와 다를 수 있기 때문에 늘어나거나 왜곡될 수 있습니다.

다음 두 단계로 해결할 수 있습니다.

  • 기본적으로 TextureView가 자체에 적용되는 배율을 계산하고 변환을 역으로 처리합니다.
  • 올바른 배율(x축과 y축에서 동일해야 함)을 계산하여 적용합니다.

올바른 배율을 계산하려면 카메라 방향과 디스플레이 회전 간의 차이를 고려해야 합니다. step1/CameraUtils.kt를 열고 다음 함수를 추가하여 센서 방향과 디스플레이 회전 간의 상대적 회전을 계산합니다.

step1/CameraUtils.kt

/**
 * Computes the relative rotation between the sensor orientation and the display rotation.
 */
private fun computeRelativeRotation(
    characteristics: CameraCharacteristics,
    deviceOrientationDegrees: Int
): Int {
    val sensorOrientationDegrees =
        characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0

    // Reverse device orientation for front-facing cameras
    val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) ==
        CameraCharacteristics.LENS_FACING_FRONT
    ) 1 else -1

    return (sensorOrientationDegrees - (deviceOrientationDegrees * sign) + 360) % 360
}

computeRelativeRotation에서 반환된 값을 아는 것이 중요합니다. 원래 미리보기가 크기 조정 전에 회전되었는지 파악하는 데 도움이 되기 때문입니다.

예를 들어 자연스러운 방향의 휴대전화에서 카메라 출력은 가로 모양이고 90도 회전된 후에 화면에 표시됩니다.

반면 자연스러운 방향의 Chromebook에서 카메라 출력은 추가 회전 없이 화면에 바로 표시됩니다.

다음 경우를 다시 살펴보세요.

4e3a61ea9796a914.png 두 번째(가운데) 경우 카메라 출력의 x축이 화면의 y축 위에 표시되고 반대의 경우도 마찬가지입니다. 즉, 변환 도중에 카메라 출력의 너비와 높이가 반전됩니다. 나머지 경우에는 동일하게 유지되지만, 세 번째 시나리오에서는 여전히 회전이 필요합니다.

이러한 경우를 다음과 같은 수식으로 일반화할 수 있습니다.

val isRotationRequired =
        computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

이제 이 정보를 사용하여 함수를 업데이트해 노출 영역을 조정할 수 있습니다.

step1/CameraUtils.kt

fun buildTargetTexture(
        containerView: TextureView,
        characteristics: CameraCharacteristics,
        surfaceRotation: Int
    ): SurfaceTexture? {

        val surfaceRotationDegrees = surfaceRotation * 90
        val windowSize = Size(containerView.width, containerView.height)
        val previewSize = findBestPreviewSize(windowSize, characteristics)
        val sensorOrientation =
            characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
        val isRotationRequired =
            computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

        /* Scale factor required to scale the preview to its original size on the x-axis */
        var scaleX = 1f
        /* Scale factor required to scale the preview to its original size on the y-axis */
        var scaleY = 1f

        if (sensorOrientation == 0) {
            scaleX =
                if (!isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (!isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        } else {
            scaleX =
                if (isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        }

        /* Scale factor required to fit the preview to the TextureView size */
        val finalScale = max(scaleX, scaleY)
        val halfWidth = windowSize.width / 2f
        val halfHeight = windowSize.height / 2f

        val matrix = Matrix()

        if (isRotationRequired) {
            matrix.setScale(
                1 / scaleX * finalScale,
                1 / scaleY * finalScale,
                halfWidth,
                halfHeight
            )
        } else {
            matrix.setScale(
                windowSize.height / windowSize.width.toFloat() / scaleY * finalScale,
                windowSize.width / windowSize.height.toFloat() / scaleX * finalScale,
                halfWidth,
                halfHeight
            )
        }

        // Rotate to compensate display rotation
        matrix.postRotate(
            -surfaceRotationDegrees.toFloat(),
            halfWidth,
            halfHeight
        )

        containerView.setTransform(matrix)

        return containerView.surfaceTexture?.apply {
            setDefaultBufferSize(previewSize.width, previewSize.height)
        }
    }

앱을 빌드하고 실행하여 만족스러운 카메라 미리보기를 즐겨 보세요.

보너스: 기본 애니메이션 변경

회전 시 기본 애니메이션을 사용하지 않으려면(카메라 앱에는 이상하게 보일 수 있음) 다음 코드를 활동 onCreate() 메서드에 추가하여 점프컷 애니메이션으로 변경하면 더 원활하게 전환됩니다.

val windowParams: WindowManager.LayoutParams = window.attributes
windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
window.attributes = windowParams

7. 축하합니다

지금까지 학습한 내용은 다음과 같습니다.

  • Android 12L의 최적화되지 않은 앱이 호환성 모드에서 작동하는 방식
  • 구성 변경을 처리하는 방법
  • 카메라 방향, 디스플레이 회전, 기기의 자연스러운 방향 같은 개념의 차이점
  • TextureView의 기본 동작
  • 모든 시나리오에서 카메라 미리보기를 올바르게 표시하기 위해 노출 영역을 조정하고 회전하는 방법

추가 자료

참조 문서