搜索
您的当前位置:首页正文

Android屏幕适配调研与总结——原理、ScreenMatch

来源:二三娱乐
本文提纲.png

一、屏幕适配的原因——碎片化

一句话——“碎片化”。由于android系统是开源的,各手机厂商可以根据各自的设计去生产手机,导致屏幕尺寸没有统一标准,屏幕的宽高比各式各样,屏幕密度也各种各样。这就导致Android开发者想要用一套代码来适配所有的设备变得格外的困难,虽然谷歌提出了使用dp单位来替代px,但是 dp依然有它无法完全适配的地方。

二、屏幕适配的思路——等比例缩放或多套并用

“等比例缩放”,侧重尺寸,就是根据UI设计图给出的尺寸,选择任意一个设备的屏幕尺寸数据作为基准尺寸数据,把这套基准尺寸数据写进values/dimens文件中。当遇到与基准尺寸数据不同的屏幕时,运用一系列的转换方式,在基准尺寸数据的基础上等比例缩放为适合当前屏幕的尺寸数据。
“多套并用”,侧重布局,就是根据不同的尺寸编写多套布局。

三、屏幕适配用到的基础知识

1、像素(px)

像素是屏幕区域的最小分割区域,单位是px。
在不同的屏幕上,1px所占的面积可能不一样大,但是在同一个屏幕上,1px的面积都是一样大的。

2、分辨率 (1920*1080)

比如1920*1080 分辨率表示当前设备的屏幕是由横向1080个像素,纵向1920个像素组合而成。

3、屏幕尺寸(英寸)

屏幕对角线的长度,一般以英寸(1英寸=25.4毫米)为单位。
常见的屏幕尺寸有 4.7英寸, 5.5英寸,6.0英寸。

4、屏幕像素密度(dpi)

一平方英寸面积内存在多少个像素,屏幕密度的单位是 dpi(dots per inch)。
标准屏幕分辨率是160dpi,即一平方英寸面积,存在160个像素(mdpi)。同样是 1920*1080的分辨率,在两款不同尺寸的手机上,一个是4.7英寸,一个是6.0英寸,这两者的像素密度是不一样的。

屏幕像素密度计算公式为:

dpi计算公式.png

一块1920*1080的5寸屏幕,通过上面的公式计算得出,它的像素密度为 440dpi。可见,px是和像素密度有直接关系的像素单位。

dpi计算示例.png

5、密度无关像素(dip/dp)

谷歌建议使用dp(density-independent pixel)作为长度单位,以保证在不同的屏幕像素密度的手机上显示 很相似的效果。
比如:使用480x800的手机上,要画一条长度为一半屏幕宽的线条,我们可以设置线条的长度为240px,而在320x480的屏幕上,我们只需要160px,但是我们可以直接使用160dp,来同时让两个屏幕上的这条线占全宽的一半。

px和dp的换算公式为:px = dp * (dpi / 160)
公式解读
如果有一个屏幕像素密度为160dpi的手机,在它上面,1px=1dp;而如果是 320dpi的手机,则 1px = 0.5dp。简而言之规律就是:屏幕像素密度(dpi)越高的手机,1dp所代表的px就越多。

既然dp这么厉害,为什么还无法适配所有屏幕呢?
简单的说就是上文所说的“碎片化”,这也是dp的不足的地方,下面我们来具体分析一下。
如果以320 x 480屏幕分辨率、160dpi屏幕像素密度的屏幕为标准,画了一条长度为屏幕宽度的一半的直线(即160px),直接用160dp就能完成适配。但是如果这样的布局运行在320x480屏幕分辨率,但是屏幕像素密度为150dpi的设备上,此时布局里面写的160dp就会少于160px(160dp*150dip/160dpi=150px)不再是占屏幕宽的一半,而是不到一半。

6、比例独立像素(sip/sp)

