0%

[原创] 详解 Android 屏幕方向

概述

关于获取 Android 手机方向,有很多种方法,这原来是一个很简单的问题。但是最近在做“支持 Android 11+ 自定义 Toast 组件”时,同样遇到了这个问题。仔细研究时,发现这个问题其实还是值得讲一下的。

网上虽然已经提供了很多种方法来获取手机方向,但是讲解的都比较片面,并没有说明需要在什么情况下才能使用相关的方法。此外如果手机处于锁定屏幕方向的情况下,应该如何来获取手机方向等问题也没有说清楚。

还有一个问题,就是在 Android 11 及以上系统,应用处于后台时,应该如何正确获取手机方向,也有必要说明下。

所以我觉得这里有必要详细的来讲解一下这个问题。尤其是在做相机开发及自定义组件开发时,这个问题需要特别留意。

老习惯,考虑到大忙人较多,这里先说结论:

为了能够始终获取到正确的屏幕方向(即设备方向),建议使用 OrientationEventListener 方案或 “反射系统 Hidden API” 的方案。

项目完整代码,详见 我的 Github

送走了大忙人,接下来请客官仔细阅读正文。

必要术语

在讲解之前,我们非常有必要先约定一下相关的术语。

  • 纵向屏幕方向 (Display orientation)

    我更愿意称之为 “设备方向”。

    此术语指设备哪一侧朝上,可为以下四个值之一:纵向(自然方向)、横向(设备逆时针旋转90度)、反向纵向或反向横向。

该术语是我们最熟知的,也就是我们常说的怎么拿着手机,指的就是设备方向。

下面这个示例是在手机纵向为自然方向,各个屏幕方向的示例:

Natrual Portrait-Portrait Natrual Portrait-Landscape Natrual Portrait-Reverse-Portrait Natrual Portrait-Reverse-Landscape
纵向(自然方向) 横向(设备逆时针旋转90度) 反向纵向(设备旋转180˚) 反向横向(设备顺时针旋转90˚)

注意:与之类似的还有 “横向屏幕方向”,某些 Pad 就是这种情况,其自然方向是横向的而不是纵向的。本文不做讲解,大家可以自行脑补下。

  • 屏幕旋转角度 (Display rotation)

指的是 Display.getRotation() 返回的值,表示设备从其自然屏幕方向逆时针旋转的角度值。

该术语也是我们平时开发时,接触最多的。

  • 目标旋转角度 (Target rotation)

此术语表示顺时针旋转设备使其达到自然屏幕方向需要旋转的度数。

相机开发时,会用到该术语。

示例

这里以手机纵向为自然方向为例,说明下以上术语的具体意义。

Natrual Portrait-Portrait Natrual Portrait-Landscape Natrual Portrait-Reverse-Portrait Natrual Portrait-Reverse-Landscape
屏幕方向 纵向(自然方向) 横向(设备逆时针旋转90˚) 反向纵向(设备旋转180˚) 反向横向(设备顺时针旋转90˚)
屏幕旋转角度 0˚ (ROTATION_0) 90˚ (ROTATION_90) 180˚ (ROTATION_180) 270˚ (ROTATION_270)
目标旋转角度 90˚ 180˚ 270˚

实战示例

了解了上述术语后,让我们先看一下示例,了解下具体运行结果。

测试用手机信息:

  • 品牌:SONY Xperia 1 III (SO-51B)
  • Android 版本:11
  • 该示例同时在以下手机做过测试:
    • Google Pixel 4 XL Android 13
    • Google Pixel 5a Android 12
    • Realme GT Neo2 (RMX3370) Android 12
    • Samsung Galaxy S9+ (SM-G965N) Android 10

说明:在开发 App 时,从用户体验上来说,通常不存在 “反向纵向” 的屏幕方向,即倒拿着手机。因此下面的示例不包括这种情况。

  • 屏幕方向锁:纵向锁定
orientation_portrait_lock_portrait orientation_portrait_lock_landscape orientation_portrait_lock_reverse_landscape
纵向(自然方向) 横向(设备逆时针旋转90˚) 反向横向(设备顺时针旋转90˚)
  • 屏幕方向锁:自动旋转
orientation_portrait_unlock_portrait orientation_portrait_unlock_landscape orientation_portrait_unlock_reverse_landscape
纵向(自然方向) 横向(设备逆时针旋转90˚) 反向横向(设备顺时针旋转90˚)
  • 应用处于后台,并且屏幕方向发生变化
