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

1. 简介

上次更新:2022 年 10 月 27 日

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

从历史上看,您的应用在其整个生命周期中都可能位于同一个窗口中。

但是,随着可折叠设备等新型态设备以及多窗口和多显示器等新型显示模式的出现,您不能再假设这一点。

特别是,让我们看看在开发针对大屏幕和可折叠设备的应用时需要考虑的一些最重要因素。

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

现在是时候确保您的相机应用在每种情况下都能正常工作了,方法是学习如何转换相机输出以适应可调整大小的 Surface,以及如何使用 Android 提供的 API 处理不同的用例。

您将构建什么

在这个代码实验室中,您将构建一个简单的应用来显示相机预览。您将从一个简单的相机应用开始,该应用锁定方向并将其自身声明为不可调整大小,然后您将看到它在 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/android/codelab-android-camera2-preview.git

如果您没有 Git,您可以点击以下按钮下载此代码实验室的所有代码:

打开第一个模块

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

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

302f1fb5070208c7.png

如果系统提示您使用最新的 Gradle 版本,请继续更新。

准备设备

截至此代码实验室的发布日期,只有有限数量的物理设备可以运行 Android 12L。

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

在可能的情况下,请使用物理设备测试相机应用,但如果您想使用模拟器,请确保创建一个具有大屏幕(例如,Pixel C)和 API 级别 32 的模拟器。

准备一个要取景的物体

在使用相机时,我喜欢有一个标准的物体可以指向,以便欣赏设置、方向和缩放的差异。

对于此代码实验室,我将使用此方形图像的打印版本。 66e5d83317364e67.png

如果在任何情况下箭头没有指向顶部或正方形变成了另一个几何图形……则需要进行修复!

3. 运行和观察

将设备置于纵向模式,并在模块 1 上运行代码。确保允许Camera2 代码实验室应用在使用应用时拍摄照片和录制视频。如您所见,预览已正确显示并有效利用了屏幕空间。

现在,将设备旋转到横向。

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。

步骤 1/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并在其中添加以下函数以计算传感器方向和显示旋转之间的相对旋转

步骤 1/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 了

步骤 1/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的默认行为
  • 如何在每种情况下缩放和旋转 surface 以正确显示相机预览!

进一步阅读

参考文档