sp(scale-independent pixel)专门用于表示字体大小。
那么sp与dp有什么不同呢?
通常情况下,dp和sp效果类似,不同的是如果用户调整了手机字体,比如从标准,变成了超大,那么,1dp原本等于1px依然不变,但是1sp就会从1px变成3px(只是举个例子,数值不代表真实情况)。因此,在用户调整字体的情况下,同样的布局,可能出现窗口大小不变,但是文字尺寸发生变化的情况。

7、Android 中关于屏幕参数的相关API

  • DisplayMetrics#density 这里的density等于dpi / 160
  • DisplayMetrics#densityDpi 这里的densityDpi就是上文中的dpi
  • DisplayMetrics#scaledDensity 这里的scaledDensity是字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值

DisplayMetrics 实例通过 Resources#getDisplayMetrics 可以获得,而Resouces通过Activity或者Application的Context获得。

综上,px、dp、dpi、density之间的换算关系如下:

  • density = dpi / 160
  • px = density * dp
  • px = dp * (dpi / 160)

四、常用的屏幕适配方案

1、屏幕分辨率限定符适配

屏幕分辨率限定符适配需要在 res 文件夹下创建各种屏幕分辨率对应的 values-xxx 文件夹,如下图:

屏幕分辨率限定符.png

然后根据一个基准分辨率,例如基准分辨率为 1280x720,将宽度分成 720 份,取值为 1px~720px,将高度分成 1280 份,取值为 1px~1280px,生成各种分辨率对应的 dimens.xml 文件。如下分别为分辨率 1280x720 与 1920x1080 所对应的横向dimens.xml 文件:

屏幕分辨率限定符示例.png

假设设计图上的一个控件的宽度为 720px,那么布局中就写 android:layout_width="@dimen/x720" ,当运行程序的时候,系统会根据设备的分辨率去寻找对应的 dimens.xml 文件。例如运行在分辨率为 1280x720 的设备上,系统会自动找到对应的 values-1280x720 文件夹下的 lay_x.xml 文件,由上图可知 x720 对应的值为
720.px,可铺满该屏幕宽度。运行在分辨率为 1920x1080 的设备上,系统会自动找到对应的 values-1920x1080 文件夹下的 lay_x.xml 文件,由上图可知 x720 对应的值为 1080.0px,可铺满该屏幕宽度。这样就达到了屏幕适配的要求!

2、smallestWidth限定符适配

smallestWidth 限定符适配原理与屏幕分辨率限定符适配原理一样,系统都是根据限定符去寻找对应的 dimens.xml 文件。例如程序运行在 最小宽度为 360dp 的设备上,系统会自动找到对应的 values-sw360dp 文件夹下的 dimens.xml 文件。区别就在于屏幕分辨率限定符适配是拿 px 值等比例缩放,而smallestWidth 限定符适配是拿 dp 值来等比缩放而已。需要注意的是“最小宽度”是不区分方向的,即无论是宽度还是高度,哪一边小就认为哪一边是“最小宽度”。如下分别为最小宽度为 360dp 与最小宽度为 640dp 所对应的 dimens.xml 文件:

smallestWidth限定符适配示例.png

获取设备最小宽度代码为:

        DisplayMetrics dm = new DisplayMetrics();
        getWindowManager().getDefaultDisplay().getMetrics(dm);
        int heightPixels = ScreenUtils.getScreenHeight(this);
        int widthPixels = ScreenUtils.getScreenWidth(this);
        float density = dm.density;
        float heightDP = heightPixels / density;
        float widthDP = widthPixels / density;
        float smallestWidthDP;
        if(widthDP < heightDP) {
            smallestWidthDP = widthDP;
        }else {
            smallestWidthDP = heightDP;
        }

ScreenUtils.java完整代码详见本文最后

