Android 今日头条屏幕适配方案的原理梳理

前言

最近在项目里面遇到了屏幕适配的问题,UI 要求 APP 在不同手机上展示效果和设计稿保持“像素级”同步,在对比了几种屏幕适配方案之后,选择了基于今日头条的AndroidAutoSize适配方案。

本文主要简单分析其适配原理,以及在实际使用中遇到的一个问题,需要更深入了解原理可以阅读文末参考文献。

正文

UI 给的设计稿一般都是以像素 px 为单位,而在 Android 开发中官方推荐的使用的单位是 dp。

dp 是一个虚拟像素单位,1 dp 约等于中密度屏幕(160dpi;“基准”密度)上的 1 像素。对于其他每个密度,Android 会将此值转换为相应的实际像素数。

—— Android Developer

根据 Android 官方的定义,dp 在屏幕上实际对应的像素 px 计算方式如下:

px = dp * (dpi / 160)

其中 dpi 表示:屏幕每平方英寸有多少像素,可以通过屏幕对角线的像素数 px/屏幕尺寸 inch 计算。

DisplayMetrics.density 字段表示根据当前像素密度指定将 dp 单位转换为像素时所必须使用的缩放系数,即上述方程等价于:

px = dp * (dpi / 160)
   = dp * getResources().getDisplayMetrics().density

这样,在 dpi 为 160 的屏幕上 1dp 占 1px,在 dpi 为 320 的屏幕上占 2px,那么就能保证同一 dp 的在不同 dpi 上占得像素是等比例变化的。

但是,在现实生活中面对千变万化的 Android 屏幕,根据 Jessyan 的文章可知由于每种屏幕宽/高对应的总 dp 数不一定都是相同的,所以即使使用了 dp 作为单位,还是会出现同一 dp 在有些屏幕上刚好占满全屏,在有的屏幕上会无法占满全屏或超出屏幕范围。

density 在每个设备上都是固定的,DPI / 160 = density屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度

  • 设备 1,屏幕宽度为 1080px480DPI,屏幕总 dp 宽度为 1080 / (480 / 160) = 360dp
  • 设备 2,屏幕宽度为 1440px560DPI,屏幕总 dp 宽度为 1440 / (560 / 160) = 411dp

——Jessyan

那么该怎么适配呢,再看一眼上述的公式:

屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度

以适配屏幕宽度为例,要使得 dp 在不同屏幕上对应的像素等比例变化,就要保证屏幕的总 dp 宽度一致,而屏幕的总 px 宽度是物理条件无法更改,那么就只能更改 density

以我们使用的设计稿宽度为 375dp 为例:

在分辨率为 2160*1080、尺寸为 5.99 英寸的屏幕上:

density = 1080px / 375dp = 2.88

而在分辨率为 2400*1176、尺寸为 6.53 英寸的屏幕上:

density = 1176px / 375dp = 3.136

这样就保证了,不管在什么样的屏幕上,375dp 始终都能够占满屏幕宽度,保证了布局在不同大小的屏幕上,在屏幕宽度上的比例一致性,也就解决屏幕适配的问题。

获取状态栏高度的问题

上述的屏幕适配方案使用简单,且侵入小,在使用到项目中之后,除了部分字体等显示需要微调外,其余内容基本上都完美还原了设计稿的内容。

但是在后续使用到状态栏相关代码的时候发现获取到的状态栏高度和实际高度不一致,导致显示异常,而使用Blankj的工具类 BarUtils.getStatusBarHeight()却可以获取到正确的高度。

对比两种代码发现获取状态栏高度的代码逻辑几乎一样:

public static int getStatusBarHeight(Resources resources) {
    int resourceId = resources.getIdentifier("status_bar_height", "dimen", "android");
    return resources.getDimensionPixelSize(resourceId);
}

不同的是,两种方法使用到的 resources 一个是 APP 的,一个是系统的

// 1. 我使用到的 resources,从当前 activity 获取
resources.displayMetrics.density
// 2. Blankj 使用的 resources,从系统获取
Resources.getSystem().displayMetrics.density

通过分别打印这两种 resources 可以发现,二者的 density 值不一样(以 2160*1080、尺寸为 5.99 英寸的屏幕为例):

context.resources.DisplayMetrics: DisplayMetrics{density=2.88, width=1080, height=2033, scaledDensity=2.88, xdpi=403.411, ydpi=403.411}

Resources.getSystem().DisplayMetrics: DisplayMetrics{density=2.7, width=1080, height=2033, scaledDensity=2.7, xdpi=403.411, ydpi=403.411}

这是由于使用了AndroidAutoSize适配方案后,APP 内部的 density 已经被改成了 2.88,而系统实际的 density 是 2.7。

又知道 android 中将像素和 dp 等单位转化的方法如下:

// android.util.TypedValue
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;
    }

分析可知,通过 getStatusBarHeight() 获取到的状态栏是系统的状态栏 69px(即 25dp),但当使用 APP 内部的 density=2.88 计算时就会只有 24dp,和实际的状态栏高度不一致,所以使用状态栏高度来控制布局的时候就会展示异常。

参考资料

骚年你的屏幕适配方式该升级了!-今日头条适配方案——jessyan

一种极低成本的 Android 屏幕适配方式——字节跳动

支持不同的像素密度——Android Developers

Android 目前稳定高效的 UI 适配方案——拉丁吴

AndroidAutoSize

请问两种获取屏幕密度的方式有什么区别,望解答多谢

有想法?欢迎通过邮件讨论。

目录