background_portrait background_landscape background_reverse_landscape
纵向(自然方向) 横向(设备逆时针旋转90˚) 反向横向(设备顺时针旋转90˚)

代码实现

接下来让我们先看一下上面示例的源码。(其中用到的扩展及其它工具类,详见 我的 Github 完整工程)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.leovp.demo.basic_components.examples.orientation

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.IRotationWatcher
import android.view.OrientationEventListener
import com.leovp.demo.base.BaseDemonstrationActivity
import com.leovp.demo.databinding.ActivityOrientationBinding
import com.leovp.lib_common_android.exts.*
import com.leovp.lib_reflection.wrappers.ServiceManager
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ITAG

class OrientationActivity : BaseDemonstrationActivity<ActivityOrientationBinding>() {
override fun getTagName(): String = ITAG

override fun getViewBinding(savedInstanceState: Bundle?): ActivityOrientationBinding {
return ActivityOrientationBinding.inflate(layoutInflater)
}

private var currentDeviceOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
private var deviceOrientationEventListener: DeviceOrientationListener? = null

private val rotationWatcher = object : IRotationWatcher.Stub() {
override fun onRotationChanged(rotation: Int) {
toast("${rotation.surfaceRotationName}[$rotation]")
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

deviceOrientationEventListener = DeviceOrientationListener(this)
deviceOrientationEventListener?.enable()

ServiceManager.windowManager?.registerRotationWatcher(rotationWatcher)
startService(Intent(this, OrientationService::class.java))
}

override fun onDestroy() {
ServiceManager.windowManager?.removeRotationWatcher(rotationWatcher)
deviceOrientationEventListener?.disable()
super.onDestroy()
}

/** @return
* [Configuration.ORIENTATION_PORTRAIT],
* [Configuration.ORIENTATION_LANDSCAPE]
*
* constants based on the current phone screen pixel relations.
*/
private fun getScreenOrientation(): Int {
val dm: DisplayMetrics = resources.displayMetrics // Screen rotation effected
// LogContext.log.w(ITAG, "dm size: ${dm.widthPixels}x${dm.heightPixels}")
return if (dm.widthPixels > dm.heightPixels)
Configuration.ORIENTATION_LANDSCAPE
else Configuration.ORIENTATION_PORTRAIT
}

inner class DeviceOrientationListener(private val ctx: Context) : OrientationEventListener(ctx) {
@SuppressLint("SetTextI18n")
override fun onOrientationChanged(degree: Int) {
// val confOrientation = resources.configuration.orientation
// LogContext.log.d("orientation=$orientation confOrientation=$confOrientation screenWidth=${ctx.screenWidth}")
binding.tvOrientationDegree.text = degree.toString()
binding.tvScreenWidth.text = ctx.screenWidth.toString()
if (degree == ORIENTATION_UNKNOWN) {
LogContext.log.w("ORIENTATION_UNKNOWN")
binding.tvDeviceOrientation.text = "ORIENTATION_UNKNOWN"
return
}

binding.tvSurfaceRotation.text =
"${screenSurfaceRotation.surfaceRotationLiteralName}($screenSurfaceRotation) " +
screenSurfaceRotation.surfaceRotationName

currentDeviceOrientation = getDeviceOrientation(degree, currentDeviceOrientation)
val screenPortraitOrLandscape = getScreenOrientation()
val screenPortraitOrLandscapeName =
if (Configuration.ORIENTATION_PORTRAIT == screenPortraitOrLandscape) {
"Portrait"
} else {
"Landscape"
}
LogContext.log.w("Device Orientation=${currentDeviceOrientation.screenOrientationName} " +
"screenSurfaceRotation=${screenSurfaceRotation.surfaceRotationName} " +
"screenPortraitOrLandscape=$screenPortraitOrLandscapeName")
binding.tvDeviceOrientation.text = currentDeviceOrientation.screenOrientationName
}
}
}

获取屏幕方向详解

首先先说明一个重要的情况,在测试时我发现,在 Android 11 或以上系统,如果应用处于后台时,常规方法是无法获取到屏幕方向的,但是在官方文档中我并没有找到相关的说法,如果有人知道的话,欢迎留言告诉一下。

后文会讲解当应用处于后台时,获取屏幕方向的方法。

自动旋转屏幕开启,应用处于前台,获取屏幕方向(即设备方向)的方法

这种情况下,获取屏幕方向(即设备方向)的方法非常简单,一句话就可以:

1
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) activity.display!!.rotation else windowManager.defaultDisplay.rotation

