Android平板、折叠手机等大屏设备适配方案

背景

随着华为、小米等厂商在平板、折叠手机上的发力,Android对多窗口(分屏模式)、自由窗口、画中画等功能的支持,在大屏设备上的体验越来越好,国内市场份额也已占据首位,越来越多的产品针对大屏设备进行相应的适配处理,为用户带来更好的体验。

数据分析报告

大屏设备主要体现在平板电脑和折叠屏手机,出货量和占比等分析报告如下:

数据表明:国内Android平板已占据主流,折叠屏销量日益增长,做好大屏设备的适配工作可提高较多用户的使用体验。

产品是否需要对大屏设备做出适配,怎样选择合适的适配方案?

  1. 产品需要对大屏设备做适配。
    Android屏幕尺寸数不胜数,开发过程中要尽可能全的考虑UI的适配,应有一套适合自己产品的UI适配规范

  2. 对于平板和折叠屏的适配方案有很多,开发成本受方案影响较大。选择的方案不但会影响用户体验,还会导致后期的开发和维护成本。
    选择哪种方案应根据该产品和大屏设备的用户量来定,总结三个方向:根据产品类别根据用户群里分析行业竞品

    1. 根据产品的方向来定:
      视频类的APP用户操作习惯更多的是横屏操作,绝大多数页面需要考虑横屏下的布局,这类APP可采用响应式布局或开发HD版本来提升体验。
      新闻、音乐、短视频和购物类APP,用户更习惯于竖屏操作,可采用部分特殊页面适配横屏布局,其他页面兼容横屏,防止横竖屏切换UI变形。
      游戏类APP,往往需要固定方向等。
    2. 根据大屏设备的用户量来定:
      如开发前可以根据用户使用的设备进行埋点,统计平板等大屏设备和普通手机的用户,根据他们的设备ID和uid等信息,来统计占比。
      如占比很小且用户量很少,则可以对横屏进行简单的适配。
      如占比很大或用户群体多,可再进一步分析用户的操作习惯,如是横屏状态下多还是竖屏状态下的多,甚至可以具体到某一页面。根据统计的数据可决定该页面是否需要出一套横屏布局。
    3. 分析竞品。对齐行业标准,用户习惯已养成,方案成熟。
    /**
     * 判断是否是平板设备
     */
    public boolean isTablet() {
        return (getResources().getConfiguration().screenLayout 
                & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
    }
    /**
     * 判断屏幕方向
     */
    public boolean isScreenOriatationPortrait(Context context) {
        return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT;
    }

竞品分析

适配方案总结:

适配方案 描述 优点 缺点
HD版本 针对平板设备重新开发维护一个APP 针对性强、更灵活使得体验更好,可跟APP隔离开 开发、设计和维护成本大
响应式布局 基于同一套代码,开发一个APP能够兼容多尺寸、多终端设备的显示,能够动态调整页面的布局及容器的布局 后期迭代速度快且成本低 前期开发成本高、改造页面多且复杂、没有标准属于摸索阶段
横屏固定宽度 根据屏幕方向控制屏幕旋转,横屏状态下固定宽度,两边留白 入侵性小、维护成本低 没有找到采用类似方案的竞品、与系统自带的平行视界样式相重、无法适配多窗口模式
平行视界 pad等大屏设备自带的功能,部分手厂商的设备可灵活配置屏幕是否可调节、分屏模式等 设备推荐、入侵性低、成本低、可分屏并自由调节分屏大小 各家设备对于平行视界的处理参差不齐,横竖屏切换操作偶尔会显示异常不居中

以上方案可结合屏幕限定符一起结合使用

最小宽度值与典型屏幕尺寸的对应关系:
320dp:典型手机屏幕(240x320 ldpi、320x480 mdpi、480x800 hdpi 等)。
480dp:约为 5 英寸的大手机屏幕 (480x800 mdpi)。
600dp:7 英寸平板电脑 (600x1024 mdpi)。
720dp:10 英寸平板电脑(720x1280 mdpi、800x1280 mdpi 等)
最小宽度限定符:
res/layout/main_activity.xml
res/layout-sw600dp/main_activity.xml # 屏幕宽度至少600dp用该布局
可用宽度限定符:
res/layout/main_activity.xml
res/layout-w600dp/main_activity.xml #宽度超过600dp的屏幕
屏幕方向限定符:
res/layout/main_activity.xml # 手机
res/layout-land/main_activity.xml # 手机-横屏
res/layout-sw600dp/main_activity.xml # 7英寸平板
res/layout-sw600dp-land/main_activity.xml # 7英寸平板-横屏

响应式布局

可以根据屏幕具体的物理尺寸自适应的显示,只需要开发一套代码,就可以兼容多种尺寸的终端,不需要开发单独的HD版本

推荐文章:
将界面迁移到自适应布局
优酷响应式布局技术全解析
Android 与 Chrome OS 中针对大屏幕设备的更新
Android大屏应用质量要求
activity 嵌入
为可折叠设备构建应用
Android 折叠屏技术发展与适配
华为折叠屏应用开发指导

横屏设置固定宽度
  1. 设置activity旋转不被销毁:
<activity
    android:name=".xx.xxActivity"  
    android:configChanges="keyboardHidden|smallestScreenSize|orientation|screenSize"
    android:launchMode="xx"/>
screenLayout 屏幕的显示发生了变化---不同的显示被激活
orientation 屏幕方向改变了---横竖屏切换
screenSize 屏幕大小改变了
smallestScreenSize 屏幕的物理大小改变了,如:连接到一个外部的屏幕上
  1. 创建屏幕旋转的工具类:
public class ScreenRotatingManager {
   
    /**
     * 用户关闭重力感应
     */
    private static final int USER_CLOSE_GRAVITY = 0;
    
    /**
     * 屏幕旋转角度阈值
     */
    private int mRotationAngle = 50;

    private OrientationSensorListener mListener;
    private SensorManager mSensorManager;
    private Sensor mSensor;
    private Activity mActivity;
    
    /**
     * 是否开启重力感应
     */
    private int isOpenRotation;

    public ScreenRotatingManager(Context context) {
        mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
        mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        mListener = new OrientationSensorListener();
        mSensorManager.registerListener(mListener, mSensor, SensorManager.SENSOR_DELAY_UI);
    }

    /**
     * 开始监听
     */
    public void startSensor(Activity activity) {
        mActivity = activity;
        if (mSensorManager != null && mListener != null && mSensor != null && mActivity!=null) {
            mSensorManager.registerListener(mListener, mSensor, SensorManager.SENSOR_DELAY_UI);
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
        }
    }

    /**
     * 停止监听
     */
    public void stopSensor() {
        if (mSensorManager != null && mListener != null && mActivity!=null) {
            mSensorManager.unregisterListener(mListener);
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
        }
    }

    /**
     * 释放传感器单利
     */
    public void releaseSensor(){
        if (mActivity !=null){
            mActivity = null;
        }
        if (mListener !=null){
            mListener = null;
        }
        if (mSensorManager!=null){
            mSensorManager = null;
        }
        if (mSensor!=null){
            mSensor = null;
        }
    }

    /**
     * 传感器屏幕方向监听
     */
    public class OrientationSensorListener implements SensorEventListener {
        private static final int _DATA_X = 0;
        private static final int _DATA_Y = 1;
        private static final int _DATA_Z = 2;
        public static final int ORIENTATION_UNKNOWN = -1;
        private int mLastAngleStamp = 0;

        @Override
        public void onAccuracyChanged(Sensor sensor, int accuracy) {

        }

        @Override
        public void onSensorChanged(SensorEvent event) {
            if (mActivity == null){
                return;
            }
            //判断手机重力感应是否开启,0关闭,1开启
            try {
                isOpenRotation = Settings.System.getInt(mActivity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION);
            } catch (Settings.SettingNotFoundException e) {
                e.printStackTrace();
            }
            if (isOpenRotation == USER_CLOSE_GRAVITY) {
                return;
            }

            float[] values = event.values;
            int orientation = ORIENTATION_UNKNOWN;
            float X = -values[_DATA_X];
            float Y = -values[_DATA_Y];
            float Z = -values[_DATA_Z];
            float magnitude = X * X + Y * Y;
            // Don't trust the angle if the magnitude is small compared to the y value
            if (magnitude * 4 >= Z * Z) {
                //1弧度的值 : 180 / 3.1415926
                float oneEightyOverPi = 57.29577957855f;
                float angle = (float) Math.atan2(-Y, X) * oneEightyOverPi;
                orientation = 90 - (int) Math.round(angle);
                // normalize to 0 - 359 range
                while (orientation >= 360) {
                    orientation -= 360;
                }
                while (orientation < 0) {
                    orientation += 360;
                }
            }

            //转动条件,间隔大于1秒且旋转角度大于50度(不舍时间手动转屏屏幕会马上恢复到手机的方向)
            if (Math.abs(mLastAngleStamp - orientation) > mRotationAngle) {
                mLastAngleStamp = orientation;
                setScreenOrientation(orientation);
            }
        }
    }

    private void setScreenOrientation(int orientation) {
        if (orientation > 45 && orientation < 135) {
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
        } else if (orientation > 135 && orientation < 225) {
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
        } else if (orientation > 225 && orientation < 315) {
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
        } else if ((orientation > 315 && orientation < 360) || (orientation > 0 && orientation < 45)) {
            mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
        }
    }
}
  1. 在基类中的onCreate()和onConfigurationChanged()方法里根据屏幕方向,动态设置屏幕宽度,横屏状态下设置宽度 = 竖屏状态下的宽,居中显示
//更改窗口宽度
getWindowManager().getDefaultDisplay().getMetrics(metrics);
WindowManager.LayoutParams lp = getWindow().getAttributes();
int width = verticalScreenWidth;
lp.width = width;
getWindow().setAttributes(lp);
 
//更改根布局宽度
FrameLayout root = findViewById(android.R.id.content);
root.getLayoutParams().width = verticalScreenWidth;
LinearLayout.LayoutParams rootLayoutParams = (LinearLayout.LayoutParams) root.getLayoutParams();
rootLayoutParams.gravity = Gravity.CENTER_HORIZONTAL;
root.getRootView().setBackgroundColor(getResources().getColor(R.color.black));
root.setLayoutParams(rootLayoutParams);
平行视界

各厂商系统自带的平行视界,默认为打开状态(华为需要配置文件开启),设置中可控制开启或关闭该模式,具体路径:
小米:设置 - 应用设置 - 横屏模式(自动开启)21年下半年平板适配说明也有平行窗口配置方式,目前小米文档中已删除该条目。配置方法同华为一样,文档地址:小米平板适配说明、未删除平行窗口适配的文档(视频中):小米平板5平行视界适配教程
华为:设置 - 应用 - 平行视界(需要配置:平行视界配置方法华为可灵活配置页面是否可分屏、分屏模式、屏幕后是否可拖动调节宽度等配置

  1. 修改AndroidManifest.xml内application中新增meta-data;
<meta-data android:name="EasyGoClient" android:value="true" />
  1. 在assets目录下新建配置文件easygo.json:配置模版
    logicEntities.body.mode:
    • 0:购物模式,activityPairs节点不生效
    • 1:自定义模式(包括导航栏模式)
    • -1:不启用分屏(华为文档中没标注,小米旧版文档标注过,同样适用华为平板)
  1. 卸载重装APP、不生效可以杀进程、重启pad试一下!
//判断是否在平行视界
Configuration configurationStr = getResources().getConfiguration().toString();
Log.i(TAG, "是否在华为平行视界:" + configurationStr.contains("hwMultiwindow-magic"));
Log.i(TAG, "是否在华为平行视界:" + configurationStr.contains("hw-magic-windows"));
Log.i(TAG, "是否在小米平行视界:" + configurationStr.contains("miui-magic-windows"));
配置平行视界后效果:
正常状态

分屏样式

分屏拖动样式

多窗口模式:Android 允许多个应用同时共享同一屏幕


用户体验取决于 Android 操作系统的版本和设备类型:
Android7.0以上支持分屏模式,可通过指定activity允许的最小尺寸来处理多窗口的显示方式。
通过设置manifest文件中的Application或Activity属性,添加resizeableActivity="true | false" 确定应用是否可以动态改变尺寸,为自己的应用开启或停用多窗口显示(同样停用小窗模式)。
Android12将多窗口模式作为标准行为,具体参考:多窗口模式说明

如下图:应用在最近使用界面中长按某个应用进入
左上图:设置了禁止动态改变尺寸(resizeableActivity = false),无法分屏或小屏;
右上图:设置了resizeableActivity = true;可以开启分屏或小屏;
左下图:开启了分屏模式分屏需注意生命周期的变化,参考多窗口模式说明
右下图:开启了小屏模式;

    //是否在多窗口模式
    boolean isInMultiWindowMode = isInMultiWindowMode();

    /**
     * 开启分屏模式的回调
     * @param isInMultiWindowMode
     * @param newConfig
     */
    @Override
    public void onMultiWindowModeChanged(boolean isInMultiWindowMode, Configuration newConfig) {
        super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig);
        //当isInMultiWindowMode为true时,表示进入分屏或小窗
    }
    //android N版本API Intent类新增Flag:FLAG_ACTIVITY_LAUNCH_ADJACEN,该Flag仅用于多窗口下的分屏模式(split-screen);
    Intent intent = new Intent(context, LoginActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
    context.startActivity(intent);
//Android N版本API新增layout布局,对layout属性值进行设置,可配置activity的显示位置和窗口大小。
<activity android:name=".MyActivity">
    <layout android:defaultHeight="500dp"
          android:defaultWidth="600dp"
          android:gravity="top|end"
          android:minHeight="450dp"
          android:minWidth="300dp" />
</activity>

如在横屏状态下需要调整列表显示个数可以在onConfigurationChanged()中重新设置recyclerView的列数:recyclerView.setLayoutManager(GridLayoutManager(this, newColumn));

大屏设备适配改造方案(主要考虑横竖屏切换)

今日头条适配方案介绍:
推荐文章:
今日头条适配方案
头条方案框架GitHub

  • 优点:入侵性小成本极低,可根据不同尺寸的设备等比缩放元素,使得在不同设备上露出的内容一致;
  • 缺点1:当某个系统控件或三方库控件的设计图尺寸和和我们项目自身的设计图尺寸差距非常大时,这个问题就越严重;
  • 缺点2:横竖屏切换后导致view元素大小不一;
  1. 对于固定方向的应用,手机设备开启头条适配方案。在平板上某些厂商默认开启了平行窗口模式,应用可横竖屏切换和分屏等,为了避免修改密度导致UI元素错乱,判断平板设备就禁用头条适配。
    /**
     * 判断是否为平板设备
     */
    public boolean isTablet() {
        return (getResources().getConfiguration().screenLayout
                & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
    }
  1. 尽可能使用约束布局:约束布局规范文档
    适配场景最多最明显的是列表,如一行两列、三列等的列表,屏幕方向变化后item要么被拉宽要么就被挤压。
    1. 建议item的布局元素宽高设置0dp,与父布局建立约束条件,设置宽高比,注意️一定不能把宽高写死,否则会导致宽高比失效。
    2. 不建议获取屏幕宽度,减去间隔再均分item,这样会造成屏幕方向改变后item的尺寸没发生变化,导致显示异常。
    3. 设置LayoutManager,给每行定义item的个数。
    4. 可以在布局中设置间距。给列表添加ItemDecoration时需要计算好每个view的偏移量。因为在ItemDecorationgetItemOffsets()方法中设置的偏移量是作用在每个view元素上的,如果item中的view设有比例,每个元素增加的偏移量不同会导致view大小不均等。
    5. 在RecyclerView.Adapter中的onAttachedToRecyclerView()方法中设置跨度,根据不同的Type设置每个类型item的跨度,这时需要配置适合自己的ItemDecoration防止item尺寸不均等。

代码如下:

//每行三列
GridLayoutManager manager = new GridLayoutManager(getContext(), 3);
recyclerView.setLayoutManager(manager);

    /**
     *  直播类型的item跨度为3,其他的跨度为1
     *  跨度设置为3,所以直播item一行一列,其他的一行三列
     * @param recyclerView
     */
    @Override
    public void onAttachedToRecyclerView(RecyclerView recyclerView) {
        super.onAttachedToRecyclerView(recyclerView);
        RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
        if (manager instanceof GridLayoutManager) {
            final GridLayoutManager gridManager = ((GridLayoutManager) manager);
            gridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
                @Override
                public int getSpanSize(int position) {
                    int itemType = getItemViewType(position);
                    if (itemType == LIVE_ROOM_TYPE) {
                        return 3;
                    } else {
                        return 1;
                    }
                }
            });
        }
    }
    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        RecyclerView.LayoutManager lm = parent.getLayoutManager();
        ViewGroup.LayoutParams lp = view.getLayoutParams();
        boolean isGrid = lm instanceof GridLayoutManager && lp instanceof GridLayoutManager.LayoutParams;
        if (!isGrid) {
            return;
        }
        int spanCount = ((GridLayoutManager) lm).getSpanCount();
        int spanIndex = ((GridLayoutManager.LayoutParams) lp).getSpanIndex();
        int spanSize = ((GridLayoutManager.LayoutParams) lp).getSpanSize();
        if (spanSize == spanCount) {
            //占满一整行的不设置边距
            outRect.set(0, 0, 0, 0);
        } else if (spanCount / spanSize == 3) {
            //一行三列
            int l = 0, r = 0;
            if (spanIndex == 0) {
                //最左侧
                l = dp11;
                r = dp3;
            } else if (spanCount % (spanIndex + 1) == 1) {
                //中间
                l = dp7;
                r = dp7;
            } else if (spanCount % (spanIndex + 1) == 0) {
                //最右侧
                l = dp3;
                r = dp11;
            }
            outRect.set(l, 0, r, 0);
        }
    }
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/background"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@drawable/shape_corner_9"
        android:scaleType="centerCrop"
        android:src="@drawable/bg_small_placeholder"
        app:layout_constraintDimensionRatio="h,1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

未完待续...

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

推荐阅读更多精彩内容