Android屏幕适配的前世今生(一)

/ Android必知必会 / 没有评论 / 336浏览

自从工作以来,一直以屏幕适配斗智斗勇。由与Android碎片化严重,存在各种奇奇怪怪的分辨率,为了开发高质量的app,必然需要尽肯能的适配多机型,其中屏幕适配就是其中一项。经过多年的磨练,学习到了一些奇技淫巧。借此机会,做个总结,也算是给自己一个交代,如果顺便能帮到一些同学,那就再好不过了。计划分成两篇文章来彻底阐述屏幕适配的前世今生。本篇先介绍下为什么需要适配,以及为下篇怎么适配提供些预备知识。

为什么需要屏幕适配?

我们以实际开发为例:

一般情况下,设计mm只会提供一套设计稿并且还是以4.7英寸的Iphone6为标准。尺寸一般是750x1334

假设我们有小米9Pixel xl两款手机,具体屏幕参数如下表:

机型分辨率屏幕尺寸ppidpi
小米91080x23406.39英寸403440
Pixel xl1440x25605.5英寸534560

设计稿中有个View,宽度为375px,视觉效果为手机宽度的一半。

如果我们直接在布局文件中设计宽度为375px,那么:

小米9上,实际视觉效果仅为手机宽度的35%(375/1080);

Pixel xl上,实际效果更小,仅为手机宽度的26%(375/1440)。

显然实际效果没有还原设计稿要求,设计mm肯定会不开心了.

这时候肯定有小伙伴跳出来说:

怎么能直接用px作为单位呢,应该用Android爸爸提供的dp啊.

  1. 什么是dp

借着这个话题,我们继续来聊聊什么是dp,顺便也说说上表中的ppidpi是什么.

ppi是指每英寸像素点个数。通过对角线上像素点个数除以对角线长度(手机尺寸)得到。

小米9为例,手机尺寸是6.39英寸ppi = 403

计算公式

我们也可以得出结论,ppi是个物理值,与手机的分辨率和屏幕尺寸有关。

dpi是指屏幕像素密度,网上很多人说它也是~~每英寸像素点个数~~,实际上我并不想纠结它的定义,可以把dpi理解成人为通过软件设置的值

Android系统中会在build.prop文件中定义这个值,我们可以通过getprop ro.sf.lcd_density命令获取到这个值

getprop

同样Android也提供相应的api来获取这个值

resources.displayMetrics.densityDpi

Android框架的很多行为都是利用这个数值,比较常见的:

这样说可能比较抽象,我们一个个举例子:

Android定义了标准的dpi及对应的屏幕像素密度等级,当dpi处于某个区间时,就会优先取对应文件夹下的资源,如下表:

dpi数值120160240320480640
屏幕像素密度等级ldpimdpihdpixhdpixxhdpixxxhdpi
缩放比0.75x1.0x 基准1.5x2.0x3.0x4.0x

假设我们资源目录结构如下

res/
          drawable-xxxhdpi/
            awesome-image.png  //尺寸 50x50
          drawable-xxhdpi/
            awesome-image.png  //尺寸 40x40
          drawable-xhdpi/
            awesome-image.png  //尺寸 30x30
          drawable-hdpi/
            awesome-image.png  //尺寸 20x20
          drawable-mdpi/
            awesome-image.png  //尺寸 10x10

布局文件中ImageView定义如下:

<ImageView
            android:id="@+id/mImage"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/awesome-image" />

小米9

我们在小米9手机上运行时,这个ImageView宽高是多少呢?

由与小米9手机的dpi440,处于320~480之间,那么我们的应用就会优先获取限定符为xxhdpi文件夹的资源。 又因为标准xxhpid像素密度dpi应该是480

因此在小米9手机上,这个ImageView的宽高为37x37,计算公式为:(37= 440/480 * 40)

如果我们把drawable-xxhdpi文件夹中的awesome-image.png删除,此时应用会从像素密度等级最高的xxxhdpi依次向下寻找。

由与drawable-xxxhdpi文件夹中存在该资源,因此在小米9手机上,此时ImageView的宽高为35x35,计算公式为:(35= 440/640* 50)

Pixel xl

同样的场景在Pixel xl上呢?

由于Pixel xl手机的dpi560,处于480~640之间,那么我们的应用就会优先获取限定符为xxxhdpi文件夹的资源。 又因为标准xxxhpid像素密度dpi应该是640

因此在Pixel xl手机上,ImageView的宽高为44x44,计算公式为:(44= 560/640 * 50)

如果我们把drawable-xxxhdpi文件夹中的awesome-image.png删除,此时应用会从像素密度等级最高的xxhdpi依次向下寻找。

由于drawable-xxhdpi文件夹中存在该资源,因此在Pixel xl手机上,此时ImageView的宽高为47x47,计算公式为:(47= 560/480 * 40)

我们还剩下两个疑问,dp是什么以及dp如何转为px

dp可以理解为Android设计者为了开发人员更好地做屏幕适配工作,而设计的一种虚拟像素单位。同样sp也一样。

1dp在不同dpi的手机上会表示不同大小的px,比如

在小米9上 1dp = 440/160 * 1px = 3px,

在Pixel xl上 1dp = 560/160 * 1px = 4px 。

可以发现在不同手机上(实际是不同dpi)1dp代表的像素点个数是不同的。这也是为什么有些小伙伴说布局文件中不要使用px要使用dp,以做屏幕适配。

