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

1. 简介

最后更新时间:2022 年 10 月 27 日

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

过去,应用在其整个生命周期中可能一直会在同一个窗口中运行。

但是,随着新的外形规格(例如可折叠设备)和新的显示模式(例如多窗口模式和多屏幕模式)面市,这种情况已经发生变化。

特别需要注意的是,在针对大屏设备和可折叠设备开发应用时,我们应考虑以下要点:

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

现在,让我们一起来学习如何转换相机输出以适应可调整大小的 Surface,以及如何使用 Android 提供的 API 来处理不同的用例,从而确保相机应用在所有场景中都能正常运行。

构建内容

在本 Codelab 中,我们将构建一个显示相机预览的简单应用。我们将从会锁定屏幕方向且声明自身不可调整大小的简单相机应用入手,并了解该应用在 Android 12L 上的行为方式。

接着,我们将更新源代码,以确保预览在所有场景中都能正常显示。最终目的是让相机应用能够正确处理配置变更,并自动转换 Surface 来匹配预览。

1df0acf495b0a05a.png

学习内容

  • Camera2 预览在 Android Surface 上的显示方式
  • 传感器方向、屏幕旋转和宽高比之间的关系
  • 如何转换 Surface,以匹配相机预览的宽高比和屏幕的旋转情况

所需条件

  • 最新版本的 Android Studio
  • 具备开发 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 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 级别 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>

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

前往第 232 行并调用函数 createCaptureSession()

step1/CameraActivity.kt

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

有一点需要注意:当设备旋转 180 度后,系统不会调用 onSurfaceTextureSizeChanged(因为大小没有改变!),也不会触发 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()
        }
    }
}

现在我们已确定,系统在任何情况下都会重新创建捕获会话。接下来,我们将了解摄像头方向和屏幕旋转之间的隐藏关系。

6. 传感器方向和屏幕旋转

我们所指的“自然屏幕方向”就是用户在使用设备时自然而然选择的屏幕方向。例如,对笔记本电脑而言,自然屏幕方向可能是横向;对手机而言,可能是纵向;对平板电脑而言,则是二者中的任意一个。

在这个定义的基础上,我们还可以定义另外两个概念。

1f9cf3248b95e534.png

我们将摄像头传感器与设备自然屏幕方向之间的角度称为“摄像头方向”。该方向可能取决于摄像头在设备上的实际安装方式,且传感器应始终与屏幕的长边对齐(请参阅 CDD)。

鉴于可能很难定义可折叠设备的长边(因为这类设备可转变其实体形状),因此从 API 级别 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,相机输出会直接在屏幕上显示,无需额外进行旋转。

再次看看以下情况:

4e3a61ea9796a914.png 在第二种(中间)情况下,相机输出的 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() 方法添加以下代码,改用跳接动画来实现更流畅的过渡:

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

7. 恭喜

您学到的内容:

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

深入阅读

参考文档