注:smallestWidth 限定符适配对屏幕分辨率限定符的比较优势

  • smallestWidth 限定符适配只需要更少的dimens.xml 文件
    屏幕分辨率限定符适配是根据屏幕分辨率的,Android 设备分辨率一大堆,而且还要考虑虚拟键盘,这样就需要大量的 dimens.xml 文件。而无论手机屏幕的像素多少、密度多少,90% 的手机的最小宽度都为 360dp,所以采用 smallestWidth 限定符适配只需要少量 dimens.xml 文件即可。
  • 屏幕分辨率限定符适配只能匹配精确的尺寸,而smallestWidth 限定符适配可以向下兼容尺寸
    屏幕分辨率限定符适配需要设备分辨率与 values-xx 文件夹完全匹配才能达到适配,而 smallestWidth 限定符适配寻找 dimens.xml 文件的原理是 从大往小 找,例如设备的最小宽度为 360dp,就会先去找 values-360dp,发现没有则会向下找 values-320dp,如果还是没有才找默认的 values 下的 demens.xml 文件,所以即使没有完全匹配也能达到不错的适配效果。
  • smallestWidth 限定符适配对文字缩放更友好
    屏幕分辨率限定符适配采用的是 px 单位,而 smallestWidth 限定符适配采用的单位是 dp 和 sp,dp 和 sp 是google 推荐使用的计量单位。又由于很多应用要求字体大小随系统改变,所以字体单位使用 sp 也更灵活。

3、布局限定符适配

使用多套布局文件(而不是dimens.xml尺寸文件)适应不同屏幕。使用方法类似上述smallestWidth 限定符适配,不同的是在原本的layout后面加上横杠(而不是values后面),然后加上限定名形成 layout-XXX的形式。

注:这是最不推荐的适配方式,因为一旦要修改某个布局,就要修改多套布局文件。此适配方案最适合这种情况:一款App在手机和平板上要显示不同的布局,但是数据源一致。

布局限定符适配示例.png
布局限定符适配1.png
屏幕限定符适配2.png

4、布局限定符结合屏幕分辨率适配(或结合smallestWidth 限定符适配)

当多个加了限定符的 layout.xml中都引用了同一个 子布局,而子布局的内容可能相同,也可能不同。这个时候,使用 布局别名 可以节省操作量,即布局限定符适配配合屏幕分辨率适配(或smallestWidth 限定符适配)。如下图:

布局别名适配1.png 布局别名适配2.png

5、其他常用适配方案

  • 代码适配
    通过java代码去获取屏幕的宽高,动态去指定控件的宽高。一般用于动态创建控件,或者自定义view自己绘制图形的时候。
  • 接口适配
    当你去向后台请求图片的时候,我们可以在参数中带入屏幕的宽高,或者是控件的宽高,来获取我们想要的图片,在图片返回之后直接就能显示得最优,而不需要我们app中修改任何代码。

五、流行的屏幕适配方案

(一)ScreenMatch屏幕适配方案

1、原理

ScreenMatch屏幕适配方案的原理=smallestWidth限定符适配的原理

2、使用方式

2.1 加载插件
  • 方法一:在AndroidStudio中在线安装ScreenMatch插件
    File -> Settings -> Plugins -> 在输入框中输入“ ScreenMatch”进行搜索 ->选择正确的结果install
  • 方法二:在AndroidStudio中离线线安装ScreenMatch插件
    先到本地,然后执行File -> Settings -> Plugins -> Install plugin from disk 把本地插件安装到AndroidStudio
2.2 配置 screenMatch.propertities文件
base_dp = 设计图**最小宽度**(单位为 dp)
match_dp = 320,360,480,……这些尺寸最后会生成对应的values-sw320,values-sw360,values-sw480等文件夹下的dimens.xml文件
ignore_dp = 533,592,……因为插件会默认生成一些尺寸文件,这里标注后就会禁止生成对应的尺寸文件

如下图:

ScreenMatch配置文件.png
2.3 生成dimens.xml文件

在需要适配的Module上单击右键选择ScreenMatch, 利用此插件生成所有设备对应的 dimens.xml 文件

