背景
在Android开发中,由于Android碎片化严重,屏幕分辨率千奇百怪,而想要在各种分辨率的设备上显示基本一致的效果,适配成本越来越高。虽然Android官方提供了dp单位来适配,但其在各种奇怪分辨率下表现却不尽如人意。
Android屏幕分辨率分布图
对比IOS屏幕分辨率分布图
所以Android的屏幕适配已经为重中之重的话题。
概念
屏幕尺寸、屏幕分辨率、屏幕像素密度
屏幕尺寸:屏幕对角线长度,单位是英寸,我们常说的多少多少寸,比如4.7存手机、5.7存手机,指的就是这个。
屏幕分辨率:如 1920×1080,是指在手机屏幕的像素点的个数,单位是px,1px = 1 像素点,一般是纵向像素 × 横向像素,意味着高有 1920 个像素点,宽有 1080 个像素点。
屏幕像素密度:是指每英寸上的像素点数,单位是 dpi(dotper inch)。像素密度和屏幕尺寸和屏幕分辨率有关,它是由对角线的像素点数除以屏幕的大小得到的,关系如下:
单一变化条件下,屏幕尺寸越小、分辨率越高,像素密度越大,反之越小。
与PPI的概念和计算方式是相同的
dp:是Android 特有的,意为密度无关像素,Google 发布的 BASELINE(基准线)为 160,以此为基准。
dip:Density Independent Pixels,同dp一个意思,目前废弃了,一般都写dp。
dpi:像素密度是屏幕上单位面积内的像素数,称为dpi(每英寸的点数)。 它与分辨率不同,后者是屏幕上像素的总数。
sp:Scale-IndependentPixels的缩写,可以根据文字大小首选项自动进行缩放。Google推荐我们使用12sp以上的大小,通常可以使用12sp,14sp,18sp,22sp,为避免精度损失,建议最好不要使用奇数和小数。
px:就是我们常说的像素
density:就这个单词本身直接翻译的意思而言,其也代表“密度”。但需要注意的是,在Android中,其实并非如此。注意我们这里指的是,通过代码
context.getResources().getDisplayMetrics().density
获取的“density”值。而通过该方法获取到的该值,实际上是等价于“dpi / 160”的一个结果值。
dp直接适配
dp的概念是谷歌官方提出的适配的一种方式。
在android中的dp在渲染前会将dp转为px,计算公式:
- px = density * dp;
- density = dpi / 160;
- px = dp * (dpi / 160);
而dpi是根据屏幕真实的分辨率和尺寸来计算的,每个设备都可能不一样的。
而因为Android碎片化非常严重的原因就导致了dpi的值非常乱,根本没有规律可循,即使dp适配可以做到80%的适配,但是效果还是差强人意。
我们用案例来看一下对比:
这里创建了两个个模拟器,同样的分辨率480 * 800
两种类别的设备,同样的放一张图片,布局代码
1 |
|
同样的代码,设置为300dp,但是两台机型却表现得不尽人意。这里就要涉及到上面一些公式的概念进行换算了,因为最终都会转换成px,我们来换算一下:
480*800 5.1寸机型下
1 | dpi = √(480^2 * 800^2)/ 5.1 = 182.93 |
其余相同计算方式,对照表格:
480*800/5.1 | 480*800/4 | |
---|---|---|
dpi | 182.93 | 233.24 |
density | 1.14 | 1.46 |
px | 342 | 438 |
上述计算结果均为保留小数点后两位
但是计算的结果真的是这样吗,我们使用代码来获取一下控件的高和宽
1 | public class MainActivity extends AppCompatActivity { |
我们查看一下Log输出:
480*800/5.1 | 480*800/4 | |
---|---|---|
Imageview height | 300 | 450 |
imageview width | 300 | 450 |
density | 1.0 | 1.5 |
dpi | 160 | 240 |
ydpi | 160.0 | 240.0 |
xdpi | 160.0 | 240.0 |
heightPixels | 800 | 800 |
widthPixels | 480 | 480 |
那么这为什么和我们计算的不一样呢,这里就要设计到系统dpi和物理dpi了,我们需要深究到其源码。
1 | //platform_frameworks_base/core/java/android/util/DisplayMetrics.java |
深究其方法是一个native方法,在代码注释中提到的init的方法,深究源头
1 | void DisplayHardware::init(uint32_t dpy) |
看其源码可以看出density的值是通过获取ro.sf.lcd_density
配置的值,如果没有默认使用DENSITY_DEFAULT
,其默认值有
1 |
|
那么问题来了,ro.sf.lcd_density
的值在哪里找到,其配置文件路径在手机的/system/build.prop
文件中。
可以使用adb命令来将文件进行导出。但是要注意的是,avd模拟器下该文件没有ro.sf.lcd_density
该配置项。但是可以在emulator根目录下/config.ini
中的hw.lcd.density
可以找到配置的值。
4寸模拟器下config.ini
的hw.lcd.density
值
1 | hw.lcd.density=240 |
我们将4寸的模拟器的配置文件修改成160后查看打印日志:
可以查看到日志的输出和上面原来的输出发生了改变,改成了自己配置的值。但是该选项只是avd模拟器环境下,真机或者一些游戏模拟器环境下都是在/system/build.prop
配置文件中ro.sf.lcd_density
的值。一般该值都是出厂时就编译好的。
1 | ro.sf.lcd_density=240 |
这是MUMU中读取
/system/build.prop
文件的读取的值,这里没有root的真机,无法演示真机环境,但原理相同。但是可以测试一下真机环境下,DPI是根据配置读取的,而非真实通过物理分辨率求出来的从而验证上述的结论。这里以三星s8手机为例,主屏分辨率2960*1440,尺寸5.8,求出dpi约为3.5,而依靠上述代码输出的值为4.5。
所以dp都是使用系统定义的dpi来进行换算的。而非是说单纯的使用物理分辨率和尺寸来计算的。但依然如此,Android的碎片化还是让dp直接适配还是无法让人满意,尽管dp适配可以解决小部分的适配问题。
宽高限定符适配
为了高效的实现UI开发,出现了新的适配方案,我把它称作宽高限定符适配。简单说,就是穷举市面上所有的Android手机的宽高像素值:
然后我们根据一个基准,为基准的意思就是,比如设计图的尺寸为480 * 800
的分辨率,有个300*300px的ImageView,则
- 宽度为480,将任何分辨率的宽度分为480份,每一份1px,取值为x1-x480。
- 高度为800,将任何分辨率的高度分为800份,每一份1px,取值为y1-y800。
则对于540 * 860的分辨率来说
可以看到x1 = 540 / 基准 = 540 / 480 = 1.12 ;而其他分辨率的计算方式相同。
看一下使用该方式适配的对比结果,同样适用dp适配所使用的布局
1 |
|
修改了ImageView的宽和高,适配结果为下图
再看看不同机型分辨率下的表现
可以看到对比于使用dp方案来适配的结果要完美上许多。通过dimens引用去寻找该分辨率的文件夹下面对应的值。这样基本可以解决我们的适配问题。
那么重点来了,既然可以适配,但为什么很少人使用该方案呢,这就涉及到该方案的一个致命的缺点:那就是需要精准命中才能适配。如果values限定符下的分辨率没有对应上手机,则就只能用默认的values下的dimens文件了。如果使用默认尺寸,而又不同于设计稿的尺寸,就可以会发生UI变形。简单的说容错率太低了。
生成的values文件夹下以哪个为基准也需要同样的拷贝一份基准值去默认values文件夹下作为默认值。
那么如何生成上述所说的文件夹呢,这里使用鸿洋大神给出的一份自动生成代码:
1 | import java.io.File; |
对于主流的分辨率我已经集成到了我们的程序中,当然对于特殊的,你可以通过参数指定。关于屏幕分辨率信息,可以通过该网站查询:http://screensiz.es/phone
AndroidAutoLayout库适配
鸿洋大佬的适配方案的项目也来自于宽高限定符方案的启发。虽然该框架已经停止维护,但是许多老项目也在使用该方案。因为集成简单,并且不需要使用dp单位,而是定义好设计稿的尺寸后使用px单位即可完成适配。
使用方法:
- Android Studio
1 | dependencies { |
- AndroidManifest注册
设计稿
尺寸
1 | <meta-data android:name="design_width" android:value="768"> |
- 集成
AutoLayoutActivity
然后就可以在布局文件按照设计稿的尺寸来使用具体的像素值了。比如,设计稿上是96*96,那么我们可以直接写96px,APP运行时,框架会帮助我们根据不同手机的具体尺寸按比例伸缩。这是比宽高限定符更好的方案,因为解决了宽高限定符的容错率问题。
但是框架要在运行时会在onMeasure里面做变换,自定义的控件可能会被影响或限制,可能有些特定的控件,需要单独适配,这里面可能存在的暗坑是不可预见的。因为这是由框架来完成,并非系统完成。并且该库作者已经放弃维护了。
smallestWidth适配
smallestWidth适配也叫做sw限定符适配。值得是Android会识别屏幕可用宽度和高度的最小尺寸的dp值,然后再根据识别的结果去资源文件中寻找对应的限定符的文件夹下的资源文件。
这种机制上和上文提到的宽高限定符适配原理上是一样的。都是通过系统特定的规则选择对应的文件。
例如,比如一台手机的dpi为480,横向分辨率为1080px,根据公式px = dp(dpi/160),横向的dp值是360dp。则系统就会自动去寻找value-sw360dp
的文件夹以及对应的资源文件。
理论条件下物理dp等于系统dp
而该方案对比与宽高限定符适配方案最大的区别也是优点就是,该方案有更好的容错率。比如上述例子中,如果系统找不到value-sw350dp
文件夹,则系统会向下寻找,比如找到离一个360最近的value-sw320dp
文件夹。那么系统就会选择该文件下的资源文件。
例如设计稿同样为480 * 800
,同样有一个300 * 300
px的ImageView,例如在values-sw360dp文件夹下的dimen应该如何编写呢?360dp则意味着手机最小宽度为360dp,我们将360dp分成480份,每一个设计稿中的像素大概代表着手机的0.75dp。那么一个300 * 300
px对应的dimen引用则为
1 |
|
而这种dimens引用,在不同的values-sw<N>
dp文件夹下的数值是不同的,比如values-sw400dp和values-sw420dp
1 | //400dp |
计算完后,那么对应的布局文件编写代码:
1 |
|
运行一下来看看适配的效果:
smallestWidth的适配机制由系统保证,我们只需要针对这套规则生成对应的资源文件即可,不会出现什么难以解决的问题,也根本不会影响我们的业务逻辑代码,而且只要我们生成的资源文件分布合理,,即使对应的smallestWidth值没有找到完全对应的资源文件,它也能向下兼容,寻找最接近的资源文件。
当然该方案也有他的缺点,生成的文件夹越多,也就意味着生成的dimens文件的覆盖范围和尺寸范围越大,apk的安装包也会增加,宽高限定符适配方案也同样有着该缺点。
smallestWidth适配方案有一个小问题,那就是它是在Android 3.2 以后引入的,Google的本意是用它来适配平板的布局文件(但是实际上显然用于diemns适配的效果更好),不过目前所有的项目应该最低支持版本应该都是4.0了(糗事百科这么老的项目最低都是4.0哦),所以,这问题其实也不重要了。
当然,计算的方式肯定也不会是自己一点计算再编写, 附上生成的代码文件。代码链接
1 | import java.io.File; |
主流dp也可以查询相关网站
今日头条适配方案
该方案的思想来源就是修改density的值,强行把所有不同分辨率的手机的宽度改成一个统一的值。
上文提到dp适配的DisplayMetrics
中的相关变量:
- DisplayMetrics#density 就是上述的density
- DisplayMetrics#densityDpi 就是上述的dpi
- DisplayMetrics#scaledDensity 字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值
那么是不是所有的dp和px的转换都是通过 DisplayMetrics 中相关的值来计算的呢?
首先来看看布局文件中dp的转换,最终都是调用 TypedValue#applyDimension(int unit, float value, DisplayMetrics metrics)
来进行转换:
1 | public static float applyDimension(int unit, float value, |
这里用到的DisplayMetrics正是从Resources中获得的。
再看看图片的decode,BitmapFactory#decodeResourceStream
方法:
1 | public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value, |
当然还有些其他dp转换的场景,基本都是通过 DisplayMetrics 来计算的,这里不再详述。因此,想要满足上述需求,我们只需要修改 DisplayMetrics 中和 dp 转换相关的变量即可。
通过该原理得到的适配方案:
比如,设计稿的宽度是480px,那么开发代码时会把目标dp值设置为480dp,在不同设备中,动态修改density的值,从而保证手机像素宽度/density这个值始终是360dp。这样来保证UI在不同设备上表现一致。
今日头条屏幕适配方案的核心原理在于,根据以下公式算出 density
当前设备屏幕总宽度(单位为像素)/ 设计图总宽度(单位为 dp) = density
今日头条方案代码实现:
1 | import android.app.Activity; |
然后在activity#onCreate方法中调用即可,在setContentView之前。运行看看适配的效果:
以设计图宽480dp去适配的,如果要以高维度适配,可以再扩展下代码即可
优点
- 使用成本非常低,操作非常简单,使用该方案后在页面布局时不需要额外的代码和操作,这点可以说完虐其他屏幕适配方案
- 侵入性非常低,该方案和项目完全解耦,在项目布局时不会依赖哪怕一行该方案的代码,而且使用的还是 Android 官方的 API,意味着当你遇到什么问题无法解决,想切换为其他屏幕适配方案时,基本不需要更改之前的代码,整个切换过程几乎在瞬间完成,会少很多麻烦,节约很多时间,试错成本接近于 0
- 可适配三方库的控件和系统的控件(不止是是 Activity 和 Fragment,Dialog、Toast 等所有系统控件都可以适配),由于修改的 density 在整个项目中是全局的,所以只要一次修改,项目中的所有地方都会受益
- 不会有任何性能的损耗
缺点
暂时没发现其他什么很明显的缺点,已知的缺点有一个,那就是第三个优点,它既是这个方案的优点也同样是缺点,但是就这一个缺点也是非常致命的
只需要修改一次 density,项目中的所有地方都会自动适配,这个看似解放了双手,减少了很多操作,但是实际上反应了一个缺点,那就是只能一刀切的将整个项目进行适配,但适配范围是不可控的
这样不是很好吗?这样本来是很好的,但是应用到这个方案是就不好了,因为我上面的原理也分析了,这个方案依赖于设计图尺寸,但是项目中的系统控件、三方库控件、等非我们项目自身设计的控件,它们的设计图尺寸并不会和我们项目自身的设计图尺寸一样
当这个适配方案不分类型,将所有控件都强行使用我们项目自身的设计图尺寸进行适配时,这时就会出现问题,当某个系统控件或三方库控件的设计图尺寸和和我们项目自身的设计图尺寸差距非常大时,这个问题就越严重
这里是JessYan总结的优缺点,个人很赞同。
AndroidAutoSIze
一个基于今日头条方案的开源库,一个极低成本的 Android 屏幕适配方案.
如果项目没有什么特殊要求,两个步骤即可完成适配:
添加依赖
1 | implementation 'me.jessyan:autosize:1.1.2' |
请在 AndroidManifest 中填写全局设计图尺寸 (单位 dp)
1 | <manifest> |
Github,更多详细集成文档建议查看github链接。github中有很详细的用法以及使用的问题。
总结
适配就是根据设计图来达到某一个维度上显示一致,不能够说使用适配就可以不使用wrap_content等,比如一个页面时上下滑动的,我们只需要保持设备在宽的维度上保持显示一致即可。而如果一个不支持上下滑动的页面,只需要保持设备在高的维度上保持显示一致。
如何适配,如何选择适配的方案还是要结合自己业务的需求。因为开发就是要追求高效和稳定。
参考资料
https://blog.csdn.net/ghost_Programmer/article/details/50042805
https://juejin.im/post/5ae9cc3a5188253dc612842b
https://blog.csdn.net/lmj623565791/article/details/45460089