今日头条屏幕适配方案落地研究
目录
前言
大家好,现在给大家推荐一种极低版本的 android 屏幕适配方案,就是今日头条适配方案,“极低成本”这四个字正是今日头条的适配文章标题。
众所周知,安卓的屏幕碎片化极其严重,适配一直是从事安卓开发人员十分头疼的事情。前期,由于公司支持的平板款式单一,只需要做几款平板的适配即可,选用了 smalledtwidth(最小宽度)适配,但是这个方案在增加新屏幕时且原 dimens 文件无法很好适配时,就需要增加新屏幕的最小宽度 dimens 文件了,比较麻烦而且会增加项目大小(虽然只是几个文件),而且这种屏幕适配极度依赖设备的屏幕密度,叫density。为了讲解更清楚,这里需要引入几个公式:
px = density * dp
dp : 安卓开发人员常常挂在嘴上的长度单位
px : 设计人员眼中的长度单位
density = dpi / 160
因此,px = dp * (dpi/160)
dpi : 根据屏幕真实分辨率和尺寸计算得出
举个例子:屏幕分辨率为 1920 * 1080,屏幕尺寸为5寸(屏幕斜边长度cm/0.3937), 则 dpi = √(宽度²+ 高度²)/屏幕尺寸
因此,屏幕密度至关重要,屏幕密度怎么来的?厂商写入一个 system/build.prop 文件,有时还会写错,就我们一款华为平板,获取的屏幕密度是2,但是手工测量并按公式得到实际屏幕密度是1.56。导致我们的适配方案在那款平板就失效了。
本人一直在寻找可以一劳永逸的屏幕适配方案,今日头条是选定基准分辨率,基于设备屏幕分辨率计算出新的屏幕密度进行适配,保证所有设备的显示效果一致,完美避开上面那款设备的问题。推荐给大家。
各平板数据比较
首先,我详细记录了公司主流设备的参数,新方案肯定要对主流设备都能完美适配,这才是入门门槛。
| 三星n5100-4.1| 三星p355c-6.0(基准) | 华为-8.0
---|---|---|---
真实宽度(px)| 800 | 768 | 1200
真实高度(px) | 1280 | 1024| 1852
原始 density | 1.33125 | 1.0 | 2.0(不准,实际1.56 )
new density | 1.04166 | - | 1.5625
|
new height(px) | 1066 | - | 1600
可以看到横向是几种设备,竖向是一些参数,其中中英文混杂,这是为什么呢?这是我故意的,中文是设备原始参数,英文是根据今日头条方案原理计算的。因为,今日头条的目的是所有设备的显示效果一致。但是设备的分辨率是不同的,怎么显示一致呢?简单述之,就是缩放,按宽度缩放的。可能有人会有疑问,缩放后的效果图放不下,显示不完整怎么办?
我们看看上面的数据,可以看到按照三星6.0基准进行缩放,效果图在三星4.1这款设备宽度上的显示,是按768乘以new density ,也就是 1.04166 进行放大,不用按计算器了,就是800px,完美适配。那么高度呢,1024 也乘以 new density,发现是1066px,比实际高度像素值 1280px 小,不会出现显示不全的现象。可能有人会问了,这不是多出来了么,会不会留空白啊?对,好问题,所以合格的开发在竖向布局上增加自适应权重,以应对这种情况。当然,横向也需要考虑自适应权重。
同理,可得知效果图在华为8.0设备的宽度像素是 1600px, 也比实际设备宽度 1852px 小,也能显示完全。
为什么看起来更小了?(头条方案跟最小宽度方案比较)
对的,跟原先的比起来,是更小了,包括图片更小,文字更小。这是为什么呢?且听我细细道来... ...
大家都知道,安卓有 mdpi、hdpi、xhdpi后缀的文件,具体使用有 drawable-mdpi、drawable-hdpi,或者mipmap-mdpi、mipmap-hdpi, 又或者 values-mdpi、values-hdpi, 这些都是安卓自带的屏幕适配方案,只是不太好用吗,经常出问题。那么,这些文件都是怎么使用的呢,这又涉及到了屏幕密度这个属性,关联如下:
dpi | 屏幕密度
---|---
drawable-ldpi | 0.75
drawable-mdpi | 1(baseline)
drawable-hdpi | 1.5
drawable-xhdpi | 2
drawable-xxhdpi | 3
drawable-xxxdpi | 4
- 平板a 三星平板5100 的屏幕密度是1.33125,大于mdpi,小于hdpi,向上取整,所以属于hdpi
- 平板b 三星平板p355c 的屏幕密度是1,属于mdpi
- ldpi:mdpi:hdpi:xhdpi:xxhdpi:xxxdpi = 0.75:1:1.5:2:3:4 = 3:4:6:8:12:16
- 上述比值乘以12,就是 36:48:72:144:192,刚好就是icon尺寸
- 我们会看到,最小宽度适配方案,values-hdpi 的值是 values-mdpi 的值乘以 0.8
0.8 的参数
- 宽高100dp的正方形图片,平板a会显示100px,平板b会乘以1.5,显示成150px,导致偏大
- 由于平板b的屏幕密度是 1.33125, 最好 显示成 100* 1.33125
- 1.33125/ 1.5 = 0.8875 约为 0.8
sw600dp-dpi
- sw : small width,就是最小宽度是600dp,
- px -> dp : dp = px / density
- 平板a: 800 /1.33125 = 600.93
- 平板b: 768/1 = 768
上述两个平板,一个是600dp,一个是768dp,都是大于600dp,平板a使用sw600dp-hdpi,平板b使用sw600dp-mdpi
最后称述
平板a、b 同时显示一个 100px 的图片:
- 按最小宽度适配:100 * 1.5 * 0.8 = 120 ,图片会显示成 120px
- 按今日头条适配: 100 * 1.04166 = 104.166,图片会显示成 104.166 px
- 所以今日头条方案显示的图片就更小了。
那么,哪个更好呢?我们再来看看一个极端,显示一个 平板b 的填满宽度的图片, 768px:
- 按最小宽度适配:768px * 1.5 * 0.8 = 921.6px ,图片会显示成 921.6px, 远远超出平板a的尺寸,此时开发人员需要手动干预
- 按今日头条适配: 768px * 1.04166 = 799.99488,图片可以看成显示成 800 px
- 优点很明显,布局更简单
严谨的你,可能会问了,那显示超过768px呢?
不好意思,我们的基准就是 768,不会超过他了。
smalleswidth 方案迁移
我们原项目使用的是 smallestwidth 方案,经试验迁移代价很低,经研究有如下两个方案。
- 删除所有适配 smallestwidth 的dimens 文件夹,只保留dp 值是1:1 的 dimens 文件即可;
- 不想删除亦可,将所有的 dimens 文件都覆盖成 dp 值是1:1 的 dimens 文件即可
优缺点
优点
- 使用成本非常低,操作非常简单,使用该方案无需增加dimens 文件,修改代码,完虐其他屏幕适配方案
- 侵入性非常低,切换几乎瞬间完成,试错成本接近为0
- 修改的 density 是全局的,一次修改,终生受益。
- 不会有任何性能的损耗
-
今日头条 大厂保证
缺点
- 第三方布局库, 未按项目效果图布局,全局修改 density 导致修改第三方布局,造成显示界面问题
与 smallestwith 适配方案不兼容,切换回来比较麻烦
issue
一个 bitmap 的density 问题
在某处,开启今日头条适配方案,全局修改屏幕密度,获取 imageview 的 bitmap 的宽高,发现获取的宽高和实际的宽高(布局出来观察)不一致。经查阅源码,发现 bitmap 也有一个 density, 怀疑未被修改。
public int mdensity = getdefaultdensity(); ... ... static int getdefaultdensity() { if (sdefaultdensity >= 0) { return sdefaultdensity; } sdefaultdensity = displaymetrics.density_device; return sdefaultdensity; }
随决定,修改 sdefaultdensity 值,查阅代码,发现 sdefaultdensity 是静态私有,于是召唤反射大法
/** * 设置 bitmap 的默认屏幕密度 * 由于 bitmap 的屏幕密度是读取配置的,导致修改未被启用 * 所有,放射方式强行修改 * @param defaultdensity 屏幕密度 */ private static void setbitmapdefaultdensity(int defaultdensity) { //获取单个变量的值 class clazz; try { clazz = class.forname("android.graphics.bitmap"); field field = clazz.getdeclaredfield("sdefaultdensity"); field.setaccessible(true); field.set(null, defaultdensity); field.setaccessible(false); } catch (classnotfoundexception e) { } catch (nosuchfieldexception e) { } catch (illegalaccessexception e) { e.printstacktrace(); } }
测试 ok, 收工。
附录(适配核心代码)
- initappdensity 方法 application 调用,记录默认屏幕密度
- setdefault 和 setorientation 方法 activity 调用,设置新的屏幕密度
- resetapporientation 方法,恢复屏幕密度
// * ================================================ // * 本框架核心原理来自于 <a href="https://mp.weixin.qq.com/s/d9qcobp6kv9vswvvldvvwa">今日头条官方适配方案</a> // * <p> // * 本框架源码的注释都很详细, 欢迎阅读学习 // * <p> // * 任何方案都不可能完美, 在成本和收益中做出取舍, 选择出最适合自己的方案即可, 在没有更好的方案出来之前, 只有继续忍耐它的不完美, 或者自己作出改变 // * 既然选择, 就不要抱怨, 感谢 今日头条技术团队 和 张鸿洋 等人对 android 屏幕适配领域的的贡献 // * <p> // * ================================================ // */ private static final int width = 1; private static final int height = 2; private static final float default_width = 768f; //默认宽度 private static final float default_height = 1024f; //默认高度 private static float appdensity; /** * 字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值 */ private static float appscaleddensity; /** * 状态栏高度 */ private static int barheight; private static displaymetrics appdisplaymetrics; private static float densityscale = 1.0f; /** * application 层调用,存储默认屏幕密度 * * @param application application */ public static void initappdensity(@nonnull final application application) { //获取application的displaymetrics appdisplaymetrics = application.getresources().getdisplaymetrics(); //获取状态栏高度 barheight = getstatusbarheight(application); if (appdensity == 0) { //初始化的时候赋值 appdensity = appdisplaymetrics.density; appscaleddensity = appdisplaymetrics.scaleddensity; //添加字体变化的监听 application.registercomponentcallbacks(new componentcallbacks() { @override public void onconfigurationchanged(configuration newconfig) { //字体改变后,将appscaleddensity重新赋值 if (newconfig != null && newconfig.fontscale > 0) { appscaleddensity = application.getresources().getdisplaymetrics().scaleddensity; } } @override public void onlowmemory() { } }); } } /** * 此方法在baseactivity中做初始化(如果不封装baseactivity的话,直接用下面那个方法就好了) * * @param activity activity */ public static void setdefault(activity activity) { setapporientation(activity, width); } /** * 比如页面是上下滑动的,只需要保证在所有设备中宽的维度上显示一致即可, * 再比如一个不支持上下滑动的页面,那么需要保证在高这个维度上都显示一致 * * @param activity activity * @param orientation width height */ public static void setorientation(activity activity, int orientation) { setapporientation(activity, orientation); } /** * 重设屏幕密度 * * @param activity activity * @param orientation width 宽,height 高 */ private static void setapporientation(@nonnull activity activity, int orientation) { float targetdensity; if (orientation == height) { targetdensity = (appdisplaymetrics.heightpixels - barheight) / default_height; } else { targetdensity = appdisplaymetrics.widthpixels / default_width; } float targetscaleddensity = targetdensity * (appscaleddensity / appdensity); int targetdensitydpi = (int) (160 * targetdensity); // 最后在这里将修改过后的值赋给系统参数,只修改activity的density值 displaymetrics activitydisplaymetrics = activity.getresources().getdisplaymetrics(); activitydisplaymetrics.density = targetdensity; activitydisplaymetrics.scaleddensity = targetscaleddensity; activitydisplaymetrics.densitydpi = targetdensitydpi; densityscale = appdensity / targetdensity; setbitmapdefaultdensity(activitydisplaymetrics.densitydpi); } /** * 重置屏幕密度 * * @param activity activity */ public static void resetapporientation(@nonnull activity activity) { displaymetrics activitydisplaymetrics = activity.getresources().getdisplaymetrics(); activitydisplaymetrics.density = appdensity; activitydisplaymetrics.scaleddensity = appscaleddensity; activitydisplaymetrics.densitydpi = (int) (appdensity * 160); densityscale = 1.0f; setbitmapdefaultdensity(activitydisplaymetrics.densitydpi); } /** * 获取状态栏高度 * * @param context context * @return 状态栏高度 */ private static int getstatusbarheight(context context) { int result = 0; int resourceid = context.getresources().getidentifier("status_bar_height", "dimen", "android"); if (resourceid > 0) { result = context.getresources().getdimensionpixelsize(resourceid); } return result; } /** * 设置 bitmap 的默认屏幕密度 * 由于 bitmap 的屏幕密度是读取配置的,导致修改未被启用 * 所有,放射方式强行修改 * @param defaultdensity 屏幕密度 */ private static void setbitmapdefaultdensity(int defaultdensity) { //获取单个变量的值 class clazz; try { clazz = class.forname("android.graphics.bitmap"); field field = clazz.getdeclaredfield("sdefaultdensity"); field.setaccessible(true); field.set(null, defaultdensity); field.setaccessible(false); } catch (classnotfoundexception e) { } catch (nosuchfieldexception e) { } catch (illegalaccessexception e) { e.printstacktrace(); } } /** * 屏幕密度缩放系数 * * @return 屏幕密度缩放系数 */ public static float getdensityscale() { return densityscale; }
上一篇: 带娃带出爆笑一大波
下一篇: 爆囧,Ta们都是幽默高手呢