上述方法会返回以下四个方向之一:

1
2
3
4
Surface.ROTATION_0   (纵向(自然方向),没有旋转)
Surface.ROTATION_90 (横向(设备逆时针旋转90度))
Surface.ROTATION_180 (反向纵向(设备旋转180˚))
Surface.ROTATION_270 (反向横向(设备顺时针旋转90˚))

注意:如果是在 Service 中,需要将 activity.display!!.rotation 替换成 displayManager.getDisplay(Display.DEFAULT_DISPLAY).rotation

现在说一下该方法的优缺点:

优点 缺点
可以准确获取当前屏幕(即设备方向)的方向(四个方向之一) 1. 若屏幕内容不会发生旋转时(例如,自动旋转屏幕处于关闭状态),则无法获得屏幕的方向。
2. 应用处于后台时,Android 11 及之后版本,无法获得屏幕方向。

补充说明

注意:如果屏幕内容没有发生旋转,该方法就无法获取当前屏幕的最新方向(即设备方向)。换句话说就是,只有当屏幕内容发生旋转后,该方法才能获取当前设备的最新方向(即设备方向)。

屏幕内容允许旋转的先决条件

只有满足以下任一条件,当设备旋转后,屏幕内容才允许被旋转:

  • Activity 被设置成允许旋转,即 android:screenOrientation 属性被设置成允许旋转,并且开启了自动旋转屏幕

  • Activity 被设置成允许旋转,即 android:screenOrientation 属性被设置成允许旋转,但未开启自动旋转屏幕。当设备旋转后,手动点击了旋转屏幕悬浮按钮。如下图:

    Rotate Float Button

PS:关于 android:screenOrientation 属性,详见后文“附录”。

只有满足了以上任一条件,当设备旋转后,才能获取到最新的屏幕方向(即设备方向)。

为了方便使用,我写了一个扩展方法用于获取该情况下的屏幕方向(即设备方向):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* The context can only be either Activity(Fragment) or Service.
*
* @return Return the screen rotation(**NOT** device rotation).
* The result is one of the following value:
*
* - Surface.ROTATION_0 (no rotation)
* - Surface.ROTATION_90
* - Surface.ROTATION_180
* - Surface.ROTATION_270
*/
val Context.screenSurfaceRotation: Int
@Suppress("DEPRECATION")
get() {
if (this !is Activity && this !is Service) fail("Context can be either Activity(Fragment) or Service.")
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (this is Service) {
// On Android 11+, we can't get `display` directly from Service, it will cause
// the following exception:
// Tried to obtain display from a Context not associated with one.
// Only visual Contexts (such as Activity or one created with Context#createWindowContext)
// or ones created with Context#createDisplayContext are associated with displays.
// Other types of Contexts are typically related to background entities
// and may return an arbitrary display.
//
// So we need to get screen rotation from `DisplayManager`.
displayManager.getDisplay(Display.DEFAULT_DISPLAY).rotation
} else {
display!!.rotation
}
} else windowManager.defaultDisplay.rotation
}

其中用到的其它扩展方法:

1
2
3
4
5
6
val Context.windowManager get() = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val Context.displayManager get() = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager

fun fail(message: String): Nothing {
throw IllegalArgumentException(message)
}

自动旋转屏幕关闭,应用处于前台,获取设备方向的方法

注意:使用此方法获得的是设备当前所处于的方向,而不是屏幕内容的方向。例如,若手机处于横向看视频时,视频内容可能是处于纵向(自然方向),但是手机的方向却是横向的。

使用 OrientationEventListener 可以准确获取到当前设备的方向(四个方向之一)。

再来看一下该方法的优缺点:

优点 缺点
无视自动旋转屏幕状态,任何情况均可准确获取当前设备的方向。

使用方法,在 ActivityService 中使用均可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private var deviceOrientationEventListener: DeviceOrientationListener? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

deviceOrientationEventListener = DeviceOrientationListener(this)
// Enable orientation listener as you need.
// For example in onCreate() method or onResume().
deviceOrientationEventListener?.enable()

// Add other codes here.
}

override fun onDestroy() {
// Disable orientation listener when you don't need it anymore.
// For example in onDestroy() method or onPause().
deviceOrientationEventListener?.disable()
super.onDestroy()
}

