【Android平板适配】手机/平板二合一应用一站式适配攻略

语言: CN / TW / HK

为啥要适配Android平板

Android平板用户越来越多

平板领域其实早已变天了,不再是IOS一家独大。2020年ipad国内市场份额已不足50%,今年更是不足30%。随着华为在平板上的发力,其它国内厂商也看到了Android平板这块的利润,纷纷推出新款平板。

平板专区,流量扶持

华为、荣耀、小米等厂商会有平板专区,如果适配了平板并且审核通过,将会获得极大的曝光。(如笔者开发的Elfinbook易飞这款应用,因为适配比较完善,顺利进入小米平板专区,且最近两个月均排在前三位,着实获取了一把流量)

1

适配方案

各家厂商均给出了适配方案文档,如:

华为平板适配官方文档

小米平板适配官方文档

不过都挺冗长、晦涩难懂且不完全。适配也踩了很多坑,今天这篇文章就是带着大家把坑填平。

平行视界

最少量开发、快速适配平板的方法。可以横屏下显示多Activity。各厂商都有,但叫法不同,如小米就叫平行窗口(magicWindow)。很多应用,如头条、B站、抖音均使用了平行视界,如图:

2

可以看到,横屏下应用可以同时显示两个Activity。适配非常简单,基本写一个xml配置文件就可以了。但缺点也很明显,就是个手机版的双屏版本,除了显示内容变多了,没有其它任何平板显示交互的优化。最大的问题是,横屏下单窗口时不能全屏,相比不适配,可显示区域反而变少了。

如果你时间紧,任务重,可以考虑这种适配方案。但显然不是一个高质量的适配方案,也达不到上架平板专区的要求。

正确获取设备及屏幕参数

工欲善其事,必先利其器。平板设备各种尺寸都有,且还可以小窗、分屏、旋转、平行窗口,必须先精确获取屏幕参数。

判断是平板设备还是平板窗口

平板就是平板,为什么还有平板设备和平板窗口之分呢?因为平板分屏下,Activity变小,UI展示应该按照手机上来,所以平板也会有手机窗口。后面分屏会讲到用法。

```kotlin /* * 判断是否平板设备,此值不会改变 / val isTabletDevice: Boolean by lazy { SystemPropertiesProxy.get(context, "ro.build.characteristics")?.contains("tablet") == true }

/* * 动态判断是否平板窗口 * 在平板设备上,也可能返回false。如分屏模式下 * 如想判断物理设备是不是平板,请使用 isTabletDevice * @return true:平板,false:手机 * @see isTabletDevice / fun isTabletWindow(context: Context): Boolean { return context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK >= Configuration.SCREENLAYOUT_SIZE_LARGE } ```

正确获取屏幕物理尺寸和窗口大小

手机上而言,窗口(或者Activity)大小就等于屏幕物理尺寸,但平板因为可以多窗口显示,并不总是相等。

获取屏幕物理尺寸:

```kotlin /* * 获取屏幕物理尺寸 * * @param context 上下文 * @return 物理尺寸 / private fun getScreenPhysicsSize(context: Context): DisplayMetrics { val display = getDisplay(context) display?.getRealMetrics(mMetrics) return mMetrics }

private fun getDisplay(context: Context): Display? {
    val windowManager = context
        .getSystemService(Context.WINDOW_SERVICE) as WindowManager
    return if (Build.VERSION.SDK_INT >= 30) {
        context.display!!
    } else {
        windowManager.defaultDisplay
    }
}

```

获取窗口大小:

kotlin fun getScreenSize(context: Context): Point { val point = Point() val displayMetrics = context.resources.displayMetrics point.x = displayMetrics.widthPixels point.y = displayMetrics.heightPixels return point }

判断是否在平行视界

kotlin /** * 平行窗口模式(华为、小米) */ fun inMagicWindow(context: Context): Boolean { val config: String = context.resources.configuration.toString() return config.contains("hwMultiwindow-magic") || config.contains("miui-magic-windows") || config.contains("hw-magic-windows") }

判断窗口/设备处于横屏

设备横屏,窗口不一定是横屏。如小窗和分屏模式有可能是竖屏。

```kotlin /* * 窗口是横屏 / fun isWindowLandscape(context: Context): Boolean { val orientation: Int = context.resources.configuration.orientation return orientation == Configuration.ORIENTATION_LANDSCAPE }

/**
 * 设备是横屏
 */
fun isDeviceLandscape(context: Context): Boolean {
    val screenPhysicsSize = getScreenPhysicsSize(context)
    return screenPhysicsSize.widthPixels > screenPhysicsSize.heightPixels
}

```

