Android 屏幕适配总结
为什么需要屏幕适配?
-
我们为什么需要屏幕适配,而不是屏幕越大应该显示更多的内容呢?不仅是我会有这样的疑问,我相信很多人和我一样有同样的疑,由于在Android开发中,由于Android设备碎片化非常严重,屏幕分辨率不计其数,而想要在各种分辨率的设备上保持UI元素一致的效果,具体表示就是:
1、屏幕尺寸的碎片化,市面上有720x1280、1080x1920等等。
2、屏幕的密度,有可能就是屏幕尺寸一样但是它的屏幕的密度是不一样的;
3、国内厂商比如OPPO、华为等,我们需要去适配的刘海屏和水滴屏等等; -
市面上有多各种适配方案,比如谷歌推荐的dp、今日头条、百分比和最小宽度适配等方案,我以前使用最小宽度适配方案,现在不是了,这段时间也在调研适配方案,最后总结出了,一句话,没有最好的适配方案,只有合适的自己方案,我们现在项目就用了多种方案结合使用,感觉效果不错,所以就有了这篇文章,下面来看看一些前置知识。
###android.util.TypedValue public static float applyDimension(int unit, float value, DisplayMetrics metrics) { switch (unit) { case COMPLEX_UNIT_PX:// px = px return value; case COMPLEX_UNIT_DIP://px = density * dp return value * metrics.density; case COMPLEX_UNIT_SP:// px = density * dp 默认情况下scaledDensity = density 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;
}
所以系统中,最后不管你是使用dp还是还是sp最终还是会被转为px,所以我们适配的方案就有了,下面我们来看看一些概念。
android中的dp在渲染前会将dp转为px,计算公式:
- px = density * dp;
- density = dpi / 160;
- px = dp * (dpi / 160);
而dpi是根据屏幕真实的分辨率和尺寸来计算的,每个设备都可能不一样的,我觉得dpi更像是作为一种标准出现。例如Android手机的:160dpi、320dpi、440dpi、480dpi,而density 在每个设备上都是固定的,dpi / 160 = density,屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度。
自定义像素适配
自定义像素适配就是以一个特定宽度和高度尺寸的设备为参考,实际上就以设计师给的设计图为参考,根据这个参考值换算出缩放比例,举个例子:
假设参考尺寸为720px*1280px,
自定义像素.png
我们想要的效果是360x360在720px*1280px的显示的UI元素是屏幕的一般,在1080x1920上显示的是540x540,但是在1080x1920上显示却只有1/3的宽度这并不是我么想要的效果,计算方法:
- 假如你当前屏幕宽度是720px上显示在一半: 720px / 720px * 360px = 360px;
- 假如你当前屏幕宽度是1080px上显示在一半: 1080px / 720px * 360px = 540px;
- 假如你当前屏幕宽度是1920px上显示在一半: 1920px / 720px * 360px = 690px;
- 假如你当前屏幕宽度是1440px上显示在一半: 1440px / 720px * 360px = 720px;
- 设计稿的值永远按照720px标准,它会自行缩放,怎么计算,首先我们应该要知道参考尺寸和当前屏幕的尺寸,代码如下:
public class UiPx2PxScaleAdapt {
private static UiPx2PxScaleAdapt mUIAdaptUtil;
private static float DEFAULT_STANDARD_WIDTH = 375;//px
private static float DEFAULT_STANDARD_HEIGHT = 667;
//设计图参考尺寸
private static float standardWidth = DEFAULT_STANDARD_WIDTH;//px
private static float standardHeight = DEFAULT_STANDARD_HEIGHT;
//这里是屏幕显示宽高
private static int mDisplayWidth;
private static int mDisplayHeight;
private UiPx2PxScaleAdapt(Context context) {
if (mDisplayWidth == 0 || mDisplayHeight == 0) {
WindowManager manager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics metrics = new DisplayMetrics();
manager.getDefaultDisplay().getMetrics(metrics);
if (metrics.widthPixels > metrics.heightPixels) {//横屏
mDisplayWidth = metrics.heightPixels;
mDisplayHeight = metrics.widthPixels;
} else {
mDisplayWidth = metrics.widthPixels;
mDisplayHeight = metrics.heightPixels - getStatusBarHeight(context);//为了精确一点呢,可以把状态栏高度减掉
}
}
}
public static UiPx2PxScaleAdapt adapt(Context context) {
if (UiPx2PxScaleAdapt.mUIAdaptUtil == null) {
synchronized (UiPx2PxScaleAdapt.class) {
if (UiPx2PxScaleAdapt.mUIAdaptUtil == null) {
UiPx2PxScaleAdapt.mUIAdaptUtil = new UiPx2PxScaleAdapt(context.getApplicationContext());
}
}
}
return mUIAdaptUtil;
}
public int getVerticalAdaptResult(int needValuePx) {
return Math.round((needValuePx * getVerticalScale()));
}
public int getHorizontalAdaptResult(int needValuePx) {
return Math.round(needValuePx * getHorizontalScale());
}
/**
* 修改设计图参考尺寸
*
* @param standardWidth 设计图参考宽度 单位px
*/
public UiPx2PxScaleAdapt standardWidth(float standardWidth) {
UiPx2PxScaleAdapt.standardWidth = standardWidth;
return this;
}
/**
* 修改设计图参考尺寸
*
* @param standardHeight 设计图参考高度 单位px
*/
public UiPx2PxScaleAdapt standardHeight(float standardHeight) {
UiPx2PxScaleAdapt.standardHeight = standardHeight;
return this;
}
/**
* @return 获取水平方向的缩放比例
*/
public float getHorizontalScale() {
return mDisplayWidth / standardWidth;
}
/**
* @return 获取垂直方向的缩放比例
*/
public float getVerticalScale() {
return mDisplayHeight / standardHeight;
}
private int getStatusBarHeight(Context context) {
int resId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resId > 0) {
return context.getResources().getDimensionPixelSize(resId);
}
return 0;
}
}
代码很简单,就是我们通过当前屏幕的宽和高,然后使用当前屏幕的宽和高除以参考尺寸得到缩放比例,然后在使用缩放比例乘以具体设置的尺寸。
<!--基本设计720,想要显示一半填写360px-->
<TextView
android:layout_width="360px"
android:layout_height="wrap_content"
android:background="@color/colorAccent"
android:text="@string/app_name" />
开发的时候只要按照设计图填写尺寸就行了最后封装成工具类。
public class UiAdaptCalPx2PxUtil {
public static void setTextSize(TextView view, int size) {
int adaptResult = UiPx2PxScaleAdapt.adapt(view.getContext()).getVerticalAdaptResult(size);
view.setTextSize(TypedValue.COMPLEX_UNIT_PX, adaptResult);
}
public static void setUIAdaptPx2Px(View dstView,
int width,
int height,
int topMargin,
int bottomMargin,
int lefMargin,
int rightMargin) {
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) dstView.getLayoutParams();
if (width != ViewGroup.LayoutParams.MATCH_PARENT &&
width != ViewGroup.LayoutParams.WRAP_CONTENT) {
layoutParams.width =
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getHorizontalAdaptResult(width);
} else {
layoutParams.width = width;
}
if (height != ViewGroup.LayoutParams.MATCH_PARENT &&
height != ViewGroup.LayoutParams.WRAP_CONTENT) {
layoutParams.height =
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getVerticalAdaptResult(height);
} else {
layoutParams.height = height;
}
layoutParams.setMargins(
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getHorizontalAdaptResult(lefMargin),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getVerticalAdaptResult(topMargin),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getHorizontalAdaptResult(rightMargin),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getVerticalAdaptResult(bottomMargin));
dstView.setLayoutParams(layoutParams);
dstView.setPadding(
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getHorizontalAdaptResult(dstView.getPaddingLeft()),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getVerticalAdaptResult(dstView.getPaddingTop()),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getHorizontalAdaptResult(dstView.getPaddingRight()),
UiPx2PxScaleAdapt.adapt(dstView.getContext()).getVerticalAdaptResult(dstView.getPaddingBottom()));
}
/**
* 在布局中指定尺寸的
*
* @param dstViews
*/
public static void setUIAdaptPx2PxLayout(View... dstViews) {
if (dstViews.length <= 0) return;
for (View dstView : dstViews) {
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) dstView.getLayoutParams();
if (layoutParams == null) continue;
setUIAdaptPx2Px(dstView, layoutParams.width, layoutParams.height,
layoutParams.topMargin, layoutParams.bottomMargin,
layoutParams.leftMargin, layoutParams.rightMargin
);
}
}
}
可能有人会说太麻烦了,那我要开始装逼了,根据上面介绍的,都是需要开发者手动去设置尺寸,那么有没有不用手动去设置呢?方案是人想出来的,一般我们开发者写布局UI都是无非就是会加载到DecorView的contentiew上不管是Dialog还是Activity或者Fragment,我们肯定能拿到content布局,或者DecorView的ContentView,那为何我们不通过content或者ContentView,遍历所有的child,从而修改布局尺寸呢?并且我们还可以把Activity或者Fragment抽取为BaseXXX,当我么加载布局时,做到无感自动完成适配?,但是这种方式有一个缺点就是,会把第三方库也给适配,会造成预想不到的效果,所以建议使用手动的方式。
/**
* 把适配放到Base中自动完成适配 指定的是开发者定义的布局的根布局
*
* @param contentView 或者开发者在xml中定义的根布局
*/
public static void setUIAdaptPx2PxContentView(View contentView) {
if (contentView == null) return;
if (contentView instanceof ViewGroup) {
//修改ViewGroup本身的
ViewGroup viewGroup = (ViewGroup) contentView;
ViewGroup.MarginLayoutParams vplp = (ViewGroup.MarginLayoutParams) viewGroup.getLayoutParams();
setUIAdaptPx2Px(viewGroup, vplp.width, vplp.height,
vplp.topMargin, vplp.bottomMargin,
vplp.leftMargin, vplp.rightMargin
);
// 修改child本身的
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = viewGroup.getChildAt(i);
if (child instanceof ViewGroup) {
setUIAdaptPx2PxContentView(child);
} else {
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) child.getLayoutParams();
if (layoutParams == null) continue;
setUIAdaptPx2Px(child, layoutParams.width, layoutParams.height,
layoutParams.topMargin, layoutParams.bottomMargin,
layoutParams.leftMargin, layoutParams.rightMargin
);
}
}
}
}
如果你使用Kotlin,那么这样使用:
view.adapt().adaptLeftMargin(13).adaptRightMargin(11).adaptTextSize(11)
Kotlin拓展函数:
fun View.adapt(): AdaptBuilder {
if (id == NO_ID) id = generateViewId()
val tag = getTag(id)
if (tag == null) {
val builder = AdaptBuilder(this)
setTag(this.id, builder)
return builder
}
return tag as AdaptBuilder
}
//AdaptBuilder
class AdaptBuilder(private val adaptView: View) {
private val lp: ViewGroup.MarginLayoutParams = adaptView.layoutParams as ViewGroup.MarginLayoutParams
fun adaptWidth(@Px width: Int): AdaptBuilder {
if (width != ViewGroup.LayoutParams.MATCH_PARENT && width != ViewGroup.LayoutParams.WRAP_CONTENT) {
lp.width = UiPx2PxScaleAdapt.getHorizontalAdaptResult(width.toFloat())
} else {
lp.width = width
}
return this
}
fun adaptHeight(@Px height: Int): AdaptBuilder {
if (height != ViewGroup.LayoutParams.MATCH_PARENT && height != ViewGroup.LayoutParams.WRAP_CONTENT) {
lp.height = UiPx2PxScaleAdapt.getVerticalAdaptResult(height.toFloat())
} else {
lp.height = height
}
return this
}
fun adaptTopMargin(@Px topMargin: Int): AdaptBuilder {
lp.setMargins(
lp.leftMargin,
UiPx2PxScaleAdapt.getVerticalAdaptResult(topMargin.toFloat()),
lp.rightMargin,
lp.bottomMargin
)
return this
}
fun adaptLeftMargin(@Px lefMargin: Int): AdaptBuilder {
lp.setMargins(
UiPx2PxScaleAdapt.getHorizontalAdaptResult(lefMargin.toFloat()),
lp.topMargin,
lp.rightMargin,
lp.bottomMargin
)
return this
}
fun adaptBottomMargin(@Px bottomMargin: Int): AdaptBuilder {
lp.setMargins(
lp.leftMargin,
lp.topMargin,
lp.rightMargin,
UiPx2PxScaleAdapt.getVerticalAdaptResult(bottomMargin.toFloat())
)
return this
}
fun adaptRightMargin(@Px rightMargin: Int): AdaptBuilder {
lp.setMargins(
lp.leftMargin,
lp.topMargin,
UiPx2PxScaleAdapt.getHorizontalAdaptResult(rightMargin.toFloat()),
lp.bottomMargin
)
return this
}
fun adaptPaddingLeft(@Px setPaddingLeft: Int): AdaptBuilder {
adaptView.setPadding(
UiPx2PxScaleAdapt.getHorizontalAdaptResult(setPaddingLeft.toFloat()),
adaptView.paddingTop, adaptView.paddingRight, adaptView.bottom
)
return this
}
fun adaptPaddingRight(@Px setPaddingRight: Int): AdaptBuilder {
adaptView.setPadding(
adaptView.left,
adaptView.paddingTop,
UiPx2PxScaleAdapt.getHorizontalAdaptResult(setPaddingRight.toFloat()),
adaptView.bottom
)
return this
}
fun adaptPaddingTop(@Px setPaddingTop: Int): AdaptBuilder {
adaptView.setPadding(
adaptView.left,
UiPx2PxScaleAdapt.getVerticalAdaptResult(setPaddingTop.toFloat()),
adaptView.paddingRight,
adaptView.bottom
)
return this
}
fun adaptPaddingBootom(@Px setPaddingBottom: Int): AdaptBuilder {
adaptView.setPadding(
adaptView.left,
adaptView.paddingTop,
adaptView.paddingRight,
UiPx2PxScaleAdapt.getVerticalAdaptResult(setPaddingBottom.toFloat())
)
return this
}
/**
*字体缩放比列:按照方向适配,比如:如果你想要在换行时准确,那么就是用水平方向的,否则垂直
*/
fun adaptTextSize(size: Float, isVertical: Boolean = false): AdaptBuilder {
if (adaptView is TextView) {
val adaptResult = UiPx2PxScaleAdapt.getVerticalAdaptResult(size)
val adaptResult2 = UiPx2PxScaleAdapt.getHorizontalAdaptResult(size)
adaptView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (if (isVertical) adaptResult else adaptResult2).toFloat())
}
return this
}
/**
* 如果是已经完成了加载并绘制了,那么需要调用此方法重新测量绘制
* 如果没有完成加载可调用可不用
*/
fun buildAdapt(): ViewGroup.MarginLayoutParams {
adaptView.layoutParams = lp
return lp
}
}
小结
大家知道我们在代码中填写的尺寸,无非就是width、height、margin和pandding,所以在代码中可以使用这个工具类完成,而使用自定义像素适配的好处就是非常完美的适配所有的尺寸,能够满足同时适配水平和垂直两个方向,而目前是,面上基本就是一个方向上适配,如果你使用Kotlin,就非常的简单了。
修改系统density,densityDpi适配
以前我不知道居然还能修改系统density,densityDpi适配,根据前面的介绍的前置知识,我们知道,不管你在布局填写dp和是sp最终还是转为px,计算公式: px = density * dp, density = dpi / 160, px = dp * (dpi / 160),而density 的意思就是 1 dp 占当前设备多少像素,而屏幕的总 dp 宽度在不同的设备上是会变化的,但是我们在布局中填写的 dp 值却是固定不变的,所以我们能不能根据px = density * dp公式,把dp看成固定设计图的尺寸,那么 density = px/ dp,如果在屏幕的尺寸和我们,即:当前设备屏幕总宽度(单位为px)/ 设计图总宽度(单位为 dp) = density,就是想前面讲的根据:当前设备屏幕总宽度(单位为px) / 设计图总宽度 (单位为 px) * 具体的值(单位为 px) = px是一样的,比如:
-
假如你当前屏幕宽度是720px,设计图是720dp,要显示的效果是360dp: 720px / 720pdp = 1.0,最终的显示的效果由公式:px = density * dp,1.0 x 360 = 360px
-
假如你当前屏幕宽度是1080px,设计图是720dp,要显示的效果是360dp: 1080px / 720pdp = 1.5,最终的显示的效果由公式:px = density * dp,1.5 x 360 = 540px
验证的结果就是宽度刚好是屏幕的一半,具体看看那代码:
public class Density {
private static float WIDTH = 375; //参考设备的宽,单位是dp 1440dp / 2 = 187.5dp 居中
private static float appDensity = 0f;
private static float appScaleDensity = 0f;
private static int appDensityDpi = 0;
public static void adaptDensity(Application app, Activity activity) {
//获取当前app的屏幕显示信息
DisplayMetrics appMetrics = app.getResources().getDisplayMetrics();
if (appDensity == 0) {
appDensity = appMetrics.density;
appScaleDensity = appMetrics.scaledDensity;
appDensityDpi = appMetrics.densityDpi;
}
Log.e("TAG", appMetrics.widthPixels + " " + appMetrics.density);
//计算目标值density, scaleDensity, densityDpi
float targetDensity = appMetrics.widthPixels / WIDTH;
// 默认 density 和 scaledDensity 相等
float targetScaleDensity = targetDensity * (appScaleDensity / appDensity);
//dpi = density * 160
int targetDensityDpi = (int) (targetDensity * 160);
Log.e("TAG", "" + targetDensity);
//替换Activity的density, scaleDensity, densityDpi
DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics();
displayMetrics.density = targetDensity;
displayMetrics.scaledDensity = targetScaleDensity;
displayMetrics.densityDpi = targetDensityDpi;
Log.e("TAG", displayMetrics.widthPixels + " " + displayMetrics.density);
}
/**
* 为了解决对第三方库的适配的影响,我们可以取消Density适配,然后使用自定义像素适配
* <p>
* 取消density适配
*
* @param activity
*/
public static void cancelAdaptDensity(Activity activity) {
DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics();
displayMetrics.density = appDensity;
displayMetrics.scaledDensity = appScaleDensity;
displayMetrics.densityDpi = appDensityDpi;
}
}
这个方案的优点是侵入性和成本低,但是缺点就是,这种修改是全局性的,但凡有一些第三方库的参考尺寸和我们的不一致,那么这个方案就会失效,为了解决对第三方库的适配的影响,我们是可以取消Density适配,然后结合前面讲的自定义像素适配,所以前面我说了没有完美的适配方案,只有更适合,我们项目中就使用两种适配方案结合使用。
总结
- 1、自定义像素目前我觉得应该是最好的适配方式,能够满足同时适配水平和垂直两个方向,同时不会受一些第三方库影响,缺点就是开发者会写更多的代码,但是我觉得也不算缺点;
- 2、修改系统density这种适配方案,只能同时适配(水平或垂直)方向,有点是侵入和适配成本低,缺点就是受第三方库影响;
- 3、百分比布局适配方案,就是根据父容器尺寸作为参考,在View的加载过程中,根据父容器的实际尺寸换算出目标尺寸,在作用到View上,这种方案侵入性强,在适配中还要开发者自行计算比例,成本太高,不能精确的做到是适配,比如我想这个Button距离左边20dp,那百分比就做不到,百分比就是通过父容器的width和height然后剩余指定的百分比,并不能作为任何场景的适配。,感兴趣的可以看着片文章屏幕适配?不如手写个百分比布局;
- 4、最小宽度适配方案,这种方案缺点就是侵入性强,会有引入大量的适配资源,增大app体积;
最后我想说的是没有完美的适配方案,只有更适合的: