文章

屏幕适配面试题

屏幕适配面试题

屏幕适配基础

屏幕尺寸、屏幕分辨率、屏幕像素密度 (dpi)

屏幕尺寸

屏幕尺寸概念: 手机对角线的物理尺寸 单位: 英寸(inch),1 英寸≈2.54cm
Android 手机常见的尺寸有 5 寸、5.5 寸、6 寸等等 计算公式: inch = 屏幕对角线长度px/dpi

屏幕分辨率

含义:手机在横向、纵向上的像素点数总和

  1. 一般描述成屏幕的 “ 宽 x 高 “=AxB
  2. 含义:屏幕在横向方向(宽度)上有 A 个像素点,在纵向方向(高)有 B 个像素点

Android 手机常见的分辨率:320x480、480x800、720x1280、1080x1920、 1080x2340

屏幕像素密度 dpi(ppi)

dpi 概念:dots per inch , 直接来说就是一英寸多少个像素点。单位是:像素/英寸(px/inch),常见取值 120(px/inch),160 (px/inch) ,240 (px/inch) 。ppi 和 dpi 其实原理是相同的,都是每英寸上的点数,他们属于同义词了;dpi 是屏幕固定的参数不会变了。

假设设备内每英寸有 160 个像素,那么该设备的屏幕像素密度=160dpi

常见的 dpi 及代表的分辨率:
image.png

dpi 如何计算:屏幕尺寸、分辨率、像素密度三者关系

一部手机的分辨率是宽 * 高,屏幕大小是以寸为单位,那么三者的关系是:

dpi = ppi = 屏幕对象线 px/屏幕大小 inch=(√(screenHeight^2 + screenWidth^2)) / 屏幕大小 (inch)

density 密度

density 概念
直接翻译的话貌似叫密度。常见取值 1.5、1.0,现在的手机 (2023) 都在 2.x~3.x 。没有单位。
density 计算公式
密度是 dpi 和 160dpi 的比例:
**density = 当前手机的dpi / 160dpi**

dp(dip) 密度无关像素

  • 含义:density-independent pixel,叫 dp 或 dip,与终端上的实际物理像素点无关。以保证在不同屏幕像素密度的设备上显示相同的效果
  • dp 是像素和密度的比:dp = px / density
  • 在 Android 中,规定以 160dpi(即屏幕分辨率为 320x480)为基准:1dp=1px image.png

sp 独立比例像素

scale-independent pixel,叫 sp 或 sip。Android 开发时用此单位设置文字大小,可根据字体大小首选项进行缩放。推荐使用 12sp、14sp、18sp、22sp 作为字体设置的大小,不推荐使用奇数和小数,容易造成精度的丢失问题;小于 12sp 的字体会太小导致用户看不清

屏幕适配方案

1、dp 原生方案

dp 方案原理

不同的设备,不同的分辨率,1dp 所代表的像素数量是不一样的。

dp 方案没有解决什么问题?

屏幕分辨率为:1920*1080,屏幕尺寸为 5 吋的话,那么 dpi 为 440。假设我们 UI 设计图是按屏幕宽度为 360dp 来设计的,那这样会存在什么问题呢?
在上述设备上,屏幕宽度其实为 1080/(440/160)=392.7dp,也就是屏幕是比设计图要宽的。这种情况下, 即使使用 dp 也是无法在不同设备上显示为同样效果的。 同时还存在部分设备屏幕宽度不足 360dp,这时就会导致按 360dp 宽度来开发实际显示不全的情况。

而且上述屏幕尺寸、分辨率和像素密度的关系,很多设备并没有按此规则来实现, 因此 dpi 的值非常乱,没有规律可循,从而导致使用 dp 适配效果差强人意。

AutoLayout 方案

2、dimen 基于 px 和 dp 的适配(宽高限定符和 smallestWidth 适配)

基于 dp 的 smallestWidth 适配

原理
这种适配依据的是最小宽度限定符。指的是 Android 会识别屏幕可用高度和宽度的最小尺寸的 dp 值(其实就是手机的宽度值),然后根据识别到的结果去资源文件中寻找对应限定符的文件夹下的资源文件。这种机制和上文提到的宽高限定符适配原理上是一样的,都是系统通过特定的规则来选择对应的文件。

举个例子,小米 5 的 dpi 是 480,横向像素是 1080px,根据 px=dp(dpi/160),横向的 dp 值是 1080/(480/160),也就是 360dp,系统就会去寻找是否存在 value-sw360dp 的文件夹以及对应的资源文件。

image.png

smallestWidth 限定符适配和宽高限定符适配最大的区别在于,有很好的容错机制,如果没有 value-sw360dp 文件夹,系统会向下寻找,比如离 360dp 最近的只有 value-sw350dp,那么 Android 就会选择 value-sw350dp 文件夹下面的资源文件。这个特性就完美的解决了上文提到的宽高限定符的容错问题。
缺点

  • 侵入性强
  • Android 私人订制的原因,宽度方面参差不齐,不可能适配所有的手机。
  • 项目中增加了 N 个文件夹,上拉下拉查看文件非常不方便:想看 string 或者 color 资源文件需要拉很多再能到达。
  • 通过宽度限定符就近查找的原理,可以看出来匹配出来的大小不够准确。
  • 是在 Android 3.2 以后引入的,Google 的本意是用它来适配平板的布局文件(但是实际上显然用于 dimens 适配的效果更好),不过目前 SPX 所有的项目应该最低支持版本应该都是 5.1 了,所以这问题其实也不重要了。

