概述
关于获取 Android 手机方向,有很多种方法,这原来是一个很简单的问题。但是最近在做“支持 Android 11+ 自定义 Toast 组件”时,同样遇到了这个问题。仔细研究时,发现这个问题其实还是值得讲一下的。
网上虽然已经提供了很多种方法来获取手机方向,但是讲解的都比较片面,并没有说明需要在什么情况下才能使用相关的方法。此外如果手机处于锁定屏幕方向的情况下,应该如何来获取手机方向等问题也没有说清楚。
还有一个问题,就是在 Android 11 及以上系统,应用处于后台时,应该如何正确获取手机方向,也有必要说明下。
所以我觉得这里有必要详细的来讲解一下这个问题。尤其是在做相机开发及自定义组件开发时,这个问题需要特别留意。
老习惯,考虑到大忙人较多,这里先说结论:
为了能够始终获取到正确的屏幕方向(即设备方向),建议使用 OrientationEventListener
方案或 “反射系统 Hidden API” 的方案。
项目完整代码,详见 我的 Github 。
送走了大忙人,接下来请客官仔细阅读正文。
必要术语
在讲解之前,我们非常有必要先约定一下相关的术语。
该术语是我们最熟知的,也就是我们常说的怎么拿着手机,指的就是设备方向。
下面这个示例是在手机纵向为自然方向,各个屏幕方向的示例:
纵向(自然方向)
横向(设备逆时针旋转90度)
反向纵向(设备旋转180˚)
反向横向(设备顺时针旋转90˚)
注意 :与之类似的还有 “横向屏幕方向”,某些 Pad 就是这种情况,其自然方向是横向的而不是纵向的。本文不做讲解,大家可以自行脑补下。
屏幕旋转角度 (Display rotation)
指的是 Display.getRotation()
返回的值,表示设备从其自然屏幕方向逆时针旋转的角度值。
该术语也是我们平时开发时,接触最多的。
此术语表示顺时针旋转设备使其达到自然屏幕方向需要旋转的度数。
相机开发时,会用到该术语。
示例
这里以手机纵向为自然方向为例,说明下以上术语的具体意义。
屏幕方向
纵向(自然方向)
横向(设备逆时针旋转90˚)
反向纵向(设备旋转180˚)
反向横向(设备顺时针旋转90˚)
屏幕旋转角度
0˚ (ROTATION_0)
90˚ (ROTATION_90)
180˚ (ROTATION_180)
270˚ (ROTATION_270)
目标旋转角度
0˚
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 时,从用户体验上来说,通常不存在 “反向纵向” 的屏幕方向,即倒拿着手机。因此下面的示例不包括这种情况。
纵向(自然方向)
横向(设备逆时针旋转90˚)
反向横向(设备顺时针旋转90˚)
纵向(自然方向)
横向(设备逆时针旋转90˚)
反向横向(设备顺时针旋转90˚)
纵向(自然方向)
横向(设备逆时针旋转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.orientationimport android.annotation .SuppressLintimport android.content.Contextimport android.content.Intentimport android.content.pm.ActivityInfoimport android.content.res.Configurationimport android.os.Bundleimport android.util.DisplayMetricsimport android.view.IRotationWatcherimport android.view.OrientationEventListenerimport com.leovp.demo.base.BaseDemonstrationActivityimport com.leovp.demo.databinding.ActivityOrientationBindingimport com.leovp.lib_common_android.exts.*import com.leovp.lib_reflection.wrappers.ServiceManagerimport com.leovp.log_sdk.LogContextimport com.leovp.log_sdk.base.ITAGclass 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() } private fun getScreenOrientation () : Int { val dm: DisplayMetrics = resources.displayMetrics 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 ) { 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 及之后版本,无法获得屏幕方向。
补充说明
**注意:**如果屏幕内容没有发生旋转,该方法就无法获取当前屏幕的最新方向(即设备方向)。换句话说就是,只有 当屏幕内容发生旋转后,该方法才能获取当前设备的最新方向(即设备方向)。
屏幕内容允许旋转的先决条件
只有满足以下任一条件,当设备旋转后,屏幕内容才允许被旋转:
**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 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) { 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 WindowManagerval Context.displayManager get () = getSystemService(Context.DISPLAY_SERVICE) as DisplayManagerfun fail (message: String ) : Nothing { throw IllegalArgumentException(message) }
自动旋转屏幕关闭 ,应用处于前台,获取设备 方向的方法
注意 :使用此方法获得的是设备当前所处于的方向,而不是屏幕内容的方向。例如,若手机处于横向看视频时,视频内容可能是处于纵向(自然方向),但是手机的方向却是横向的。
使用 OrientationEventListener
可以准确获取到当前设备的方向(四个方向之一)。
再来看一下该方法的优缺点:
优点
缺点
无视自动旋转屏幕 状态,任何情况均可准确获取当前设备的方向。
无
使用方法,在 Activity
或 Service
中使用均可:
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 ) deviceOrientationEventListener?.enable() } override fun onDestroy () { 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 } } }
onOrientationChanged(degree: Int)
接口的参数返回的是当前设备相对于纵向(自然方向)所偏离的角度,值的范围是 [0, 359]。我们可以根据该角度来判断当前设备的方向。 我的 Github 完整代码 中有具体的判断方法。
补充说明
经测试发现,在开启自动旋转屏幕 时,系统在设备旋转超过 60˚ 时,才会改变屏幕方向。这一点和系统相机判断设备是否处于横屏拍摄时的条件一致。
在 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.orientationimport android.content.Intentimport android.content.res.Configurationimport android.os.IBinderimport com.leovp.androidbase.framework.BaseServiceimport com.leovp.log_sdk.LogContextimport com.leovp.log_sdk.base.ITAGclass OrientationService : BaseService () { private var lastOrientation = -1 override fun onConfigurationChanged (newConfig: Configuration ) { super .onConfigurationChanged(newConfig) if (newConfig.orientation != lastOrientation) { 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.orientationimport android.annotation .SuppressLintimport android.content.Contextimport android.content.Intentimport android.os.IBinderimport android.view.OrientationEventListenerimport com.leovp.androidbase.framework.BaseServiceimport com.leovp.lib_common_android.exts.screenSurfaceRotationimport com.leovp.log_sdk.LogContextimport com.leovp.log_sdk.base.ITAGclass 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 " ) 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.orientationimport android.content.Intentimport android.os.IBinderimport android.view.IRotationWatcherimport com.leovp.androidbase.framework.BaseServiceimport com.leovp.lib_common_android.exts.surfaceRotationNameimport com.leovp.lib_reflection.wrappers.ServiceManagerimport com.leovp.log_sdk.LogContextimport com.leovp.log_sdk.base.ITAGclass 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