inner class DeviceOrientationListener(private val ctx: Context) : OrientationEventListener(ctx) {
@SuppressLint("SetTextI18n")
override fun onOrientationChanged(degree: Int) {
if (degree == ORIENTATION_UNKNOWN) {
LogContext.log.w("ORIENTATION_UNKNOWN")
return
}
// Use parameter degree to determine the device orientation.
}
}

onOrientationChanged(degree: Int) 接口的参数返回的是当前设备相对于纵向(自然方向)所偏离的角度,值的范围是 [0, 359]。我们可以根据该角度来判断当前设备的方向。 我的 Github 完整代码 中有具体的判断方法。

补充说明

经测试发现,在开启自动旋转屏幕时,系统在设备旋转超过 60˚ 时,才会改变屏幕方向。这一点和系统相机判断设备是否处于横屏拍摄时的条件一致。

Left Rotate 60 Degrees Right Rotate 60 Degrees

Service 中获取屏幕方向

方法一

Service 中,可以通过覆写 onConfigurationChanged(newConfig: Configuration) 方法来获取屏幕方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.leovp.demo.basic_components.examples.orientation

import android.content.Intent
import android.content.res.Configuration
import android.os.IBinder
import com.leovp.androidbase.framework.BaseService
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ITAG

class OrientationService : BaseService() {

private var lastOrientation = -1

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)

if (newConfig.orientation != lastOrientation) {
// Checks the orientation of the screen
when (newConfig.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
LogContext.log.w(ITAG, "Device is in Landscape mode.")
}
Configuration.ORIENTATION_PORTRAIT -> {
LogContext.log.w(ITAG, "Device is in Portrait mode.")
}
}
lastOrientation = newConfig.orientation
}
}

override fun onBind(intent: Intent): IBinder? = null
}

再来看一下该方法的优缺点:

优点 缺点
1. 直接覆写系统方法,简单便捷。
2. 应用处于后台时,若屏幕内容发生旋转,可以获得屏幕方向。
1. 只能知道设备是否处于横屏或纵屏两种状态,无法获知具体的四个方向。
2. 若屏幕内容不会发生旋转时(例如,自动旋转屏幕处于关闭状态),则无法获得屏幕的方向。

方法二

Service 中,使用前文中提到的 Context.screenSurfaceRotation 扩展或 OrientationEventListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.leovp.demo.basic_components.examples.orientation

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.view.OrientationEventListener
import com.leovp.androidbase.framework.BaseService
import com.leovp.lib_common_android.exts.screenSurfaceRotation
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ITAG

class OrientationService : BaseService() {

private var deviceOrientationEventListener: ServiceOrientationListener? = null

override fun onCreate() {
LogContext.log.i(ITAG, "=====> onCreate <=====")
super.onCreate()
deviceOrientationEventListener = ServiceOrientationListener(this)
deviceOrientationEventListener?.enable()
}

override fun onDestroy() {
LogContext.log.i(ITAG, "=====> onDestroy <=====")
deviceOrientationEventListener?.disable()
super.onDestroy()
}

override fun onBind(intent: Intent): IBinder? = null

inner class ServiceOrientationListener(ctx: Context) : OrientationEventListener(ctx) {
@SuppressLint("SetTextI18n")
override fun onOrientationChanged(degree: Int) {
if (degree == ORIENTATION_UNKNOWN) {
LogContext.log.w("ORIENTATION_UNKNOWN")
return
}

val ssr = screenSurfaceRotation
LogContext.log.w(ITAG, "=====> ssr=$ssr")

// Use parameter degree to determine the device orientation.
LogContext.log.i(ITAG, "=====> In Service: rotation=$degree")
}
}
}

该方案暂无缺点。

方法三

反射系统 Hidden API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.leovp.demo.basic_components.examples.orientation

import android.content.Intent
import android.os.IBinder
import android.view.IRotationWatcher
import com.leovp.androidbase.framework.BaseService
import com.leovp.lib_common_android.exts.surfaceRotationName
import com.leovp.lib_reflection.wrappers.ServiceManager
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ITAG

