Android 屏幕分辨率适配

4,499 阅读14分钟

前言

Android屏幕分辨率千奇百怪,怎么让app在不同的分辨率的设备上“看起来一样”呢?
你也许还有以下疑惑:

  • px、dp、sp区别与作用
  • mipmap和drawable区别与作用
  • mdpi hdpi xhdpi的图片资源有什么区别
  • 如何适配不同密度下的图片资源
  • 不同分辨率的设备如何适配宽度
  • dpi是怎么确定的

这篇文章将会针对以上问题一一解答。

基本单位

px

Pixels 我们看到屏幕上的图像由一个个像素组成,像素里包含色彩信息。
如常说的手机分辨率:1080 x 1920 指的是手机宽度可展示1080像素,高度可展示1920像素。

ppi

Pixels Per Inch 每英寸长度所具有的像素个数,单位面积内像素越多,图像显示越清晰。
ppi一般用在显示器、手机、平板等描述屏幕精细度。

dpi

Dots Per Inch 每英寸长度所具有的点数。
dpi一般用来描述打印(书本、杂志、电报)的精细度

dp/dip

density-independent pixels (device-independent pixels 我查了一下,官网更多时候使用前者,有的时候也显示后者),dip是缩写,也可以更简单些称作dp。该单位的目的是屏蔽不同设备密度差异,后面细说。

sp

Scalable pixels 用于设置字体,在用户更改字体大小时候会适配。

简单例子

澄清了基本概念,我们现在从一个例子开始说明以上单位之间的区别与联系。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/big"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <View
        android:layout_gravity="center"
        android:background="@color/green"
        android:layout_width="200px"
        android:layout_height="200px">
    </View>

</FrameLayout>

布局文件里有个View,长宽都是200px,分别在分辨率为480(宽)x800(高)简称A设备、 1080(宽)x1920(高)简称B设备,效果如下:

image.png


左边是A设备,右边是B设备。问题出来了,同样长宽都是200px,为啥A设备显示很大,B设备显示很小呢?你可能会说B设备的横向分辨率1080比A设备的480大,所以在B设备上看起来比较小。来看看A、B设备横向到底是多少英寸,怎么来计算呢?这时候就需要用到ppi了,既然知道横向的像素点个数,也知道每英寸能容纳的像素点,当然可以得知横向的尺寸了。

DisplayMetrics.java
    /**
     * The exact physical pixels per inch of the screen in the X dimension.
     */
    public float xdpi;
    /**
     * The exact physical pixels per inch of the screen in the Y dimension.
     */
    public float ydpi;

其中一种方式获取DisplayMetrics对象:

DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
A设备宽度尺寸:480(px)/240(ppi)=2inch
B设备宽度尺寸:1080(px)/420(ppi)=2.5inch
可以看出,A、B设备尺寸差别不大。A设备ppi=240 B设备ppi=420,明显地看出B设备单位长度上比A>设备能够容纳更多的像素,因此同样的200px,B设备只需要较小的尺寸就能够显示,因此在B设备上的 >view看起来比A设备小很多。
知道了问题的原因,然而显示的效果却不能接受。
我们想要的效果是:同一大小的view在不同的设备上“看起来一样大”

我们总不能自己判断每个设备的ppi,然后计算实际需要多少像素,再动态设置view的大小吧,那layout里的静态布局大小就无法动态更改适应了。想当然的能有一个统一的地方替我们转换,没错!Android系统已经帮我们实现了转换。接下来就是dpi、dp出场了。

引入dpi、dp

Android系统使用dpi来描述屏幕的密度,使用dp来描述密度与像素的关系。
A设备dpi=240
B设备dpi=420
Android系统最终识别的单位是px,怎么将dpi和px关联起来呢?,答案是dp。
Android规定当dpi=160时,1dp=1px,当dpi=240时,1dp=1.5px,依此类推,并且给各个范围的dpi取了简易的名字加以直观的识别,如120<dpi<=160,称作为mdpi,120<dpi<=240 称作hdpi,最终形成如下规则:

ldpi(value <= 120 dpi)
mdpi(120 dpi < value <= 160 dpi)
hdpi(160 dpi < value <= 240 dpi)
xhdpi(240 dpi < value <= 320 dpi)
xxhdpi(320 dpi < value <= 480 dpi)
xxxhdpi(480 dpi < value <= 640 dpi)

现在知道了dp能够在不同dpi设备上对应不同px,相当于中间转换层,我们只需要将view长宽单位设置为合适的dp,就无需关注设备之间密度差异,系统会帮我们完成dp-px转换。将我们之前的例子稍微更改,再看看效果验证一下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/big"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <View
        android:layout_gravity="center"
        android:background="@color/green"
        android:layout_width="200dp"
        android:layout_height="200dp">
    </View>