基于 px 的 smallestWidth 适配

原理
根据市面上手机分辨率的占比分析,我们选定一个占比例值大的(比如 1280_720)设定为一个基准,然后其他分辨率根据这个基准做适配。
基准的意思(比如 320_480 的分辨率为基准)是:
宽为 320,将任何分辨率的宽度分为 320 份,取值为 x1 到 x320
长为 480,将任何分辨率的高度分为 480 份,取值为 y1 到 y480

例如对于 800 * 480 的分辨率设备来讲,需要在项目中 values-800x480 目录下的 dimens.xml 文件中的如下设置(当然了,可以通过工具自动生成):

1
2
3
4
5
6
<resources>
<dimen name="x1">1.5px</dimen>
<dimen name="x2">3.0px</dimen>
<dimen name="x3">4.5px</dimen>
<dimen name="x4">6.0px</dimen>
<dimen name="x5">7.5px</dimen></pre>

可以看到 x1 = 480 / 基准 = 480 / 320 = 1.5 ; 它的意思就是同样的 1px,在 320/480 分辨率的手机上是 1px,在 480/800 的分辨率的手机上就是 1 1.5px,px 会根据我们指定的不同 values 文件夹自动适配为合适的大小。
image.png

缺点

  • 侵入性强
  • 需要精准命中资源文件才能适配,比如 1920x1080 的手机就一定要找到 1920x1080 的限定符,否则就只能用统一的默认的 dimens 文件了。而使用默认的尺寸的话,UI 就很可能变形,简单说,就是容错机制很差。
  • Android 不同分辨率的手机实在太多了,可能你说主流就可以,的确小公司主流就可以,淘宝这种 App 肯定不能只适配主流手机。控件在设计图上显示的大小以及控件之间的间隙在小分辨率和大分辨率手机上天壤之别,你会发现大屏幕手机上控件超级大。可能你会觉得正常,毕竟分辨率不同。但实际效果大的有些夸张。
  • 占据资源大:好几百 KB,甚至多达 1M 或跟多。

3、头条屏幕适配方案

背景

首先来梳理下我们的需求,一般我们设计图都是以固定的尺寸来设计的。比如以分辨率 1920px * 1080px 来设计,以 density 为 3 来标注,也就是屏幕其实是 640dp * 360dp。如果我们想在所有设备上显示完全一致,其实是不现实的,因为屏幕高宽比不是固定的,16:9、4:3 甚至其他宽高比层出不穷,宽高比不同,显示完全一致就不可能了。但是通常下,我们只需要以宽或高一个维度去适配,比如我们 Feed 是上下滑动的,只需要保证在所有设备中宽的维度上显示一致即可,再比如一个不支持上下滑动的页面,那么需要保证在高这个维度上都显示一致,尤其不能存在某些设备上显示不全的情况。同时考虑到现在基本都是以 dp 为单位去做的适配,如果新的方案不支持 dp,那么迁移成本也非常高。

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

原理

修改 density,保证每个设备的屏幕总 dp 宽度不变。
如以 360dp 为基准,然后根据 widthPixels/360dp = density,算出 density,然后设置到系统 DisplayMetrics 去。
核心源码:

1
2
3
4
5
6
7
val baseDp: Float = 360.0F

var displayMetrics = resources.displayMetrics
var widthPixels = displayMetrics.widthPixels
var originalDensity = displayMetrics.density
var realDensity = widthPixels / baseDp
displayMetrics.density = realDensity

自定义 density = 设备真实宽 (单位 px) / 360,接下来只需要把我们计算好的 density 在系统中修改下即可。
同时在 Activity#onCreate 方法中调用下。代码比较简单,也没有涉及到系统非公开 api 的调用,因此理论上不会影响 app 稳定性。

缺点

  • 只能支持以高或宽中的一个作为基准进行适配。
  • 只需要修改一次 density,项目中的所有地方都会自动适配,这个看似解放了双手,减少了很多操作,但是实际上反应了一个缺点,那就是只能一刀切的将整个项目进行适配,但适配范围是不可控的。项目中如果采用了系统控件、三方库控件、等不是我们项目自身设计的控件,这时就会出现和我们项目自身的设计图尺寸差距非常大的问题。

4、头条适配方案改进版本

原理

在头条适配方案的基础上,通过重写 Activity 的 getResources(),重写冷门单位 pt 作为基准单位,它是 Android 中的一个长度单位:表示一个点,是屏幕的物理尺寸,其大小为 1 英寸的 1 / 72,也就是 72pt 等于 1 英寸

