如果您的 Android 应用使用摄像头,则在处理屏幕方向时需要考虑一些特殊事项。本文档假定您了解 Android camera2 API 的基本概念。您可以阅读我们的博文或摘要,了解 camera2 的概况。我们还建议您先尝试编写相机应用,然后再阅读本文档。
背景
在 Android 相机应用中处理屏幕方向是一项棘手的任务,需要考虑以下因素:
- 自然屏幕方向:设备处于“正常”位置(即设备设计所预期的位置)时的显示屏方向,通常是手机的竖屏方向和笔记本电脑的横屏方向。
- 传感器方向:传感器实际安装在设备上的方向。
- 屏幕旋转:设备以自然屏幕方向为起点被实际旋转的角度。
- 取景器大小:用于显示相机预览的取景器的大小。
- 摄像头输出的图片大小。
这些因素共同为相机应用带来了大量可能的界面和预览配置。本文档旨在演示开发者如何应对这些问题,以及如何在 Android 应用中正确处理相机方向。
为简单起见,除非另有说明,否则假设所有示例都涉及后置摄像头。此外,以下所有照片均为模拟照片,以便更清晰地展示相关内容。
全面了解屏幕方向
自然屏幕方向
自然屏幕方向是指设备处于正常预期位置时的显示屏方向。对于手机,其自然屏幕方向通常是纵向。换句话说,手机的宽度更短,高度更长。对于笔记本电脑,其自然屏幕方向为横向,这意味着其宽度较长,高度较短。平板电脑的情况要稍微复杂一些,它们可以是纵向或横向。
传感器方向
从形式上讲,传感器方向是指传感器输出的图像需要顺时针旋转多少度才能与设备的自然屏幕方向一致。换句话说,传感器方向是指传感器在安装到设备上之前逆时针旋转的度数。在查看屏幕时,旋转似乎是顺时针方向,这是因为后置摄像头传感器安装在设备的“背面”。
根据 Android 10 兼容性定义 7.5.5 摄像头方向,前置摄像头和后置摄像头“必须朝向正确方向,以便摄像头的长度方向与屏幕的长度方向对齐”。
摄像头的输出缓冲区为横向大小。由于手机的自然屏幕方向通常为纵向,因此传感器方向通常与自然屏幕方向相差 90 度或 270 度,以便输出缓冲区的长边与屏幕的长边相匹配。对于自然屏幕方向为横屏的设备(例如 Chromebook),传感器方向有所不同。在这些设备上,图像传感器再次放置为使输出缓冲区的长边与屏幕的长边相匹配。由于这两个都是横向尺寸,因此方向匹配,传感器方向为 0 或 180 度。
以下插图展示了从观察者的角度来看,设备屏幕上的内容是什么样的:
请考虑以下场景:
| 电话 | 笔记本电脑 |
|---|---|
![]() |
![]() |
由于手机上的传感器方向通常为 90 度或 270 度,如果不考虑传感器方向,您获得的图片将如下所示:
| 电话 | 笔记本电脑 |
|---|---|
![]() |
![]() |
假设逆时针传感器方向存储在变量 sensorOrientation 中。为了补偿传感器方向,您需要将输出缓冲区顺时针旋转 `sensorOrientation`,使方向与设备的自然方向重新对齐。
在 Android 中,应用可以使用 TextureView 或 SurfaceView 来显示相机预览。如果应用正确使用这两个函数,它们都可以处理传感器方向。我们将在以下部分中详细说明如何考虑传感器方向。
显示旋转
显示旋转正式定义为屏幕上所绘制图形的旋转,其方向与设备从自然屏幕方向开始的物理旋转方向相反。以下部分假设显示屏旋转角度均为 90 的倍数。如果您按绝对度数检索显示屏旋转角度,请将其向上舍入到最接近的 {0、90、180、270}。
以下部分中的“屏幕方向”是指设备在物理上是横向放置还是纵向放置,与“屏幕旋转”不同。
假设您将设备从之前的位置逆时针旋转 90 度,如下图所示:
假设输出缓冲区已根据传感器方向旋转,则您将获得以下输出缓冲区:
| 电话 | 笔记本电脑 |
|---|---|
![]() |
![]() |
如果显示旋转角度存储在变量 displayRotation 中,为了获得正确的图像,您应该按 displayRotation 逆时针旋转输出缓冲区。
对于前置摄像头,显示旋转会以与屏幕相反的方向作用于图像缓冲区。如果您使用的是前置摄像头,则应按 displayRotation 顺时针旋转缓冲区。
注意事项
显示旋转用于衡量设备的逆时针旋转。但并非所有方向/旋转 API 都是如此。
例如,
-
如果您使用
Display#getRotation(),则会获得本文档中提到的逆时针旋转效果。 - 如果您使用 OrientationEventListener#onOrientationChanged(int),则会获得顺时针旋转角度。
这里需要注意的重要一点是,显示屏旋转是相对于自然屏幕方向而言的。例如,如果您将手机实际旋转 90 度或 270 度,屏幕就会变为横向。相比之下,如果您将笔记本电脑旋转相同的角度,则会获得竖屏。应用应始终牢记这一点,切勿对设备的自然屏幕方向做出假设。
示例
我们使用上图来说明什么是方向和旋转。
| 电话 | 笔记本电脑 |
|---|---|
| 自然屏幕方向 = 纵向 | 自然屏幕方向 = 横向 |
| 传感器方向 = 90 | 传感器方向 = 0 |
| 屏幕旋转角度 = 0 | 屏幕旋转角度 = 0 |
| 显示屏方向 = 纵向 | 显示屏方向 = 横向 |
| 电话 | 笔记本电脑 |
|---|---|
| 自然屏幕方向 = 纵向 | 自然屏幕方向 = 横向 |
| 传感器方向 = 90 | 传感器方向 = 0 |
| 显示旋转角度 = 90 | 显示旋转角度 = 90 |
| 显示屏方向 = 横向 | 显示屏方向 = 纵向 |
取景器尺寸
应用应始终根据屏幕方向、旋转角度和屏幕分辨率调整取景器的大小。一般来说,应用应使取景器的方向与当前显示方向保持一致。换句话说,应用应将取景器的长边与屏幕的长边对齐。
各相机的图片输出大小
在为预览选择图片输出大小时,应尽可能选择与取景器大小相同或略大于取景器大小的尺寸。您通常不希望放大输出缓冲区,因为这会导致像素化。您也不希望选择过大的尺寸,这可能会降低性能并消耗更多电池电量。
JPEG 方向
我们先从一种常见情况入手,即拍摄 JPEG 照片。在 camera2 API 中,您可以在拍摄请求中传递 JPEG_ORIENTATION,以指定希望输出的 JPEG 顺时针旋转多少度。
下面简要回顾一下我们提到的内容:
-
为了处理传感器方向,您需要将图像缓冲区顺时针旋转
sensorOrientation。 -
为了处理显示屏旋转,您需要将缓冲区逆时针旋转
displayRotation(对于后置摄像头),顺时针旋转displayRotation(对于前置摄像头)。
将这两个因素相加,您要顺时针旋转的量为
-
sensorOrientation - displayRotation用于后置摄像头。 -
sensorOrientation + displayRotation用于前置摄像头。
您可以在 JPEG_ORIENTATION 文档中查看此逻辑的示例代码。请注意,文档示例代码中的 deviceOrientation 使用的是设备的顺时针旋转。因此,显示旋转的正负号会反转。
预览
相机预览呢?应用可以通过两种主要方式显示相机预览:SurfaceView 和 TextureView。每种情况都需要采用不同的方法来正确处理屏幕方向。
SurfaceView
如果您不需要处理或动画化预览缓冲区,一般建议使用 SurfaceView 进行相机预览。它比 TextureView 性能更高,对资源的要求更低。
SurfaceView 的布局也相对简单。您只需考虑显示相机预览的 SurfaceView 的宽高比。
信息来源
在 SurfaceView 下,Android 平台会旋转输出缓冲区,以匹配设备的显示方向。换句话说,它同时考虑了传感器方向和屏幕旋转。简单来说,当显示屏处于横屏模式时,我们会获得横屏预览;当显示屏处于竖屏模式时,我们会获得竖屏预览。
下表对此进行了说明。请务必注意,显示旋转本身并不能决定来源的方向。
| 显示旋转 | 手机(自然屏幕方向 = 纵向) | 笔记本电脑(自然屏幕方向 = 横向) |
|---|---|---|
| 0 | ![]() |
![]() |
| 90 | ![]() |
![]() |
| 180 | ![]() |
![]() |
| 270 | ![]() |
![]() |
布局
如您所见,SurfaceView 已经为我们处理了一些棘手的问题。但现在,您需要考虑取景器的大小,或者您希望预览在屏幕上显示多大。SurfaceView 会自动缩放源缓冲区以适应其尺寸。您需要确保取景器的宽高比与 sourcebuffer 的宽高比相同。例如,如果您尝试将竖屏预览画面放入横屏 SurfaceView 中,则会得到类似如下的扭曲效果:
您通常希望取景器的宽高比(即宽度/高度)与来源的宽高比相同。如果您不想剪裁取景器中的图像(即剪掉一些像素来修正显示效果),则需要考虑两种情况:aspectRatioActivity 大于 aspectRatioSource 和 aspectRatioActivity 小于或等于 aspectRatioSource
aspectRatioActivity > aspectRatioSource
您可以将这种情况视为活动“更宽”。下面我们来看一个示例,其中 activity 的宽高比为 16:9,而视频源的宽高比为 4:3。
aspectRatioActivity = 16/9 ≈ 1.78 aspectRatioSource = 4/3 ≈ 1.33
首先,您希望取景器也采用 4:3 的比例。然后,您需要将来源和取景器放入 activity 中,如下所示:
在这种情况下,您应使取景器的高度与 activity 的高度一致,同时使取景器的宽高比与来源的宽高比相同。伪代码如下:
viewfinderHeight = activityHeight; viewfinderWidth = activityHeight * aspectRatioSource;
aspectRatioActivity ≤ aspectRatioSource
另一种情况是 activity“较窄”或“较高”。我们可以沿用之前的示例,但以下示例中,您将设备旋转了 90 度,使 activity 的宽高比为 9:16,来源的宽高比为 3:4。
aspectRatioActivity = 9/16 = 0.5625 aspectRatioSource = 3/4 = 0.75
在这种情况下,您需要将来源和取景器调整到适合 Activity 的大小,如下所示:
您应使取景器的宽度与 activity 的宽度相匹配(而不是像之前那样与高度相匹配),同时使取景器的宽高比与来源的宽高比相同。伪代码:
viewfinderWidth = activityWidth; viewfinderHeight = activityWidth / aspectRatioSource;
剪裁
Camera2 示例中的 AutoFitSurfaceView.kt (github) 会替换 SurfaceView,并使用在两个维度上都等于或“略大于”activity 的图片来处理宽高比不匹配的问题,然后剪裁溢出的内容。对于希望预览覆盖整个 activity 或完全填充固定尺寸的视图而不扭曲图像的应用,此属性非常有用。
警告
上述示例尝试通过使预览略大于 activity 来最大限度地利用屏幕空间,从而不留任何未填充的空间。这依赖于以下事实:默认情况下,溢出部分会被父布局(或 ViewGroup)裁剪。此行为与 RelativeLayout 和 LinearLayout 一致,但与 ConstraintLayout 不一致。ConstraintLayout 可能会调整子视图的大小,使其适合布局,这会破坏预期的“居中裁剪”效果,并导致预览拉伸。您可以参考此提交。
TextureView
TextureView 可最大限度地控制相机预览的内容,但会带来性能成本。此外,还需要更多工作才能使相机预览显示得恰到好处。
信息来源
在 TextureView 下,Android 平台会根据传感器方向旋转输出缓冲区,以匹配设备的自然屏幕方向。虽然 TextureView 可以处理传感器方向,但无法处理屏幕旋转。它使输出缓冲区与设备的自然屏幕方向保持一致,这意味着您需要自行处理显示旋转。
下表对此进行了说明。尝试按相应的显示旋转度旋转图形,您实际上会在 SurfaceView 中获得相同的图形。
| 显示旋转 | 手机(自然屏幕方向 = 纵向) | 笔记本电脑(自然屏幕方向 = 横向) |
|---|---|---|
| 0 | ![]() |
![]() |
| 90 | ![]() |
![]() |
| 180 | ![]() |
![]() |
| 270 | ![]() |
![]() |
布局
对于 TextureView,布局有点棘手。之前有人建议使用 TextureView 的转换矩阵,但该方法并不适用于所有设备。建议您改为按照此处所述的步骤操作。
在 TextureView 上正确布局预览的 3 步流程:
- 将 TextureView 的大小设置为与所选的预览大小相同。
- 将可能拉伸的 TextureView 缩放回预览的原始尺寸。
-
将 TextureView 逆时针旋转
displayRotation。
假设您有一部显示屏旋转角度为 90 度的手机。
1. 将 TextureView 的大小设置为与所选预览大小相同
假设您选择的预览尺寸为 previewWidth × previewHeight,其中 previewWidth > previewHeight(传感器输出自然为横向)。配置拍摄会话时,应调用 SurfaceTexture#setDefaultBufferSize(int width, height) 来指定预览大小 (previewWidth × previewHeight)。
在调用 setDefaultBufferSize 之前,请务必将 TextureView 的大小也设置为 `previewWidth × previewHeight`,并使用 View#setLayoutParams(android.view.ViewGroup.LayoutParams)。这是因为 TextureView 会使用其测量宽度和高度调用 SurfaceTexture#setDefaultBufferSize(int width, height)。如果未预先明确设置 TextureView 的大小,可能会导致竞态条件。通过先明确设置 TextureView 的大小,可以缓解此问题。
现在,TextureView 可能与来源的尺寸不匹配。对于手机,来源是竖屏形状,但由于您刚刚设置的 layoutParams,TextureView 是横屏形状。这会导致预览画面拉伸,如下图所示:
2. 将可能拉伸的 TextureView 缩放回预览的原始尺寸
请考虑以下事项,以便将拉伸的预览恢复为源的尺寸。
来源的维度 (sourceWidth × sourceHeight) 为:
-
previewHeight × previewWidth,如果自然屏幕方向为纵向或反向纵向(传感器方向为 90 度或 270 度) -
previewWidth × previewHeight,如果自然方向为横向或反向横向(传感器方向为 0 或 180 度)
通过利用 View#setScaleX(float) 和 View#setScaleY(float) 修复了拉伸问题
-
setScaleX(
sourceWidth / previewWidth) -
setScaleY(
sourceHeight / previewHeight)
3. 将预览逆时针旋转 `displayRotation`
如前所述,您应将预览逆时针旋转 displayRotation,以补偿显示旋转。
您可以通过 View#setRotation(float) 执行此操作
-
setRotation(
-displayRotation),因为它会进行顺时针旋转。
示例
-
Jetpack 中 camerax 的
PreviewView会处理 TextureView 布局,如前所述。它使用 PreviewCorrector 配置转换。
注意:如果您之前在代码中为 TextureView 使用过转换矩阵,那么在 Chromebook 等自然横屏设备上,预览效果可能不正确。可能是您的转换矩阵错误地假设传感器方向为 90 度或 270 度。您可以参考 GitHub 上的此提交来了解解决方法,但我们强烈建议您迁移应用,改为使用此处所述的方法。





















