1. 简介
上次更新:2022 年 10 月 27 日
为什么需要可调整大小的 Surface?
从历史上看,您的应用在其整个生命周期中都可能位于同一个窗口中。
但是,随着可折叠设备等新型态设备以及多窗口和多显示器等新型显示模式的出现,您不能再假设这一点。
特别是,让我们看看在开发针对大屏幕和可折叠设备的应用时,一些最重要的注意事项。
- 不要假设您的应用将位于纵向形状的窗口中。Android 12L 仍然支持请求固定方向,但我们现在正在为设备制造商提供覆盖应用首选方向请求的选项。
- 不要假设应用的任何固定尺寸或纵横比。即使您设置了
resizeableActivity = "false"
,您的应用也可以在大屏幕(>=600dp)上以多窗口模式使用API 级别 31 及更高版本。 - 不要假设屏幕方向和摄像头之间存在固定的关系。Android 兼容性定义文档规定,摄像头图像传感器“必须面向摄像头长边与屏幕长边对齐的方向”。从 API 级别 32 开始,在可折叠设备上查询方向的摄像头客户端可能会收到一个值,该值会根据设备/折叠状态动态更改。
- 不要假设内嵌的大小不会改变。新的任务栏向应用报告为内嵌,并且当与手势导航一起使用时,任务栏可以动态隐藏和显示。
- 不要假设您的应用对摄像头拥有独占访问权限。当您的应用处于多窗口模式时,其他应用可以获得对共享资源(如摄像头和麦克风)的独占访问权限。
现在是时候确保您的相机应用在每种情况下都能正常工作了,方法是学习如何转换摄像头输出以适应可调整大小的 Surface,以及如何使用 Android 提供的 API 处理不同的用例。
您将构建什么
在此 Codelab 中,您将构建一个简单的应用,该应用显示摄像头预览。您将从一个简单的相机应用开始,该应用锁定方向并将其自身声明为不可调整大小,然后您将看到它在 Android 12L 上的行为。
然后,您将更新源代码以确保预览在每种情况下都能正确显示。结果是一个相机应用,它可以正确处理配置更改并自动转换 Surface 以匹配预览。
您将学到什么
- 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,可以点击以下按钮下载此 Codelab 的所有代码:
打开第一个模块
在 Android Studio 中,打开位于 /step1 下的第一个模块。
Android Studio 将提示您设置 SDK 路径。如果您遇到任何问题,可能需要按照有关更新 IDE 和 SDK 工具的建议操作。
如果系统要求您使用最新版本的 Gradle,请继续更新。
准备设备
截至此 Codelab 发布之日,只有有限的物理设备可以运行 Android 12L。
您可以在此处找到设备列表以及安装 12L 的说明:https://developer.android.com/about/versions/12/12L/get
在可能的情况下,请使用物理设备测试相机应用,但如果您想使用模拟器,请确保创建一个具有大屏幕(例如,Pixel C)且 API 级别为 32 的模拟器。
准备要取景的主题
在使用摄像头时,我喜欢有一个标准主题可以指向,以便欣赏设置、方向和缩放方面的差异。
对于此 Codelab,我将使用此方形图像的打印版本。
如果在任何情况下箭头没有指向顶部或正方形变成了另一个几何图形……则需要修复某些内容!
3. 运行和观察
将设备置于纵向模式,并在模块 1 上运行代码。请确保允许Camera2 Codelab应用在使用应用时拍摄照片和录制视频。如您所见,预览已正确显示并有效利用了屏幕空间。
现在,将设备旋转到横向。
这绝对不好。现在,点击右下角的刷新按钮。
它应该会好一点,但仍然不是最佳状态。
您看到的是 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>
现在构建应用并再次以横向方向运行它。您应该会看到如下内容。
箭头没有指向顶部,这不是一个正方形!
由于该应用并非设计为在多窗口模式或不同方向下工作,因此它不希望窗口大小发生任何变化,从而导致您刚刚遇到的问题。
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()
}
}
}
现在,我们确信在任何情况下都会重新创建捕获会话。现在是时候了解摄像头方向和显示屏旋转之间的隐藏关系了。
6. 传感器方向和显示屏旋转
我们将自然方向称为用户倾向于“自然”使用设备的方向。例如,对于笔记本电脑,自然方向可能是横向,对于手机,自然方向可能是纵向。对于平板电脑,这两种方向都可以。
从这个定义开始,我们可以定义另外两个概念。
我们将摄像头方向称为摄像头传感器与设备自然方向之间的角度。这可能取决于摄像头在设备上的物理安装方式,以及传感器应该始终与屏幕长边对齐(请参阅CDD)。
考虑到对于可折叠设备可能难以定义长边(因为它可以物理地改变其几何形状),从 API 级别 32 开始,此字段不再是静态的,而是可以从CameraCharacteristics
对象中动态检索。
另一个概念是设备旋转,它衡量设备相对于其自然方向的物理旋转程度。
由于我们通常只需要处理四种不同的方向,因此我们可以只考虑 90 的倍数的角度,并通过将 Display.getRotation()
返回的值乘以 90 来获取此信息。
默认情况下,TextureView
已经补偿了相机方向,但它没有处理显示旋转,导致预览旋转错误。
可以通过简单地旋转目标 SurfaceTexture
来解决此问题。让我们更新函数 CameraUtils.buildTargetTexture
以接受 surfaceRotation: Int
参数并将转换应用于表面
步骤 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))
现在运行应用程序将得到如下所示的预览
箭头现在指向顶部,但容器仍然不是正方形。让我们看看如何在最后一步解决这个问题。
缩放取景器
最后一步是缩放表面以匹配相机输出的纵横比。
上一步的问题出现是因为默认情况下 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,相机输出直接显示在屏幕上,无需任何额外的旋转。
再次查看以下情况
在第二种(中间)情况下,相机输出的 x 轴显示在屏幕的 y 轴上,反之亦然,这意味着相机输出的宽度和高度在转换过程中被反转。在其他情况下,它们保持不变,尽管在第三种情况下仍然需要旋转。
我们可以用以下公式概括这些情况
val isRotationRequired =
computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0
有了这些信息,我们现在可以更新函数以缩放表面
步骤 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
的默认行为- 如何在每种情况下缩放和旋转表面以正确显示相机预览!