优点

  1. 无侵入性 用了这个之后依然可以使用 dp 包括其他任何单位,对从前使用的布局不会造成任何影响,在老项目中开发新功能你可以胆大地加入该适配方案,新项目的话更可以毫不犹豫地采用该适配,并且在关闭该关闭后,pt 效果等同于 dp 哦。
  2. 灵活性高 如果你想要对某个 View 做到不同分辨率的设备下,使其尺寸在适配维度上所占比例一致的话,那么对它使用 pt 单位即可,如果你不想要这样的效果,而是想要更大尺寸的设备显示更多的内容,那么可以像从前那样写 dp、sp 什么的即可,结合这两点,在界面布局上你就可以游刃有余地做到你想要的效果。
  3. 不会影响系统 View 和三方 View 的大小 这点其实在无侵入性中已经表现出来了,由于头条的方案是直接修改 DisplayMetrics#density 的 dp 适配,这样会导致系统 View 尺寸和原先不一致,比如 Dialog、Toast、 尺寸,同样,三方 View 的大小也会和原先效果不一致,这也就是选择 pt 适配的原因之一
  4. 不会失效 因为不论头条的适配还是其他三方库适配,都会存在 DisplayMetrics#density 被还原的情况,需要自己重新设置回去,最显著的就是界面中存在 WebView 的话,由于其初始化的时候会还原 DisplayMetrics#density 的值导致适配失效,当然这点已经有解决方案了,但还会有很多其他情况会还原 DisplayMetrics#density 的值导致适配失效。而我这方案就是为了解决这个痛点,不让 DisplayMetrics 中的值被还原导致适配失效。

缺点

只能适配宽或者高其中一边,但这也是绝大部分适配方案的痛点所在,长和宽只能适配其一,好在大部分公司在采用这些方案去适配是都采用优先适配宽,然后在长上面以滑动形式去进行解决;

代码

Android 最全面的屏幕适配方案

旧项目的适配

  1. 优先采用基于 dp 的 smallestWidth 来适配,对旧项目的影响最小

问题

Android 手机获取屏幕分辨率高度因虚拟导航栏带来的问题

Android 系统在 4.4(KITKAT)版本后,增加了更炫的交互,并且对于标题栏和状态栏也增加了可定制化,于此同时在获取手机分辨率的时候一些旧方法已经不那么好使了。

常用获取屏幕分辨率的方法:

1
2
3
4
5
6
Context context = getApplicationContext();
DisplayMetrics localDisplayMetrics = context.getResources().getDisplayMetrics(); 
// 获取高度
int height = localDisplayMetrics.heightPixels;
// 获取宽度
int width = localDisplayMetrics.widthPixels;

例如,在一部分辨率为 1280_720 带虚拟导航栏的华为手机上,并且手机系统在 4.4 之后,通过上面的方法获取到的手机分辨率为 1184_720,没错,是 1184,难道是系统 api 获取到的值不准确?但其实是你手机的虚拟导航栏搞的鬼,如果这时候你将导航栏隐藏起来,再调用上面的方法就可以获得和手机对应的分辨率一样的值了。

在 4.2 的系统,Google 就更新了获取分辨率的方法,使用新的方法,无论手机的虚拟导航栏或标题栏是否显示隐藏都可以正确获取分辨率

1
2
3
4
5
6
7
8
Context context = getApplicationContext();
DisplayMetrics dm = new DisplayMetrics();
WindowManager windowMgr = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
windowMgr.getDefaultDisplay().getRealMetrics(dm);
// 获取高度
int height = dm.heightPixels;
// 获取宽度
int width = dm.widthPixels;

最终版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static int getScreenWidth(Context context) {
    DisplayMetrics metrics = context.getResources().getDisplayMetrics();
    return metrics.widthPixels;
}

public static int getScreenHeight(Context context) {
    Method dpMethod = null;
    int mScreenHeight;
    int ver = Build.VERSION.SDK_INT;
    DisplayMetrics metrics = context.getResources().getDisplayMetrics();
    WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    Display display = wm.getDefaultDisplay();
    if (ver < 13) {
        mScreenHeight = metrics.heightPixels;
    } else if (ver == 13) {
        try {
            if (dpMethod == null) {
                dpMethod = display.getClass().getMethod("getRealHeight");
            }
            mScreenHeight = (Integer) dpMethod.invoke(display);
        } catch (Exception e) {
            // e.printStackTrace();
            mScreenHeight = metrics.heightPixels;
        }
    } else if (ver > 13 && ver < 17) {
        try {
            if (dpMethod == null) {
                dpMethod = display.getClass().getMethod("getRawHeight");
            }
            mScreenHeight = (Integer) dpMethod.invoke(display);

        } catch (Exception e) {
            // e.printStackTrace();
            mScreenHeight = metrics.heightPixels;
        }
    } else {
        try {
            android.graphics.Point realSize = new android.graphics.Point();
            Display.class.getMethod("getRealSize", android.graphics.Point.class).invoke(display, realSize);
            mScreenHeight = realSize.y;
        } catch (Exception ignored) {
            mScreenHeight = metrics.heightPixels;
        }
    }
    return mScreenHeight;
}
本文由作者按照 CC BY 4.0 进行授权