分屏适配

判断是否在分屏模式

这里注意Android7.0之后才支持分屏, isInMultiWindowMode为true时表示在分屏或小窗

kotlin if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { isInMultiWindowMode = activity.isInMultiWindowMode }

用户进入分屏Acitivity的回调:

kotlin override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) { super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) // 当isInMultiWindowMode为true时,表示进入分屏或小窗 }

动态调整列表展示列数

分屏可以是1/3屏、1/2屏、2/3屏。所以我们应该根据分屏后屏幕尺寸来重新调整展示列数,如下图:

fenping.gif

首先需要重写Activity的onConfigurationChanged方法,触发分屏不会销毁Activity,而是会收到此方法的回调,在此方法里调用上面getScreenSize方法,通过当前窗口宽度计算出要显示的列数,再重新设置LayoutManager。示例如下:

kotlin override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val newColumn = min(7, max(3, (getScreenSize(context).x / dip2px(context, 150f).toFloat() + 0.3f).roundToInt()) // 根据窗口宽度动态计算出一个3-7的列数 recyclerView.setLayoutManager(GridLayoutManager(this, newColumn)) }

Dialog适配

手机设备上,许多弹窗是宽铺满,底部弹出的,这在平板上展示会非常丑陋。所以一套弹窗应该有两套弹出方式,如图:

popup.jpg

可以看出,手机上Dialog应从底部弹出,平板应居中显示,且不可以铺满。平板分屏状态下,2/3屏和1/2屏时应该和手机显示一致。

给不同设备适配不同Dialog动画

这就用到上面isTabletWindow()方法了,可以动态判断是否平板窗口。此方法在平板全屏、2/3屏时返回true,1/3、1/2屏 和手机设备上返回false。

kotlin dialog.window.setWindowAnimations(if (isTabletWindow(context)) R.style.DialogAnimFadeCenter else R.style.DialogAnimBottomUp)

修改Dialog位置

kotlin dialog.window.attributes.gravity = if (isTabletWindow(context)) Gravity.CENTER else Gravity.Bottom

修改Dialog宽度

dialog.window.attributes.width = if (isTabletWindow(context)) WRAP_CONTENT else MATCH_PARENT

旋转屏幕适配

手机上大家绝大多数场景都是竖屏使用,大多数Android应用本来就不支持横屏显示,但是平板设备横屏很常见,用户可能会经常切换屏幕方向。

旋转屏幕不重建Activity

通过在Manifest中给Activity增加configChanges属性,可以旋转不销毁重建Activity。如下:

xml <activity android:name=".com.example.DemoActivity" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />

各属性意义如下:

| screenLayout | 屏幕的显示发生了变化---不同的显示被激活 | | ------------------ | ------------------------------------------------ | | orientation | 屏幕方向改变了---横竖屏切换 | | screenSize | 屏幕大小改变了 | | smallestScreenSize | 屏幕的物理大小改变了,如:连接到一个外部的屏幕上 |

添加configChanges后,当屏幕方向改变时,Activity会回调onConfigurationChanged()方法。就像前面分屏一样,可以在此方法回调中更新列数,View尺寸等。

禁止手机自动旋转

很多时候,我们不想手机用户自动旋转屏幕,只锁定竖屏就够了。平板用户可以自由旋转。那一个应用怎么满足这两种需求呢?我们知道可以在AndroidManifest中加android:screenOrientation="portrait",但Manifest中并不能动态判断手机还是平板设备。我们还知道可以在Activity中调用setRequestedOrientation()方法动态设置屏幕方向,但此时Activity已创建过了,强行改变屏幕方向会重建Activity,出现闪屏。我们当然不希望适配个平板还让手机闪屏了。但网上搜了很久也没找到解决方案,后来自己琢磨出来一个办法:

1.在AndroidManifest中设置Activity为android:screenOrientation="behind"

xml <activity android:name="com.demo.DemoActivity" android:screenOrientation="behind" />

behind的意思是,屏幕方向和上一个Activity保持一致。

2.在Activity的onCreate里,根据设备类型,再次修改屏幕方向

kotlin override fun onCreate(savedInstanceState: Bundle?) { requestedOrientation = if (ScreenUtils.isTabletDevice) { ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } else { ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } super.onCreate(savedInstanceState) }

如果是平板设备,就再次指定为可自由旋转,否则指定为竖屏。因为手机本来就是竖屏,所以指定为竖屏不会重建Activity,也就不会闪屏啦。

非全屏窗口

适配了这么多,平板设备的优势还没太显现出来,那就是大屏幕,多内容!我们想要平行窗口的多窗口展示,也想要单窗口时撑满屏幕。既要...也要...,能实现吗?当然,看效果:

halfScreen.jpg

手机设备仍然是铺满展示,平板半屏展示,既可以展示更多内容,也能避免没有适配的页面被横向拉伸。

Activity定义半屏主题

```xml

```

此主题主要是设置透明背景和Activity滑入/滑出动画。在Manifest中给需要半屏显示的Activity设置此theme即可。

定义半屏滑入、滑出动画

如果是抽屉Activity,那需要有一个侧边栏滑入、滑出的动画。

```xml

// activity_slide_enter_left.xml

// activity_slide_exit_left.xml

```

但如果是在抽屉Activity之上再展示半屏Activity,就不需要动画了。另外定义个主题,删除android:windowAnimationStyle,或换成淡入、淡出动画即可。

Activity onCreate方法半屏设置

Activity的onCreate方法中设置半屏比例、点击外侧关闭和背景变暗等, 可以在BaseActivity中设置。

kotlin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (inHalfScreenMode()) { // 是否是半屏模式,根据需要设置 val root = findViewById<View>(android.R.id.content) ?: return // 最顶层的View // 设置抽屉所占比例,横屏时比例占40%,竖屏占75% val width = (getScreenWidth(this) * if (isWindowLandscape(this)) 0.4 else 0.75).toInt() root.layoutParams.width = width (root.parent as View).setOnClickListener { // 抽屉打开时,点击外侧应该关闭该Activity finish() } root.setOnClickListener { } //防止点击穿透 // 设置背景变暗 window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) window.attributes.dimAmount = 0.4f } }

inHalfScreenMode可以是从外部传入Activity的Intent参数,就可以动态控制此Activity是否要半屏显示啦。

相机适配

自动旋转

相机和其它适配不同,为保证用户体验,相机在旋转过程中预览必须为连续的,所以不能销毁重建Activity或View,而是要根据用户旋转角度实时旋转界面元素,如图:

camera.gif

监听屏幕旋转角度

OrientationEventListener是系统自带的屏幕旋转方向监听,监听范围0-359度。几个阈值我调整了许多次,基本避免了旋转动画跳动、不流畅等问题。

kotlin private var orientationEventListener: OrientationEventListener? = null /** * 打开屏幕方向改变监听 */ fun enableOrientationListener() { orientationEventListener = object : OrientationEventListener(requireContext()) { override fun onOrientationChanged(orientation: Int) { var currentOrientation = mCurrentOrientation if (orientation >= 330 || orientation in 0..29) { // 设备放平会返回ORIENTATION_UNKNOWN(-1),不做处理,否则会抖动 currentOrientation = 0 } else if (orientation in 60..119) { currentOrientation = -90 } else if (orientation in 150..209) { currentOrientation = 180 } else if (orientation in 240..299) { currentOrientation = 90 } if (mCurrentOrientation != currentOrientation) { onScreenOrientationChanged(currentOrientation) //在此方法里旋转可见View mCurrentOrientation = currentOrientation } } } orientationEventListener?.enable() //开始监听,使用完记得禁用 }

按需执行旋转动画

kotlin fun onScreenOrientationChanged(degree: Int) { // viewList即要旋转的View列表 viewList.filter { it?.isVisible == true } //可见的View执行旋转动画 .forEach { ObjectAnimator.ofFloat(it, "rotation", previousDegree.toFloat(), degree.toFloat()) .start() } viewList.filter { it?.isVisible == false } //不可见的View直接更改旋转角度 .forEach { it?.rotation = degree.toFloat() } }

根据宽高比使用不同布局

因平板宽高比和手机设备相差较大,大部分应用的相机都采用4:3的拍摄尺寸,全部使用一个布局可能会导致黑边过大、显示不全等问题。所以应该根据宽高比使用不同布局,如笔者以宽高比0.65625作为临界值。

kotlin val layoutResId = if (screenWidth / screenHeight.toFloat() > 0.65625f) {// 超过了目标大小就绪要引用平板布局 R.layout.fragment_camera_tablet } else { R.layout.fragment_camera }

相机其它适配注意点

不仅仅View需要旋转,Dialog、Toast、PopupWindow等一切屏幕可见元素都需要。所以相机适配也是我花费最大精力的部分,但鉴于业务逻辑并不相同,不再赘述。

还有好多适配细节没有提到,鉴于篇幅和作者的懒惰,权当抛砖引玉吧。