可能有小伙伴质疑或者懵逼,凭什么是按照上面的公式计算,你是不是瞎xx编的。

好嘛,我们来看看Android源码


        // step1 ViewGroup.LayoutParams#setBaseAttributes
        protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) {
            // 进入step2
            width = a.getLayoutDimension(widthAttr, "layout_width");
            height = a.getLayoutDimension(heightAttr, "layout_height");
        }
        // step2 TypedArray#getLayoutDimension
        public int getLayoutDimension(@StyleableRes int index, String name) {
             .......省略无关代码    
             else if (type == TypedValue.TYPE_DIMENSION) {
                // 进入step3
                return TypedValue.complexToDimensionPixelSize(data[index + STYLE_DATA], mMetrics);
            } 
             .......省略无关代码 
        }        
        
        // step3 TypedValue#complexToDimensionPixelSize
        public static int complexToDimensionPixelSize(int data,
                DisplayMetrics metrics)
        {
            final float value = complexToFloat(data);
            // 进入step4
            final float f = applyDimension(
                    (data>>COMPLEX_UNIT_SHIFT)&COMPLEX_UNIT_MASK,
                    value,
                    metrics);
            final int res = (int) ((f >= 0) ? (f + 0.5f) : (f - 0.5f));
            if (res != 0) return res;
            if (value == 0) return 0;
            if (value > 0) return 1;
            return -1;
        }
        
        // step4 TypedValue#applyDimension
        public static float applyDimension(int unit, float value,
                                           DisplayMetrics metrics)
        {
            switch (unit) {
            case COMPLEX_UNIT_PX:
                return value;
            case COMPLEX_UNIT_DIP:
                return value * metrics.density;
            case COMPLEX_UNIT_SP:
                return value * metrics.scaledDensity;
            case COMPLEX_UNIT_PT:
                return value * metrics.xdpi * (1.0f/72);
            case COMPLEX_UNIT_IN:
                return value * metrics.xdpi;
            case COMPLEX_UNIT_MM:
                return value * metrics.xdpi * (1.0f/25.4f);
            }
            return 0;
        }

我们在xml中设置layout_width属性,属性值最终会映射到ViewGroup.LayoutParamswidth字段,具体数值通过TypedValue#applyDimension方法计算得到。

如果我们设置的单位是dp,会走到如下分支:

return value * metrics.density

显然这里的转换与DisplayMetricsdensity字段有关,我们继续看下这个类中几个关键字段的赋值。

    // DisplayMetrics#setToDefaults
    public static int DENSITY_DEVICE = getDeviceDensity();
    public static final int DENSITY_DEFAULT = DENSITY_MEDIUM;
    public static final int DENSITY_MEDIUM = 160;        
    public void setToDefaults() {
        density =  DENSITY_DEVICE / (float) DENSITY_DEFAULT;
        densityDpi =  DENSITY_DEVICE;
        scaledDensity = density;
    }
    private static int getDeviceDensity() {
        // qemu.sf.lcd_density can be used to override ro.sf.lcd_density
        // when running in the emulator, allowing for dynamic configurations.
        // The reason for this is that ro.sf.lcd_density is write-once and is
        // set by the init process when it parses build.prop before anything else.
        return SystemProperties.getInt("qemu.sf.lcd_density",
                SystemProperties.getInt("ro.sf.lcd_density", DENSITY_DEFAULT));
    }    

从源码可以看出,getDeviceDensity()就是获取build.prop中的厂商设置的dpi,三个关键字段的赋值也很清晰,如下:

densityDpi = build.prop文件中ro.sf.lcd_density的值
density = densityDpi/160
scaledDensity = density

再回到TypedValue#applyDimension方法中,很容易得出dppx的转换公式,顺便无意间也参透了sppx转换公式:

density = densityDpi/160
scaledDensity = density
px = density * dp 
px = scaledDensity * sp

如果不调整手机设置中的字体缩放比例,默认情况下 density = scaledDensity

这里需要注意的是applyDimension方法返回的是float类型,而像素是int类型,在TypedValue#complexToDimensionPixelSize中有floatint的操作:

       final int res = (int) ((f >= 0) ? (f + 0.5f) : (f - 0.5f));

其实就是简单的四舍五入。(插个题外话:我猜你项目也有UIUtils工具类,里面有dp2px方法,而方法的内容也就是类似上面那样)

对照下上面的逻辑,我们再验证之前的转换结果是否正确:

在小米9上 1dp = 440/160 * 1px = 3px,

在Pixel xl上 1dp = 560/160 * 1px = 4px 

是不是千真万确,我没骗你。

回到文章开头的例子:

设计稿尺寸为750*1334,其中有个View,宽度为375px,视觉效果为手机宽度的一半.

Iphone6理论上来讲,density是2,因此375px转换为dp为375/2=187.5dp

我们尝试使用dp作为单位,在小米9和Pixel xl上实际是什么样子呢。

// 怕你忘了公式,再来一次:
px = densityDpi/160 * dp

小米9516px = 440/160 * 187.5px,实际视觉效果仅为手机宽度的48%(516/1080);

Pixel xl656px = 560/160 * 187.5px,实际视觉效果仅为手机宽度的46%(656/1440);

是不是都比较接近50%,最起码比使用px的效果要好得多。

但还是不可避免的存在误差,设计mm肯定还是会不开心。

那我们到底怎么做才会完美还原设计稿的视觉效果呢,因为篇幅原因,下篇再来揭晓。