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

说明:

本文转载,原文地址: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等一切屏幕可见元素都需要。所以相机适配也是我花费最大精力的部分,但鉴于业务逻辑并不相同,不再赘述。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容