</FrameLayout>

image.png


这里看起来还是不一样呢?[注1]
综上所述,dp作为中间单位为我们屏蔽了不同密度设备差异,这也是为啥dp/dip叫做“设备(密度)无关像素”的原因。

mipmap图片资源文件

通过上面对dp的了解,我们知道在设定view大小、间距时使用dp能最大限度地屏蔽设备密度之间的差异。可能你就会问了,那bitmap展示的时候如何适配不同密度的设备呢?

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(bitmap.getWidth(), bitmap.getHeight());
    }

    private void init() {
        String path = Environment.getExternalStorageDirectory() + "/Download/photo1.jpg";
        bitmap = BitmapFactory.decodeFile(path);
        paint = new Paint();
        paint.setAntiAlias(true);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        Rect src = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
        RectF rectF = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
        canvas.drawBitmap(bitmap, src, rectF, paint);
    }

自定义view从磁盘上加载一张图片,并将之显示在view上,view的大小决定于bitmap大小。依旧以上述A、B设备为例,展示结果如下:

image.png


左边是A设备,右边是B设备。
明显地看出,在A设备显示比B设备大很多,实际上和我们之前用px来描述view的大小原理是一样的,bitmap的宽、高都是px在描述,而bitmap决定了view的宽、高,最终导致A设备和B设备上的view大小(宽、高像素)是一样的,而它们屏幕密度又不相同,因此产生了差异。
那不会每次都需要我们自己根据屏幕密度来转换bitmap大小吧?幸运的是,Android已经为我们考虑到了。

image.png


如上图,在Android Studio创建工程的时候,默认在res下创建mipmap目录,这些mipmap目录按照密度分为mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi,看起来都在“一个“mipmap”目录下,实际上分为不同的目录:

image.png

生成不同密度的目录有什么作用?
A设备dpi=240,根据dpi范围,属于hdpi
B设备dpi=420,根据dpi范围,属于xxhdpi
图片原始尺寸:photo1.jpg(宽高 172px-172px)
当我们想要在不同密度设备上显示同一张图片并且想要“看起来一样大时”。假设设计的时候以hdpi为准,放置photo1.jpg为172*172,那么根据计算规则在xxhdpi上需要设置photo1.jpg为:

scale = 480 / 240 = 2
width = 172 * 2 = 344
height = 172 * 2= 344
注:这里为什么要放大?可以这么理解,因为B设备密度大,通常来说密度越大单位尺寸内需要的像素越多,假设A设备上172*172占据1inch面积,那么为了能够在B设备上填充满相同的面积需要更多的像素,因此B设备上的图片分辨率应该更大(这里说的通常是因为真正决定设备单位尺寸内容纳的像素个数的因素是ppi,有些设备dpi比较大,但是ppi反而小)

现在hdpi和xxhdpi目录下分别存放了同名图片:photo1.jpg,只是大小不同。当程序运行的时候:

A设备发现自己密度属于hdpi,它会直接到hdpi下寻找对应的photo1.jpg并显示
B设备发现自己密度属于xxhdpi,它会直接到xxhdpi下寻找对应的photo1.jpg并显示

来看看效果:

image.png

左边A设备,右边B设备 针对不同的密度设计不同的图片大小,最大限度保证了同一图片在不同密度设备上表现“看起来差不多大”。
来看看A、B设备上图片占内存大小:

A设备 172 * 172 * 4 = 118336 ≈ 116k
B设备 344 * 344 * 4 = 473344 ≈ 462k
注:解析bitmap时,默认inPreferredConfig=ARGB_8888,也就是每个像素有4个字节来存储

说明在B设备上显示photo1.jpg需要更多的内存。
上边只是列举了hdpi、xxhdipi,同理对于mdpi、xhdpi、xxxhdpi根据规则放入相应大小的图片,程序会根据不同的设备密度从对应的mipmap文件夹下加载资源。如此一来,我们无需关注bitmap在不同密度设备上显示问题了。

图片资源文件的加载

在mipmap各个文件夹下都放置同一套资源的不同尺寸文件似乎有点太占apk大小,能否只放某个密度下图片,其余的靠系统自己适配呢?
现在只保留hdpi下的photo1.jpg图片,看看在A、B设备上运行情况如何:

image.png

看起来和上张图差不多,说明系统会帮我们适配B设备上的图片。
再来看看A、B设备上图片占内存大小:
先看A设备:

image.png


再看B设备:

image.png

A设备 172 * 172 * 4 = 118336 ≈ 116k
B设备 301 * 301 * 4 = 362404 ≈ 354k