2.4 在xml布局文件中使用

设计图中的标注是多少 dp,布局中就写多少dp,格式为@dimen/dp_XX。

3、优点

  • 优点一:参照上述smallestWidth 限定符适配对屏幕分辨率限定符的比较优势
  • 优点二:根据设计图的最小宽度配置基准dp后,设计图标注多少 dp,布局中就写多少 dp 。
  • 优点三:可以一键生成所有需要适配的尺寸文件

4、缺点

  • 缺点一:最小宽度为 392.7272 与 411.4285 的手机不能达到完全适配。原因是该插件的默认值都是取整的,即 392.7272 与 411.4285 在插件中写的是 392 与 411。
  • 缺点二:因为是基于smallestWidth 限定符适配原理,所以只能根据最小宽度来设定基准dp,即只适用于短边固定,长边可滚动的UI设计图,这对手机来说没有问题,但是对横屏设计的平板来说就是一个缺陷,通常横屏设计的平板UI设计图是长边固定,短边可滚动,这就要求横屏设计的平板需要以最大宽度(即长边)来设定基准dp——ScreenMatch无法实现。

5、优化

  • 针对缺点一,已经有大神在ScreenMatch插件基础上优化了代码,生成了,下载后使用AndroidStudio的Plugins离线安装方式安装。此时,如果需要适配的最小宽度值是小数,则可保留4位小数。例如 392.727272...则取 392.7272,即配置文件中base_dp=392.7272。
  • 针对缺点二,如果要坚持使用smallestWidth 限定符适配原理的适配方案,只能让UI同事以最小宽度为基准来设计出图,或者在平板中的长边避免给大块区域的长度赋值为@dimen/dp_xx,而应使用match_parent/wrap_parent或权重尺寸。

(二)头条屏幕适配方案

1、原理

头条屏幕适配方案的原理就是调用Android API,根据设备某一维度(宽或高)的真实长度(单位是px)与这一维度在UI设计图上的dp值之间的关系,重新计算density来实现(density见上述关于“Android 中关于屏幕参数的相关API”的部分)。

回顾一下 px、dp、dpi、density之间的换算关系

  • density = dpi / 160
  • px = density * dp
  • px = dp * (dpi / 160)

从dp和px的转换公式 :px = density * dp 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。

在API中 density=dpi/160,头条适配时,将给density重新赋值,即
density= 设备某一维度(宽或高)的真实长度(单位px)/UI设计图中这一维度的dp标注值
这样就可以计算出UI设计图中某一维度的1dp等于多少px,而且设备某一维度(宽或高)的真实长度(单位px)是可以通过Android API获取到的。

2、使用方式

首先创建 ScreenAdapterUtil.java 工具类,代码如下:

public class ScreenAdapterUtil {

    private static float sRoncompatDennsity;
    private static float sRoncompatScaledDensity;
    private void setCustomDensity(@NonNull Activity activity, final @NonNull Application application) {

        //application
        final DisplayMetrics appDisplayMetrics = application.getResources().getDisplayMetrics();

        if (sRoncompatDennsity == 0) {
            sRoncompatDennsity = appDisplayMetrics.density;
            sRoncompatScaledDensity = appDisplayMetrics.scaledDensity;
            application.registerComponentCallbacks(new ComponentCallbacks() {
                @Override
                public void onConfigurationChanged(Configuration newConfig) {
                    if (newConfig != null && newConfig.fontScale > 0) {
                        sRoncompatScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
                    }
                }

                @Override
                public void onLowMemory() {

                }
            });
        }

        //计算宽为360dp 同理可以设置高为640dp的根据实际情况
        final float targetDensity = appDisplayMetrics.widthPixels / 360f;
        final float targetScaledDensity = targetDensity * (sRoncompatScaledDensity / sRoncompatDennsity);
        final int targetDensityDpi = (int) (targetDensity * 160);

        appDisplayMetrics.density = targetDensity;
        appDisplayMetrics.densityDpi = targetDensityDpi;
        appDisplayMetrics.scaledDensity = targetScaledDensity;

        //activity
        final DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();

        activityDisplayMetrics.density = targetDensity;
        activityDisplayMetrics.densityDpi = targetDensityDpi;
        activityDisplayMetrics.scaledDensity = targetScaledDensity;
    }

}

