说明:
本文转载,原文地址:https://zhuanlan.zhihu.com/p/451056794
原作者:尼特胡
需要阅读请点击原文链接观看。
【Android平板适配】手机/平板二合一应用一站式适配攻略
为啥要适配Android平板
Android平板用户越来越多
平板领域其实早已变天了,不再是IOS一家独大。2020年ipad国内市场份额已不足50%,今年更是不足30%。随着华为在平板上的发力,其它国内厂商也看到了Android平板这块的利润,纷纷推出新款平板。
平板专区,流量扶持
华为、荣耀、小米等厂商会有平板专区,如果适配了平板并且审核通过,将会获得极大的曝光。(如笔者开发的Elfinbook易飞这款应用,因为适配比较完善,顺利进入小米平板专区,且最近两个月均排在前三位,着实获取了一把流量)
适配方案
各家厂商均给出了适配方案文档,如:
不过都挺冗长、晦涩难懂且不完全。适配也踩了很多坑,今天这篇文章就是带着大家把坑填平。
平行视界
最少量开发、快速适配平板的方法。可以横屏下显示多Activity。各厂商都有,但叫法不同,如小米就叫平行窗口(magicWindow)。很多应用,如头条、B站、抖音均使用了平行视界,如图:
可以看到,横屏下应用可以同时显示两个Activity。适配非常简单,基本写一个xml配置文件就可以了。但缺点也很明显,就是个手机版的双屏版本,除了显示内容变多了,没有其它任何平板显示交互的优化。最大的问题是,横屏下单窗口时不能全屏,相比不适配,可显示区域反而变少了。
如果你时间紧,任务重,可以考虑这种适配方案。但显然不是一个高质量的适配方案,也达不到上架平板专区的要求。
正确获取设备及屏幕参数
工欲善其事,必先利其器。平板设备各种尺寸都有,且还可以小窗、分屏、旋转、平行窗口,必须先精确获取屏幕参数。
判断是平板设备还是平板窗口
平板就是平板,为什么还有平板设备和平板窗口之分呢?因为平板分屏下,Activity变小,UI展示应该按照手机上来,所以平板也会有手机窗口。后面分屏会讲到用法。
/**
* 判断是否平板设备,此值不会改变
*/
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)大小就等于屏幕物理尺寸,但平板因为可以多窗口显示,并不总是相等。
获取屏幕物理尺寸:
/**
* 获取屏幕物理尺寸
*
* @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
}
}
获取窗口大小:
fun getScreenSize(context: Context): Point {
val point = Point()
val displayMetrics = context.resources.displayMetrics
point.x = displayMetrics.widthPixels
point.y = displayMetrics.heightPixels
return point
}
判断是否在平行视界
/**
* 平行窗口模式(华为、小米)
*/
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")
}
判断窗口/设备处于横屏
设备横屏,窗口不一定是横屏。如小窗和分屏模式有可能是竖屏。
/**
* 窗口是横屏
*/
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时表示在分屏或小窗
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
isInMultiWindowMode = activity.isInMultiWindowMode
}
用户进入分屏Acitivity的回调:
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)
// 当isInMultiWindowMode为true时,表示进入分屏或小窗
}
动态调整列表展示列数
分屏可以是1/3屏、1/2屏、2/3屏。所以我们应该根据分屏后屏幕尺寸来重新调整展示列数
首先需要重写Activity的onConfigurationChanged方法,触发分屏不会销毁Activity,而是会收到此方法的回调,在此方法里调用上面getScreenSize方法,通过当前窗口宽度计算出要显示的列数,再重新设置LayoutManager。示例如下:
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适配
手机设备上,许多弹窗是宽铺满,底部弹出的,这在平板上展示会非常丑陋。所以一套弹窗应该有两套弹出方式,如图:
可以看出,手机上Dialog应从底部弹出,平板应居中显示,且不可以铺满。平板分屏状态下,2/3屏和1/2屏时应该和手机显示一致。
给不同设备适配不同Dialog动画
这就用到上面isTabletWindow()方法了,可以动态判断是否平板窗口。此方法在平板全屏、2/3屏时返回true,1/3、1/2屏 和手机设备上返回false。
dialog.window.setWindowAnimations(if (isTabletWindow(context)) R.style.DialogAnimFadeCenter else R.style.DialogAnimBottomUp)
修改Dialog位置
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。如下:
<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"
<activity
android:name="com.demo.DemoActivity"
android:screenOrientation="behind" />
behind的意思是,屏幕方向和上一个Activity保持一致。
2.在Activity的onCreate里,根据设备类型,再次修改屏幕方向
override fun onCreate(savedInstanceState: Bundle?) {
requestedOrientation = if (ScreenUtils.isTabletDevice) {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
super.onCreate(savedInstanceState)
}
如果是平板设备,就再次指定为可自由旋转,否则指定为竖屏。因为手机本来就是竖屏,所以指定为竖屏不会重建Activity,也就不会闪屏啦。
非全屏窗口
适配了这么多,平板设备的优势还没太显现出来,那就是大屏幕,多内容!我们想要平行窗口的多窗口展示,也想要单窗口时撑满屏幕。既要...也要...,能实现吗?当然,看效果:
手机设备仍然是铺满展示,平板半屏展示,既可以展示更多内容,也能避免没有适配的页面被横向拉伸。
Activity定义半屏主题
<style name="HalfScreenTheme" parent="AppTheme">
<!--适配平板半屏用的主题-->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@style/SlideAnim</item>
</style>
此主题主要是设置透明背景和Activity滑入/滑出动画。在Manifest中给需要半屏显示的Activity设置此theme即可。
定义半屏滑入、滑出动画
如果是抽屉Activity,那需要有一个侧边栏滑入、滑出的动画。
<style name="SlideAnim">
<item name="android:activityOpenEnterAnimation">@anim/activity_slide_enter_left</item>
<item name="android:activityCloseExitAnimation">@anim/activity_slide_exit_left</item>
</style>
// activity_slide_enter_left.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:duration="300"
android:fromXDelta="-100%"
android:toXDelta="0" >
</translate>
</set>
// activity_slide_exit_left.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:duration="300"
android:fromXDelta="0"
android:toXDelta="-100% " >
</translate>
</set>
但如果是在抽屉Activity之上再展示半屏Activity,就不需要动画了。另外定义个主题,删除android:windowAnimationStyle,或换成淡入、淡出动画即可。
Activity onCreate方法半屏设置
Activity的onCreate方法中设置半屏比例、点击外侧关闭和背景变暗等, 可以在BaseActivity中设置。
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,而是要根据用户旋转角度实时旋转界面元素,如图:
[图片上传失败...(image-29a544-1653997563020)]
监听屏幕旋转角度
OrientationEventListener是系统自带的屏幕旋转方向监听,监听范围0-359度。几个阈值我调整了许多次,基本避免了旋转动画跳动、不流畅等问题。
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() //开始监听,使用完记得禁用
}
按需执行旋转动画
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作为临界值。
val layoutResId = if (screenWidth / screenHeight.toFloat() > 0.65625f) {// 超过了目标大小就绪要引用平板布局
R.layout.fragment_camera_tablet
} else {
R.layout.fragment_camera
}
相机其它适配注意点
不仅仅View需要旋转,Dialog、Toast、PopupWindow等一切屏幕可见元素都需要。所以相机适配也是我花费最大精力的部分,但鉴于业务逻辑并不相同,不再赘述。
还有好多适配细节没有提到,鉴于篇幅和作者的懒惰,权当抛砖引玉吧。