对比photo1.jpg 分别放在hdpi、xxhdpi和只放在hdpi下可以看出:B设备上图片所占内存变小了。为什么呢?接下来从源码里寻找答案。

构造Bitmap

Bitmap bitmap = BitmapFactory.decodeResource(getContext().getResources(), R.mipmap.photo1);

A、B设备同样加载hdpi/photo1.jpg,返回的bitmap大小不相同,我们从这方法开始一探究竟。

    public static Bitmap decodeResource(Resources res, int id, BitmapFactory.Options opts) {
        validate(opts);
        Bitmap bm = null;
        InputStream is = null;

        try {
            
            final TypedValue value = new TypedValue();
            //根据资源id,构造Value对象,这里面需要关注的变量:density
            is = res.openRawResource(id, value);
            bm = decodeResourceStream(res, value, is, null, opts);
        } catch (Exception e) {
            /*  do nothing.
                If the exception happened on open, bm will be null.
                If it happened on close, bm is still valid.
            */
        } finally {
            try {
                if (is != null) is.close();
            } catch (IOException e) {
                // Ignore
            }
        }

        if (bm == null && opts != null && opts.inBitmap != null) {
            throw new IllegalArgumentException("Problem decoding into existing bitmap");
        }

        return bm;
    }
    public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
                                              @Nullable InputStream is, @Nullable Rect pad, @Nullable BitmapFactory.Options opts) {
        validate(opts);
        if (opts == null) {
            opts = new BitmapFactory.Options();
        }
        
        if (opts.inDensity == 0 && value != null) {
            //通过value里的density给options里的inDensity赋值
            final int density = value.density;
            if (density == TypedValue.DENSITY_DEFAULT) {
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }

        if (opts.inTargetDensity == 0 && res != null) {
            //获取设备屏幕密度并赋予opts.inTargetDensity
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }

        //确定option inDensity、inTargetDensity 后传入jni层加载bitmap
        return decodeStream(is, pad, opts);
    }

上面涉及到的关键点是density,分别是TypedValue的density和Options的density。
先来看看TypedValue density:

    /**
     * If the Value came from a resource, this holds the corresponding pixel density.
     * */
    public int density;

简单解释:表示该资源从哪个密度文件夹下取的;比如A、B设备取hdpi下的photo1.jpg,那么此时density=240

再来看看Options density

* The pixel density to use for the bitmap.  This will always result
* in the returned bitmap having a density set for it

public int inDensity;

* The pixel density of the destination this bitmap will be drawn to.
* This is used in conjunction with {@link #inDensity} and
* {@link #inScaled} to determine if and how to scale the bitmap before
* returning it.

public int inTargetDensity;

简单解释:inDensity表示该资源来源于哪个密度的文件夹,该值从TypedValue获取; inTargetDensity表示该资源将要显示在哪个密度的设备上。在构造Bitmap时,会根据inDensity与inTargetDensity决定Bitmap放大缩写的倍数。
计算公式如下:
needSize = (int)(size * ((float)inTargetDensity / inDensity) + 0.5) (四舍五入)

现在分析B设备加载hdpi/photo1.jpg如何做的:

1、hdpi密度是240 因此Options.inDesnity = 240
2、B设备密度是420 因此Options.inTargetDensity = 420;
3、B设备返回bitmap大小=172 * 420 / 240 = 301px

和我们之前调试的结果一致。

Density匹配规则

B设备是怎么决定使用hdpi下的图片资源呢?
根据实验(尝试找了源码,没怎么看懂,因此只是做了实验,可能在不同密度设备上找寻规则不一样):B设备先找属于自己密度范围文件夹下的图片,B设备属于xxhdpi,先查看xxhdpi有没有photo1.jpg,如果没有则往更高的密度找,比它高的密度是xxxhdpi,还是没有,则往低密度找,找xhdpi,没有再找hdpi,找到了则返回构造好的TypedValue,剩下的就是我们前面分析的。
既然我们只想放某个密度下的一份切图,该放哪个密度下呢?从系统寻找规则看,更推荐放置在更高密度下的,因为如果放在低密度下,那么当运行在高密度设备上时,图片会进行放大,可能导致不清晰。我一般习惯放在xxhdpi下。

drawable和mipmap不同密度文件夹

Android Studio默认创建了不同密度的mipmap文件夹,默认放置了ic_launcher.png。我们普通的切图该放drawable还是mipmap下呢?对于这个问题网上也是众说纷纭,实际上对于我们来说,关注的重点是图片放在drawable或者mipmap,加载出来bitmap是否有差异,如果没有差异放在哪就看习惯了。通过实践,普通的切图放drawable和mipmap下加载出来的bitmap是没有差异的,只不过用drawable的话需要自己创建不同密度的文件夹。我习惯于放在drawable下(启动图标logo还是放在mipmap下)。

屏幕宽度适配

前边**[注1]**留了个问题,我们使用dp来表示view的大小了,为啥两个看起来还是有些差距?下面我们更加直观地看一个例子。
A设备dpi=240 密度1.5 分辨率(宽高px):480 * 800
B设备dpi=420 密度2.625 分辨率(宽高px):1080 * 1794
换算成dp
A设备分辨率:320dp * 533dp
B设备分辨率:411dp * 683dp
依旧是上边的例子:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/big"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <View
        android:id="@+id/iv"
        android:background="@color/green"
        android:layout_gravity="center"
        android:layout_width="320dp"
        android:layout_height="320dp"/>

</FrameLayout>

将view宽高分别设置为320dp,看看效果:

image.png

左边A设备,右边B设备
可以看出同样的320dp大小,A设备铺满了屏幕,而B设备没有。这效果显然是不能接受的,Android考虑到不同设备宽高不同,推出了"宽高限定符"。以A、B设备为例:
在res文件夹下创建文件夹:

values-800x480
values-1794x1080

假设设计师出图是按照800x480,那么我们创建dimen文件的时候

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="px1">1px</dimen>
    <dimen name="px2">1px</dimen>
    ...
    <dimen name="px100">100px</dimen>
    <dimen name="px101">101px</dimen>
</resources>

该文件放在values-800x480文件夹下。
根据分辨率比例算出1794x1080的dimen值

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="px1">2.24px</dimen>
    <dimen name="px2">4.48px</dimen>
    ...
    <dimen name="px100">224px</dimen>
    <dimen name="px101">226.24px</dimen>
</resources>

这样子,A、B设备加载资源的时候使用对应分辨率限定符下的px,如果找不到再找默认值,可以在一定程度上解决屏幕宽高碎片化适配问题。
但是这样子的限定比较严格,需要测试各种分辨率,后来Android又推出了"smallest-width"简称最小宽度限制。
A设备宽320dp
B设备宽411dp
假设设计师切图标准屏幕宽是320dp(A设备),那么可以定义如下dimen.xml文件

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="dp1">1dp</dimen>
    <dimen name="dp2">2dp</dimen>
    <dimen name="dp320">320dp</dimen>
</resources>

该文件放在values-sw320dp文件夹下
根据规则,计算B设备dimen.xml

scale = targetWidth/baseWidth=411/320≈1.28
value = scale * baseValue
得出:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="dp1">1dp</dimen>
    <dimen name="dp2">3dp</dimen>
    <dimen name="dp320">410dp</dimen>
</resources>

现在我们继续来看之前的view

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/big"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <View
        android:id="@+id/iv"
        android:background="@color/green"
        android:layout_gravity="center"
        android:layout_width="@dimen/dp320"
        android:layout_height="@dimen/dp320"/>

</FrameLayout>

通过对dimen引用,A设备寻找和自己宽度一样的dimen文件,找到values-sw320dp,dp320=320dp。B设备寻找和自己宽度一样的dimen文件,找到values-sw411dp,dp320=410dp。这样子同样的dp320,得出不同的值,就适配了屏幕宽度不同的问题。
看看效果:

image.png

这次B设备也铺满了屏宽。

1、如果B设备找不到values-sw411dp,那么会继续往下寻找(比自己宽度小的),比如找到values-sw390dp,就会使用里面的值
2、为什么高度没有限定呢?因为对于竖直方向上来说,我们是可以设计为滚动模式的,因此对于高度的适配没那么敏感

综上,为了适配不同屏幕大小,推荐使用dp+smallest-width。

如何获取dpi

DisplayMetrics.java
    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));
    }

获取设备dpi最终都是从这方法获取的,实际上就是读取系统的配置文件。因此我们也可以通过adb shell 获取:

HWTAS:/ $ wm size                                                                                                                                                                                   
Physical size: 1080x2340
HWTAS:/ $ 
HWTAS:/ $ getprop ro.sf.lcd_density
480
HWTAS:/ $ wm density 
Physical density: 480
HWTAS:/ $ 

可以看出dpi是系统配置好的,当然有些手机是可以设置分辨率的,设置之后我们查看分辨率:

HWTAS:/ $ wm density                                                                                                                                                                                
Physical density: 480
Override density: 320
HWTAS:/ $ 
HWTAS:/ $ 
HWTAS:/ $ wm size                                                                                                                                                                                   
Physical size: 1080x2340
Override size: 720x1560
HWTAS:/ $ 

分辨率变低了,dpi也变小了。