在BaseActivity(所有需要适配的Activity的基类)的onCreate()中,setContentView()之前使用鲜面这行代码:

ScreenAdapterUtil.setCustomDensity(this,getApplication());

3、优点

  • 支持以宽或者高任意一个维度去适配,保持该维度上和设计图一致
  • 支持dp和sp单位,控制迁移成本到最小

4、缺点

  • density的计算结果可能精度不够,从而导致适配出现偏差
    由于头条的适配方案是以手机设备为测试标准,手机的宽度往往可以直接整除,而手机的高度(或平板的宽度)则不一定可以被整除,如果以手机高度(或平板宽度)作为density的计算因子,最后得出的density值可能会有小数。 但是今日头条给出的方法,做除法后结果会取整,从而会影响精度。
  • 计算状态栏和导航栏的高度会出错
    由于使用的是 Application#getResources,这会导致最后计算状态栏高度使用的是修改过后的 density。
  • 跳出本应用后再返回,适配会失效
    比如在应用内启动相机拍照(如上传头像),拍完照返回应用时,头条适配会失效。

5、优化

/**
 * 头条适配方案优化版
 */
public class ScreenAdapterUtil {

    private static float appDensity;
    private static float appScaledDensity;
    private static DisplayMetrics appDisplayMetrics;
    private static int barHeight;

    public static void setDensity(@NonNull Application application) {
        //获取application的DisplayMetrics
        appDisplayMetrics = application.getResources().getDisplayMetrics();
        //获取状态栏高度
        barHeight = ScreenUtil.getStatusBarHeight(application);//改变density前,提前获取状态栏的高度

        if (appDensity == 0) {
            //初始化的时候赋值
            appDensity = appDisplayMetrics.density;
            appScaledDensity = appDisplayMetrics.scaledDensity;

            //添加字体变化的监听
            application.registerComponentCallbacks(new ComponentCallbacks() {
                @Override
                public void onConfigurationChanged(Configuration newConfig) {
                    //字体改变后,将appScaledDensity重新赋值
                    if (newConfig != null && newConfig.fontScale > 0) {
                        appScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
                    }
                }

                @Override
                public void onLowMemory() {
                }
            });
        }
    }

    //此方法在BaseActivity中做初始化(如果不封装BaseActivity的话,直接用下面那个方法就好)
    public static void setDefault(Activity activity) {
        setAppOrientation(activity, ScreenUtil.WIDTH);
    }

    //此方法用于在某一个Activity里面更改适配的方向
    public static void setOrientation(Activity activity, String orientation) {
        setAppOrientation(activity, orientation);
    }

    /**
     * targetDensity
     * targetScaledDensity
     * targetDensityDpi
     * 这三个参数是统一修改过后的值
     * <p>
     * orientation:方向值,传入width或height
     */
    private static void setAppOrientation(@Nullable Activity activity, String orientation) {

        float targetDensity;

        if (orientation.equals("height")) {
            targetDensity = (appDisplayMetrics.heightPixels - barHeight) / 667f;//此处的667f替换为你的实际值
        } else {
            targetDensity = appDisplayMetrics.widthPixels / 360f;//此处的360f替换为你的实际值
        }

        float targetScaledDensity = targetDensity * (appScaledDensity / appDensity);
        int targetDensityDpi = (int) (160 * targetDensity);

        /**
         *
         * 最后在这里将修改过后的值赋给系统参数
         *
         * 只修改Activity的density值
         */
        DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
        activityDisplayMetrics.density = targetDensity;
        activityDisplayMetrics.scaledDensity = targetScaledDensity;
        activityDisplayMetrics.densityDpi = targetDensityDpi;
    }

}