class OrientationService : BaseService() {

private val screenRotationChanged: IRotationWatcher.Stub = object : IRotationWatcher.Stub() {
override fun onRotationChanged(rotation: Int) {
LogContext.log.w(ITAG, "Device rotation changed to ${rotation.surfaceRotationName}")
}
}

override fun onCreate() {
LogContext.log.i(ITAG, "=====> onCreate <=====")
super.onCreate()
ServiceManager.windowManager?.registerRotationWatcher(screenRotationChanged)
}

override fun onDestroy() {
LogContext.log.i(ITAG, "=====> onDestroy <=====")
ServiceManager.windowManager?.removeRotationWatcher(screenRotationChanged)
super.onDestroy()
}

override fun onBind(intent: Intent): IBinder? = null
}

具体的反射方法,详见 我的 Github 完整代码

注意:该方案同“方法二”类似,基本没缺点。但是唯一的缺点,也是潜在的缺点,就是使用了反射调用系统 Hidden API。

随着系统的升级,若 API 的方法签名发生变化或升级,则需要重新适配。此外,从 Android 11 开始,Google 就开始打压反射系统 Hidden API 的操作,虽然截止目前都有应对方法,但是不排除日后被彻底禁用的可能。

注意:该方案特有的好处是可以在 dex 中使用该方法。

特别说明

android:screenOrientation 属性被设置成不允许“旋转”,或关闭了自动旋转屏幕。则上述所有方法,除了 OrientationEventListener 方案外,其它所有方法都无法获取到设备当前的方向。

结论

综上所述,考虑到大多数人使用手机时,通常都会关闭自动旋转屏幕。因此,为了能够始终获取到正确的屏幕方向(即设备方向),建议使用 OrientationEventListener 方案或 “反射系统 Hidden API” 的方案。

项目源码

项目完整代码,详见 我的 Github

附录

关于 <activity>android:screenOrientation 属性

根据官网说明,android:screenOrientation 属性用于表明 Activity 在设备上的显示方向。其值及含义详见下表:

注意:如果 Activity 是在多窗口模式下运行,则系统会忽略该属性。

含义
unspecified 默认值。由系统选择方向。在不同设备上,系统使用的政策以及基于政策在特定上下文中所做的选择可能会有所差异。
behind 与 activity 堆栈中紧接其后的 activity 的方向相同。
landscape 屏幕方向为横向(显示的宽度大于高度)。
portrait 屏幕方向为纵向(显示的高度大于宽度)。
reverseLandscape 屏幕方向是与正常横向方向相反的横向。 在 API 级别 9 中引入。
reversePortrait 屏幕方向是与正常纵向方向相反的纵向。 在 API 级别 9 中引入。
sensorLandscape 屏幕方向为横向,但可根据设备传感器调整为正常或反向的横向。即使用户锁定基于传感器的旋转,系统仍可使用传感器。 在 API 级别 9 中引入。
sensorPortrait 屏幕方向为纵向,但可根据设备传感器调整为正常或反向的纵向。即使用户锁定基于传感器的旋转,系统仍可使用传感器。 在 API 级别 9 中引入。
userLandscape 屏幕方向为横向,但可根据设备传感器和用户首选项调整为正常或反向的横向。在 API 级别 18 中引入。
userPortrait 屏幕方向为纵向,但可根据设备传感器和用户首选项调整为正常或反向的纵向。 在 API 级别 18 中引入。
sensor 屏幕方向由设备方向传感器决定。显示方向取决于用户如何手持设备,它会在用户旋转设备时发生变化。但在默认情况下,一些设备不会旋转为所有四种可能的方向。如要支持所有这四种方向,请使用 "fullSensor"。即使用户锁定基于传感器的旋转,系统仍可使用传感器。
fullSensor 屏幕方向由使用 4 种方向中任一方向的设备方向传感器决定。 这与 "sensor" 类似,不同之处在于无论设备在正常情况下使用哪种方向,该值均支持所有 4 种可能的屏幕方向(例如,一些设备正常情况下不使用反向纵向或反向横向,但其支持这些方向)。在 API 级别 9 中引入。
nosensor 确定屏幕方向时不考虑物理方向传感器。系统会忽略传感器,因此显示内容不会随用户手持设备的方向而旋转。
user 用户当前的首选方向。
fullUser 如果用户锁定基于传感器的旋转,则其行为与 user 相同,否则,其行为与 fullSensor 相同,并且支持所有 4 种可能的屏幕方向。 在 API 级别 18 中引入。
locked 将方向锁定在其当前的任意旋转方向。在 API 级别 18 中引入。

参考文献

https://developer.android.com/training/camerax/orientation-rotation

坚持原创及高品质技术分享,您的支持将鼓励我继续创作!