在相机应用中支持可调整大小的 Surface

1. 简介

上次更新日期: 2022 年 10 月 27 日

为什么需要可调整大小的 Surface?

过去,您的应用在整个生命周期内都可能位于同一窗口中。

但随着折叠屏设备等新外形规格以及多窗口和多显示屏等新显示模式的出现,您不能再认为情况会一直如此。

特别是,让我们来看看开发针对大屏和折叠屏设备的应用时一些最重要的注意事项:

  • 不要假定您的应用将始终显示在纵向窗口中。 Android 12L 仍然支持请求固定方向,但我们现在为设备制造商提供了覆盖应用首选方向请求的选项
  • 不要假定您的应用有固定的尺寸或宽高比。 即使您设置了 resizeableActivity = "false",您的应用仍然可以在大屏幕(>=600dp)上的多窗口模式下使用(适用于 API level 31 及更高级别)。
  • 不要假定屏幕方向与摄像头之间存在固定关系。Android 兼容性定义文档》规定摄像头图像传感器“必须方向正确,使其长轴与屏幕的长轴对齐。” 从 API level 32 开始,在折叠屏设备上查询方向的摄像头客户端可以收到一个值,该值会根据设备/折叠状态动态变化。
  • 不要假定内边距的大小不会改变。 新的任务栏作为内边距报告给应用,与手势导航一起使用时,任务栏可以动态隐藏和显示。
  • 不要假定您的应用对摄像头拥有独占访问权。 您的应用处于多窗口模式时,其他应用可以获得摄像头和麦克风等共享资源的独占访问权。

是时候通过学习如何转换摄像头输出以适应可调整大小的 Surface,以及如何使用 Android 提供的 API 来处理不同用例,从而确保您的相机应用在各种场景下都能正常工作了。

您将构建什么

在本 Codelab 中,您将构建一个简单的应用来显示摄像头预览。您将从一个简单的相机应用开始,该应用锁定方向并声明自身不可调整大小,然后您将了解它在 Android 12L 上的行为。

然后,您将更新源代码,以确保预览在各种场景下都能良好显示。最终结果是一个可以正确处理配置更改并自动转换 Surface 以匹配预览的相机应用。

1df0acf495b0a05a.png

您将学到什么

  • 如何在 Android Surface 上显示 Camera2 预览
  • 传感器方向、显示旋转和宽高比之间的关系
  • 如何转换 Surface 以匹配摄像头预览的宽高比和显示旋转

您需要什么

  • 最新版本的 Android Studio
  • 开发 Android 应用的基础知识
  • Camera2 API 的基础知识
  • 运行 Android 12L 的设备或模拟器

2. 设置

获取起始代码

为了了解在 Android 12L 上的行为,您将从一个锁定方向并声明自身不可调整大小的相机应用开始。

如果您已安装 Git,只需运行以下命令即可。要检查 Git 是否已安装,请在终端或命令行中输入 git --version 并验证其是否正确执行。

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

如果您未安装 Git,可以点击以下按钮下载本 Codelab 的所有代码:

打开第一个模块

在 Android Studio 中,打开 /step1 下的第一个模块。

Android Studio 会提示您设置 SDK 路径。如果遇到任何问题,建议您按照更新 IDE 和 SDK 工具的建议进行操作。

302f1fb5070208c7.png

如果系统要求您使用最新的 Gradle 版本,请进行更新。

准备设备

截至本 Codelab 发布之日,能够运行 Android 12L 的实体设备数量有限。

您可以在此处找到设备列表以及安装 12L 的说明:https://developer.android.com/about/versions/12/12L/get

如果可能,请使用实体设备测试相机应用,但如果您想使用模拟器,请确保创建一个大屏幕(例如 Pixel C)且 API level 为 32 的模拟器。

准备一个要拍摄的物体

使用摄像头时,我喜欢有一个标准的物体,我可以对着它来观察设置、方向和缩放方面的差异。

对于本 Codelab,我将使用这张方形图片的打印版本。 方形图片

如果在任何情况下,箭头没有指向上方或正方形变成了其他几何图形……那么就需要修复一些东西了!

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>

现在您还应该更新 step1/CameraActivity.kt 以便在每次 Surface 大小改变时重新创建 CameraCaptureSession

转到第 232 行并调用函数 createCaptureSession()

step1/CameraActivity.kt

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

这里有一个注意事项:onSurfaceTextureSizeChanged 在 180 度旋转后不会被调用(大小没有改变!)。它也不会触发 onConfigurationChanged,因此我们唯一的选择是实例化一个 DisplayListener 并检查 180 度的旋转。由于设备有四种方向(纵向、横向、反向纵向和反向横向),由整数 0、1、2 和 3 定义,我们需要检查旋转差异是否为 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()
        }
    }
}

现在我们确定无论如何都会重新创建 capture session。是时候了解摄像头方向和显示旋转之间的隐藏关系了。

6. 传感器方向和显示旋转

我们将用户倾向于“自然地”使用设备的方向称为自然方向。例如,笔记本电脑的自然方向很可能是横向,而手机的自然方向是纵向。对于平板电脑,可以是两者中的任何一种。

从这个定义出发,我们可以定义另外两个概念。

1f9cf3248b95e534.png

我们将摄像头传感器与设备的自然方向之间的角度称为摄像头方向。这可能取决于摄像头在设备上的物理安装方式,以及传感器应该始终与屏幕的长边对齐(参见《CDD》)。

考虑到折叠屏设备由于其几何形状可以物理变化,可能难以定义长边,从 API level 32 开始,该字段不再是静态的,而是可以从 CameraCharacteristics 对象中动态检索。

另一个概念是设备旋转,它衡量设备与其自然方向物理旋转的角度。

由于我们通常只需要处理四种不同的方向,我们可以只考虑 90 度的倍数角度,并通过将 Display.getRotation() 返回的值乘以 90 来获取此信息。

默认情况下,TextureView 已经补偿了摄像头方向,但它不处理显示旋转,导致预览画面错误旋转。

这可以通过简单地旋转目标 SurfaceTexture 来解决。让我们更新函数 CameraUtils.buildTargetTexture 以接受 surfaceRotation: Int 参数并将转换应用于 Surface

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

箭头现在指向上方了,但容器仍然不是一个正方形。让我们在最后一步看看如何修复这个问题。

缩放取景器

最后一步是缩放 Surface 以匹配摄像头输出的宽高比。

上一步出现的问题是因为默认情况下 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,摄像头输出直接显示在屏幕上,没有任何额外的旋转。

再次查看以下情况:

图片 在第二个(中间)案例中,摄像头输出的 x 轴显示在屏幕的 y 轴上,反之亦然,这意味着在转换过程中摄像头输出的宽度和高度被颠倒了。在其他情况下,它们保持不变,尽管第三种情况仍然需要旋转。

我们可以用以下公式概括这些情况:

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

有了这些信息,我们现在可以更新函数来缩放 Surface 了:

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)
        }
    }

构建并运行应用,尽情享受闪亮的摄像头预览吧!

额外内容:更改默认动画

如果您想避免旋转时的默认动画(对于相机应用来说可能显得不典型),您可以通过将以下代码添加到 Activity 的 onCreate() 方法中,使用跳切(jumpcut)动画来实现更平滑的过渡。

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

7. 恭喜

您已学到

  • 未优化的应用在 Android 12L 兼容模式下的行为
  • 如何处理配置更改
  • 摄像头方向、显示旋转和设备自然方向等概念的区别
  • TextureView 的默认行为
  • 如何缩放和旋转 Surface 以在各种场景下正确显示摄像头预览!

延伸阅读

参考文档