在这个类的初始化方法里面默认的以 宽度 来作为基准(这是在Activity中设置的方法,存在于此Activity下的fragment,dialog和PopupWindow都会受到此效果的影响,也就是说,在Activity中设置一次之后,Activity下的其他子View都无需再设置一次)。

优化后的使用方式:
在BaseApplication中初始化ScreenAdapterUtil

public class BaseApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        ScreenAdapterUtil.setDensity(this);
    }
}

在BaseActivity(所有需要适配的Activity的基类)的onCreate()中,setContentView()之前使用鲜面这行代码:

//此方法默认以width作为基准方向
ScreenAdapterUtil.setDefault(this);

或者

//此方法指定以高度作为基准方向
ScreenAdapterUtil.setOrientation(this,ScreenUtil.HEIGHT);

我们一般来说做适配都是以手机的宽度为基准,但是一个app里面避免不了偶尔一两个页面是按照高度为基准(就是内容纵向填充全屏的页面)做适配的。但是上述方法只能保证一个方向,ScreenAdapterUtil.setOrientation 这个方法可以自由的切换适配的基准方向。

针对跳出应用后再返回适配失效的问题,粗暴的解决办法是在BaseActivity(所有需要适配的Activity的基类)的onResume()中 再次调用 ScreenAdapterUtil.setDefault(this)。比较优雅的方案是,在Application中通过 ActivityLifecycleCallbacks 来判断是否已经跳出又返回,有选择地在 onResume()调用 ScreenAdapterUtil.setDefault(this),而不是每次都调用。

附文资料

1、ScreenUtils.java代码

public class ScreenUtil {

    public final static String WIDTH = "width";
    public final static String HEIGHT = "height";

    /**
     * 获取屏幕宽度
     *
     * @param context Context
     * @return 屏幕宽度(px)
     */
    public static int getScreenWidth(Context context) {
        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        Point point = new Point();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            wm.getDefaultDisplay().getRealSize(point);
        } else {
            wm.getDefaultDisplay().getSize(point);
        }
        return point.x;
    }

    /**
     * 获取屏幕高度
     *
     * @param context Context
     * @return 屏幕高度(px)
     */
    public static int getScreenHeight(Context context) {
        WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        Point point = new Point();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            wm.getDefaultDisplay().getRealSize(point);
        } else {
            wm.getDefaultDisplay().getSize(point);
        }
        return point.y;
    }

    /**
     * 获取手机状态栏高度
     */
    public static int getStatusBarHeight(Context context) {
        int result = 0;
        int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            result = context.getResources().getDimensionPixelSize(resourceId);
        }
        return result;
    }

    /**
     * 通过反射获取手机状态栏高度
     */
    public static int getStatusBarHeightByReflect(Context context) {
        Class<?> c = null;
        Object obj = null;
        Field field = null;
        int x = 0, statusBarHeight = 0;
        try {
            c = Class.forName("com.android.internal.R$dimen");
            obj = c.newInstance();
            field = c.getField("status_bar_height");
            x = Integer.parseInt(field.get(obj).toString());
            statusBarHeight = context.getResources().getDimensionPixelSize(x);
        } catch (Exception e) {
            return 50;
        }
        return statusBarHeight == 0 ? 50 : statusBarHeight;
    }

}

2、同一个布局文件中,我使用线性布局和相对布局都可以达成目的,那么如何抉择?
使用相对布局,很有可能出现 第一次测量"不满意"的情况,从而会测量第二次。如果两者都可以达成目的,并且两者的布局层级相同,并且线性布局中没有使用到权重(权重可能也会触发第二次测量),此时,优先使用线性布局。 除此之外,都选择相对布局。

Top