一、屏幕适配的原因——碎片化
一句话——“碎片化”。由于android系统是开源的,各手机厂商可以根据各自的设计去生产手机,导致屏幕尺寸没有统一标准,屏幕的宽高比各式各样,屏幕密度也各种各样。这就导致Android开发者想要用一套代码来适配所有的设备变得格外的困难,虽然谷歌提出了使用dp单位来替代px,但是 dp依然有它无法完全适配的地方。
二、屏幕适配的思路——等比例缩放或多套并用
“等比例缩放”,侧重尺寸,就是根据UI设计图给出的尺寸,选择任意一个设备的屏幕尺寸数据作为基准尺寸数据,把这套基准尺寸数据写进values/dimens文件中。当遇到与基准尺寸数据不同的屏幕时,运用一系列的转换方式,在基准尺寸数据的基础上等比例缩放为适合当前屏幕的尺寸数据。
“多套并用”,侧重布局,就是根据不同的尺寸编写多套布局。
三、屏幕适配用到的基础知识
1、像素(px)
像素是屏幕区域的最小分割区域,单位是px。
在不同的屏幕上,1px所占的面积可能不一样大,但是在同一个屏幕上,1px的面积都是一样大的。
2、分辨率 (1920*1080)
比如1920*1080 分辨率表示当前设备的屏幕是由横向1080个像素,纵向1920个像素组合而成。
3、屏幕尺寸(英寸)
屏幕对角线的长度,一般以英寸(1英寸=25.4毫米)为单位。
常见的屏幕尺寸有 4.7英寸, 5.5英寸,6.0英寸。
4、屏幕像素密度(dpi)
一平方英寸面积内存在多少个像素,屏幕密度的单位是 dpi(dots per inch)。
标准屏幕分辨率是160dpi,即一平方英寸面积,存在160个像素(mdpi)。同样是 1920*1080的分辨率,在两款不同尺寸的手机上,一个是4.7英寸,一个是6.0英寸,这两者的像素密度是不一样的。
屏幕像素密度计算公式为:
dpi计算公式.png一块1920*1080的5寸屏幕,通过上面的公式计算得出,它的像素密度为 440dpi。可见,px是和像素密度有直接关系的像素单位。
dpi计算示例.png5、密度无关像素(dip/dp)
谷歌建议使用dp(density-independent pixel)作为长度单位,以保证在不同的屏幕像素密度的手机上显示 很相似的效果。
比如:使用480x800的手机上,要画一条长度为一半屏幕宽的线条,我们可以设置线条的长度为240px,而在320x480的屏幕上,我们只需要160px,但是我们可以直接使用160dp,来同时让两个屏幕上的这条线占全宽的一半。
px和dp的换算公式为:px = dp * (dpi / 160)
公式解读:
如果有一个屏幕像素密度为160dpi的手机,在它上面,1px=1dp;而如果是 320dpi的手机,则 1px = 0.5dp。简而言之规律就是:屏幕像素密度(dpi)越高的手机,1dp所代表的px就越多。
既然dp这么厉害,为什么还无法适配所有屏幕呢?
简单的说就是上文所说的“碎片化”,这也是dp的不足的地方,下面我们来具体分析一下。
如果以320 x 480屏幕分辨率、160dpi屏幕像素密度的屏幕为标准,画了一条长度为屏幕宽度的一半的直线(即160px),直接用160dp就能完成适配。但是如果这样的布局运行在320x480屏幕分辨率,但是屏幕像素密度为150dpi的设备上,此时布局里面写的160dp就会少于160px(160dp*150dip/160dpi=150px)不再是占屏幕宽的一半,而是不到一半。
6、比例独立像素(sip/sp)
sp(scale-independent pixel)专门用于表示字体大小。
那么sp与dp有什么不同呢?
通常情况下,dp和sp效果类似,不同的是如果用户调整了手机字体,比如从标准,变成了超大,那么,1dp原本等于1px依然不变,但是1sp就会从1px变成3px(只是举个例子,数值不代表真实情况)。因此,在用户调整字体的情况下,同样的布局,可能出现窗口大小不变,但是文字尺寸发生变化的情况。
7、Android 中关于屏幕参数的相关API
- DisplayMetrics#density 这里的density等于dpi / 160
- DisplayMetrics#densityDpi 这里的densityDpi就是上文中的dpi
- DisplayMetrics#scaledDensity 这里的scaledDensity是字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值
DisplayMetrics 实例通过 Resources#getDisplayMetrics 可以获得,而Resouces通过Activity或者Application的Context获得。
综上,px、dp、dpi、density之间的换算关系如下:
- density = dpi / 160
- px = density * dp
- px = dp * (dpi / 160)
四、常用的屏幕适配方案
1、屏幕分辨率限定符适配
屏幕分辨率限定符适配需要在 res 文件夹下创建各种屏幕分辨率对应的 values-xxx 文件夹,如下图:
屏幕分辨率限定符.png然后根据一个基准分辨率,例如基准分辨率为 1280x720,将宽度分成 720 份,取值为 1px~720px,将高度分成 1280 份,取值为 1px~1280px,生成各种分辨率对应的 dimens.xml 文件。如下分别为分辨率 1280x720 与 1920x1080 所对应的横向dimens.xml 文件:
屏幕分辨率限定符示例.png假设设计图上的一个控件的宽度为 720px,那么布局中就写 android:layout_width="@dimen/x720" ,当运行程序的时候,系统会根据设备的分辨率去寻找对应的 dimens.xml 文件。例如运行在分辨率为 1280x720 的设备上,系统会自动找到对应的 values-1280x720 文件夹下的 lay_x.xml 文件,由上图可知 x720 对应的值为
720.px,可铺满该屏幕宽度。运行在分辨率为 1920x1080 的设备上,系统会自动找到对应的 values-1920x1080 文件夹下的 lay_x.xml 文件,由上图可知 x720 对应的值为 1080.0px,可铺满该屏幕宽度。这样就达到了屏幕适配的要求!
2、smallestWidth限定符适配
smallestWidth 限定符适配原理与屏幕分辨率限定符适配原理一样,系统都是根据限定符去寻找对应的 dimens.xml 文件。例如程序运行在 最小宽度为 360dp 的设备上,系统会自动找到对应的 values-sw360dp 文件夹下的 dimens.xml 文件。区别就在于屏幕分辨率限定符适配是拿 px 值等比例缩放,而smallestWidth 限定符适配是拿 dp 值来等比缩放而已。需要注意的是“最小宽度”是不区分方向的,即无论是宽度还是高度,哪一边小就认为哪一边是“最小宽度”。如下分别为最小宽度为 360dp 与最小宽度为 640dp 所对应的 dimens.xml 文件:
smallestWidth限定符适配示例.png获取设备最小宽度代码为:
DisplayMetrics dm = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(dm);
int heightPixels = ScreenUtils.getScreenHeight(this);
int widthPixels = ScreenUtils.getScreenWidth(this);
float density = dm.density;
float heightDP = heightPixels / density;
float widthDP = widthPixels / density;
float smallestWidthDP;
if(widthDP < heightDP) {
smallestWidthDP = widthDP;
}else {
smallestWidthDP = heightDP;
}
ScreenUtils.java完整代码详见本文最后
注:smallestWidth 限定符适配对屏幕分辨率限定符的比较优势
-
smallestWidth 限定符适配只需要更少的dimens.xml 文件
屏幕分辨率限定符适配是根据屏幕分辨率的,Android 设备分辨率一大堆,而且还要考虑虚拟键盘,这样就需要大量的 dimens.xml 文件。而无论手机屏幕的像素多少、密度多少,90% 的手机的最小宽度都为 360dp,所以采用 smallestWidth 限定符适配只需要少量 dimens.xml 文件即可。 -
屏幕分辨率限定符适配只能匹配精确的尺寸,而smallestWidth 限定符适配可以向下兼容尺寸
屏幕分辨率限定符适配需要设备分辨率与 values-xx 文件夹完全匹配才能达到适配,而 smallestWidth 限定符适配寻找 dimens.xml 文件的原理是 从大往小 找,例如设备的最小宽度为 360dp,就会先去找 values-360dp,发现没有则会向下找 values-320dp,如果还是没有才找默认的 values 下的 demens.xml 文件,所以即使没有完全匹配也能达到不错的适配效果。 -
smallestWidth 限定符适配对文字缩放更友好
屏幕分辨率限定符适配采用的是 px 单位,而 smallestWidth 限定符适配采用的单位是 dp 和 sp,dp 和 sp 是google 推荐使用的计量单位。又由于很多应用要求字体大小随系统改变,所以字体单位使用 sp 也更灵活。
3、布局限定符适配
使用多套布局文件(而不是dimens.xml尺寸文件)适应不同屏幕。使用方法类似上述smallestWidth 限定符适配,不同的是在原本的layout
后面加上横杠(而不是values后面),然后加上限定名形成 layout-XXX的形式。
布局限定符适配示例.png注:这是最不推荐的适配方式,因为一旦要修改某个布局,就要修改多套布局文件。此适配方案最适合这种情况:一款App在手机和平板上要显示不同的布局,但是数据源一致。
布局限定符适配1.png
屏幕限定符适配2.png
4、布局限定符结合屏幕分辨率适配(或结合smallestWidth 限定符适配)
当多个加了限定符的 layout.xml中都引用了同一个 子布局,而子布局的内容可能相同,也可能不同。这个时候,使用 布局别名 可以节省操作量,即布局限定符适配配合屏幕分辨率适配(或smallestWidth 限定符适配)。如下图:
布局别名适配1.png 布局别名适配2.png5、其他常用适配方案
-
代码适配
通过java代码去获取屏幕的宽高,动态去指定控件的宽高。一般用于动态创建控件,或者自定义view自己绘制图形的时候。 -
接口适配
当你去向后台请求图片的时候,我们可以在参数中带入屏幕的宽高,或者是控件的宽高,来获取我们想要的图片,在图片返回之后直接就能显示得最优,而不需要我们app中修改任何代码。
五、流行的屏幕适配方案
(一)ScreenMatch屏幕适配方案
1、原理
ScreenMatch屏幕适配方案的原理=smallestWidth限定符适配的原理
2、使用方式
2.1 加载插件
- 方法一:在AndroidStudio中在线安装ScreenMatch插件
File -> Settings -> Plugins -> 在输入框中输入“ ScreenMatch”进行搜索 ->选择正确的结果install - 方法二:在AndroidStudio中离线线安装ScreenMatch插件
先到本地,然后执行File -> Settings -> Plugins -> Install plugin from disk 把本地插件安装到AndroidStudio
2.2 配置 screenMatch.propertities文件
base_dp = 设计图**最小宽度**(单位为 dp)
match_dp = 320,360,480,……这些尺寸最后会生成对应的values-sw320,values-sw360,values-sw480等文件夹下的dimens.xml文件
ignore_dp = 533,592,……因为插件会默认生成一些尺寸文件,这里标注后就会禁止生成对应的尺寸文件
如下图:
ScreenMatch配置文件.png2.3 生成dimens.xml文件
在需要适配的Module上单击右键选择ScreenMatch, 利用此插件生成所有设备对应的 dimens.xml 文件
2.4 在xml布局文件中使用
设计图中的标注是多少 dp,布局中就写多少dp,格式为@dimen/dp_XX。
3、优点
- 优点一:参照上述smallestWidth 限定符适配对屏幕分辨率限定符的比较优势
- 优点二:根据设计图的最小宽度配置基准dp后,设计图标注多少 dp,布局中就写多少 dp 。
- 优点三:可以一键生成所有需要适配的尺寸文件
4、缺点
- 缺点一:最小宽度为 392.7272 与 411.4285 的手机不能达到完全适配。原因是该插件的默认值都是取整的,即 392.7272 与 411.4285 在插件中写的是 392 与 411。
- 缺点二:因为是基于smallestWidth 限定符适配原理,所以只能根据最小宽度来设定基准dp,即只适用于短边固定,长边可滚动的UI设计图,这对手机来说没有问题,但是对横屏设计的平板来说就是一个缺陷,通常横屏设计的平板UI设计图是长边固定,短边可滚动,这就要求横屏设计的平板需要以最大宽度(即长边)来设定基准dp——ScreenMatch无法实现。
5、优化
- 针对缺点一,已经有大神在ScreenMatch插件基础上优化了代码,生成了,下载后使用AndroidStudio的Plugins离线安装方式安装。此时,如果需要适配的最小宽度值是小数,则可保留4位小数。例如 392.727272...则取 392.7272,即配置文件中base_dp=392.7272。
- 针对缺点二,如果要坚持使用smallestWidth 限定符适配原理的适配方案,只能让UI同事以最小宽度为基准来设计出图,或者在平板中的长边避免给大块区域的长度赋值为@dimen/dp_xx,而应使用match_parent/wrap_parent或权重尺寸。
(二)头条屏幕适配方案
1、原理
头条屏幕适配方案的原理就是调用Android API,根据设备某一维度(宽或高)的真实长度(单位是px)与这一维度在UI设计图上的dp值之间的关系,重新计算density来实现(density见上述关于“Android 中关于屏幕参数的相关API”的部分)。
回顾一下 px、dp、dpi、density之间的换算关系
- density = dpi / 160
- px = density * dp
- px = dp * (dpi / 160)
从dp和px的转换公式 :px = density * dp 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。
在API中 density=dpi/160,头条适配时,将给density重新赋值,即
density= 设备某一维度(宽或高)的真实长度(单位px)/UI设计图中这一维度的dp标注值
这样就可以计算出UI设计图中某一维度的1dp等于多少px,而且设备某一维度(宽或高)的真实长度(单位px)是可以通过Android API获取到的。
2、使用方式
首先创建 ScreenAdapterUtil.java 工具类,代码如下:
public class ScreenAdapterUtil {
private static float sRoncompatDennsity;
private static float sRoncompatScaledDensity;
private void setCustomDensity(@NonNull Activity activity, final @NonNull Application application) {
//application
final DisplayMetrics appDisplayMetrics = application.getResources().getDisplayMetrics();
if (sRoncompatDennsity == 0) {
sRoncompatDennsity = appDisplayMetrics.density;
sRoncompatScaledDensity = appDisplayMetrics.scaledDensity;
application.registerComponentCallbacks(new ComponentCallbacks() {
@Override
public void onConfigurationChanged(Configuration newConfig) {
if (newConfig != null && newConfig.fontScale > 0) {
sRoncompatScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
}
}
@Override
public void onLowMemory() {
}
});
}
//计算宽为360dp 同理可以设置高为640dp的根据实际情况
final float targetDensity = appDisplayMetrics.widthPixels / 360f;
final float targetScaledDensity = targetDensity * (sRoncompatScaledDensity / sRoncompatDennsity);
final int targetDensityDpi = (int) (targetDensity * 160);
appDisplayMetrics.density = targetDensity;
appDisplayMetrics.densityDpi = targetDensityDpi;
appDisplayMetrics.scaledDensity = targetScaledDensity;
//activity
final DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
activityDisplayMetrics.density = targetDensity;
activityDisplayMetrics.densityDpi = targetDensityDpi;
activityDisplayMetrics.scaledDensity = targetScaledDensity;
}
}
在BaseActivity(所有需要适配的Activity的基类)的onCreate()中,setContentView()之前使用鲜面这行代码:
ScreenAdapterUtil.setCustomDensity(this,getApplication());
3、优点
- 支持以宽或者高任意一个维度去适配,保持该维度上和设计图一致
- 支持dp和sp单位,控制迁移成本到最小
4、缺点
-
density的计算结果可能精度不够,从而导致适配出现偏差
由于头条的适配方案是以手机设备为测试标准,手机的宽度往往可以直接整除,而手机的高度(或平板的宽度)则不一定可以被整除,如果以手机高度(或平板宽度)作为density的计算因子,最后得出的density值可能会有小数。 但是今日头条给出的方法,做除法后结果会取整,从而会影响精度。 -
计算状态栏和导航栏的高度会出错
由于使用的是 Application#getResources,这会导致最后计算状态栏高度使用的是修改过后的 density。 -
跳出本应用后再返回,适配会失效
比如在应用内启动相机拍照(如上传头像),拍完照返回应用时,头条适配会失效。
5、优化
/**
* 头条适配方案优化版
*/
public class ScreenAdapterUtil {
private static float appDensity;
private static float appScaledDensity;
private static DisplayMetrics appDisplayMetrics;
private static int barHeight;
public static void setDensity(@NonNull Application application) {
//获取application的DisplayMetrics
appDisplayMetrics = application.getResources().getDisplayMetrics();
//获取状态栏高度
barHeight = ScreenUtil.getStatusBarHeight(application);//改变density前,提前获取状态栏的高度
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的话,直接用下面那个方法就好)
public static void setDefault(Activity activity) {
setAppOrientation(activity, ScreenUtil.WIDTH);
}
//此方法用于在某一个Activity里面更改适配的方向
public static void setOrientation(Activity activity, String orientation) {
setAppOrientation(activity, orientation);
}
/**
* targetDensity
* targetScaledDensity
* targetDensityDpi
* 这三个参数是统一修改过后的值
* <p>
* orientation:方向值,传入width或height
*/
private static void setAppOrientation(@Nullable Activity activity, String orientation) {
float targetDensity;
if (orientation.equals("height")) {
targetDensity = (appDisplayMetrics.heightPixels - barHeight) / 667f;//此处的667f替换为你的实际值
} else {
targetDensity = appDisplayMetrics.widthPixels / 360f;//此处的360f替换为你的实际值
}
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;
}
}
在这个类的初始化方法里面默认的以 宽度 来作为基准(这是在Activity中设置的方法,存在于此Activity下的fragment,dialog和PopupWindow都会受到此效果的影响,也就是说,在Activity中设置一次之后,Activity下的其他子View都无需再设置一次)。
优化后的使用方式:
在BaseApplication中初始化ScreenAdapterUtil
public class BaseApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
ScreenAdapterUtil.setDensity(this);
}
}
在BaseActivity(所有需要适配的Activity的基类)的onCreate()中,setContentView()之前使用鲜面这行代码:
//此方法默认以width作为基准方向
ScreenAdapterUtil.setDefault(this);
或者
//此方法指定以高度作为基准方向
ScreenAdapterUtil.setOrientation(this,ScreenUtil.HEIGHT);
我们一般来说做适配都是以手机的宽度为基准,但是一个app里面避免不了偶尔一两个页面是按照高度为基准(就是内容纵向填充全屏的页面)做适配的。但是上述方法只能保证一个方向,ScreenAdapterUtil.setOrientation 这个方法可以自由的切换适配的基准方向。
针对跳出应用后再返回适配失效的问题,粗暴的解决办法是在BaseActivity(所有需要适配的Activity的基类)的onResume()中 再次调用 ScreenAdapterUtil.setDefault(this)。比较优雅的方案是,在Application中通过 ActivityLifecycleCallbacks 来判断是否已经跳出又返回,有选择地在 onResume()调用 ScreenAdapterUtil.setDefault(this),而不是每次都调用。
附文资料
1、ScreenUtils.java代码
public class ScreenUtil {
public final static String WIDTH = "width";
public final static String HEIGHT = "height";
/**
* 获取屏幕宽度
*
* @param context Context
* @return 屏幕宽度(px)
*/
public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Point point = new Point();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
wm.getDefaultDisplay().getRealSize(point);
} else {
wm.getDefaultDisplay().getSize(point);
}
return point.x;
}
/**
* 获取屏幕高度
*
* @param context Context
* @return 屏幕高度(px)
*/
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Point point = new Point();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
wm.getDefaultDisplay().getRealSize(point);
} else {
wm.getDefaultDisplay().getSize(point);
}
return point.y;
}
/**
* 获取手机状态栏高度
*/
public 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;
}
/**
* 通过反射获取手机状态栏高度
*/
public static int getStatusBarHeightByReflect(Context context) {
Class<?> c = null;
Object obj = null;
Field field = null;
int x = 0, statusBarHeight = 0;
try {
c = Class.forName("com.android.internal.R$dimen");
obj = c.newInstance();
field = c.getField("status_bar_height");
x = Integer.parseInt(field.get(obj).toString());
statusBarHeight = context.getResources().getDimensionPixelSize(x);
} catch (Exception e) {
return 50;
}
return statusBarHeight == 0 ? 50 : statusBarHeight;
}
}
2、同一个布局文件中,我使用线性布局和相对布局都可以达成目的,那么如何抉择?
使用相对布局,很有可能出现 第一次测量"不满意"的情况,从而会测量第二次。如果两者都可以达成目的,并且两者的布局层级相同,并且线性布局中没有使用到权重(权重可能也会触发第二次测量),此时,优先使用线性布局。 除此之外,都选择相对布局。