Android 屏幕适配
传统dp适配方式的缺点
android中的dp在渲染前会将dp转为px,计算公式:
- px = density * dp(dp = px / density);
- density = dpi / 160;
- px = dp * (dpi / 160);
而dpi是根据屏幕真实的分辨率和尺寸来计算的,每个设备都可能不一样的
屏幕尺寸、分辨率、像素密度三者关系
通常情况下,一部手机的分辨率是宽x高,屏幕大小是以寸为单位,那么三者的关系是:
关系图举个例子:屏幕分辨率为:1920*1080,屏幕尺寸为5吋的话,那么dpi为440。
今日头条屏幕适配
根据dp和px的转换公式 :px = dp * density,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。
通过阅读源码,我们可以得知,density 是 DisplayMetrics 中的成员变量,而 DisplayMetrics 实例通过 Resources#getDisplayMetrics 可以获得,而Resouces通过Activity或者Application的Context获得。
先来熟悉下 DisplayMetrics 中和适配相关的几个变量:
- DisplayMetrics#density 就是上述的density
- DisplayMetrics#densityDpi 就是上述的dpi
- DisplayMetrics#scaledDensity 字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值。如果在系统设置中切换字体,再返回应用,字体并没有变化。于是还得监听下字体切换,调用 Application#registerComponentCallbacks 注册下 onConfigurationChanged 监听即可。
方案如下:
private static float sNoncompatDensity;
private static float sNoncompatScaledDensity;
private static void setCustomDensity(@NonNull Activity activity,@NonNull final Application application){
final DisplayDensity appDisplayMetrics = application.getResource().getDisplayMetrics();
if(sNoncompatDensity == 0){
sNoncompatDensity = appDisplayMetrics.density;
sNoncompatScaledDensity = appDisplayMetrics.scaledDensity;
application.registerComponentCallbacks(new ComponentCallbacks(){
@Overried
public void onConfigurationChanged(Configuration newConfig){
if(newConfig != null && newConfig.fontScale > 0){
sNoncompatScaledDensity = application.getResources().scaledDensity;
}
}
@Overried
public void onLowMemory(){
}
});
}
final float targetDensity = appDisplayMetrics / 360;
final float targetScaledDensity = targetDensity * (sNoncompatScaledDensity / sNoncompatDensity);
final int targetDensityDpi = (int)(160 * targetDensity);
appDisplayMetrics.density = targetDensity;
appDisplayMetrics.scaledDensity = targetScaledDensity;
appDisplayMetrics.densityDpi = targetDensityDpi;
final DisplayMetrics activityDisplayDetrics = activity.getResources().getDisplayMetrics();
activityDisplayDetrics.density = targetDensity;
activityDisplayDetrics.scaledDensity = targetScaledDensity;
activityDisplayDetrics.densityDpi = targetDensityDpi;
}
由于 API 26 及以上的 Activity#getResources()#getDisplayMetrics() 和 Application#getResources()#getDisplayMetrics() 是不同的引用,所以在 API 26 及以上适配是没有影响的,但在 API 26 以下 Activity#getResources()#getDisplayMetrics() 和 Application#getResources()#getDisplayMetrics() 是相同的引用,导致适配有问题。
如果我们以 xxhdpi 的 360dp 来适配的话,首先在 AS 中预览是个问题,在接入第三方 SDK 带有界面或者 View 的话会导致它的尺寸全然不对,因为我们那样适配后界面宽度只有 360dp,而第三方 SDK 中很有可能写的布局会超出 360dp,这便会引发新的问题,当然这也是有响应的解决之道,比如暂时取消适配,但我们有更好的方式,着重看下面介绍。
我着重推荐以 mdpi 为特例来适配,比如前面说到的 xxhdpi 的 360dp,那么在 mdpi 下就是 360 * 3 = 1080dp,这样我们新建一个宽为 1080px 的 mdpi 设备(可以通过修改设备尺寸来达到 mdpi),然后切换为该设备来预览布局就完美解决了以上问题,我们在写布局的时候设计图是 36px,那么我们直接就写 36dp 即可,设计图字体是 24px, 我们直接就写 24sp 即可,这样便可达到和设计图一致的效果。另外,图片资源放在需要适配的最高 dpi 下面即可,比如 drawable-xxhdpi 或者 drawable-xxxhdpi,这样在高清屏上也不会导致失真。
但是这样会导致获取状态栏和导航栏高度有问题,其获取状态栏高度代码为如下所示:
public static int getStatusBarHeight() {
Resources resources = Application.getResources();
int resourceId = resources.getIdentifier("status_bar_height", "dimen", "android");
return resources.getDimensionPixelSize(resourceId);
}
由于使用的是 Application#getResources,这会导致最后计算状态栏高度使用的是修改过后的 density,通过Resources.getSystem() 来获取系统的 Resources,果不其然可以获取到正确高度的状态栏高度,代码如下所示:
public static int getStatusBarHeight() {
Resources resources = Resources.getSystem();
int resourceId = resources.getIdentifier("status_bar_height", "dimen", "android");
return resources.getDimensionPixelSize(resourceId);
}
考虑到了 Resources.getSystem(),那么我们在适配上岂不是可以更方便,不用区分版本什么的 Activity#getResources()#getDisplayMetrics() 和 Application#getResources()#getDisplayMetrics(),也不需要什么中间变量来记录适配前的值,那些值我们直接在 Resources#getSystem()#getDisplayMetrics() 中获取 density、densityDpi、scaledDensity 即可,而且在修改系统字体的时候,Resources#getSystem()#getDisplayMetrics() 也会相应地改变,这样也就不需要注册 registerComponentCallbacks 来监听系统字体的改变,所以最终的源码很是简洁
最终方案:
/**
* Adapt the screen for vertical slide.
*
* @param activity The activity.
* @param designWidthInPx The size of design diagram's width, in pixel.
*/
public static void adaptScreen4VerticalSlide(final Activity activity,
final int designWidthInPx) {
adaptScreen(activity, designWidthInPx, true);
}
/**
* Adapt the screen for horizontal slide.
*
* @param activity The activity.
* @param designHeightInPx The size of design diagram's height, in pixel.
*/
public static void adaptScreen4HorizontalSlide(final Activity activity,
final int designHeightInPx) {
adaptScreen(activity, designHeightInPx, false);
}
private static void adaptScreen(final Activity activity,
final int sizeInPx,
final boolean isVerticalSlide) {
final DisplayMetrics systemDm = Resources.getSystem().getDisplayMetrics();
final DisplayMetrics appDm = Utils.getApp().getResources().getDisplayMetrics();
final DisplayMetrics activityDm = activity.getResources().getDisplayMetrics();
if (isVerticalSlide) {
activityDm.density = activityDm.widthPixels / (float) sizeInPx;
} else {
activityDm.density = activityDm.heightPixels / (float) sizeInPx;
}
activityDm.scaledDensity = activityDm.density * (systemDm.scaledDensity / systemDm.dens
activityDm.densityDpi = (int) (160 * activityDm.density);
appDm.density = activityDm.density;
appDm.scaledDensity = activityDm.scaledDensity;
appDm.densityDpi = activityDm.densityDpi;
}
/**
* Cancel adapt the screen.
*
* @param activity The activity.
*/
public static void cancelAdaptScreen(final Activity activity) {
final DisplayMetrics systemDm = Resources.getSystem().getDisplayMetrics();
final DisplayMetrics appDm = Utils.getApp().getResources().getDisplayMetrics();
final DisplayMetrics activityDm = activity.getResources().getDisplayMetrics();
activityDm.density = systemDm.density;
activityDm.scaledDensity = systemDm.scaledDensity;
activityDm.densityDpi = systemDm.densityDpi;
appDm.density = systemDm.density;
appDm.scaledDensity = systemDm.scaledDensity;
appDm.densityDpi = systemDm.densityDpi;
}
/**
* Return whether adapt screen.
*
* @return {@code true}: yes<br>{@code false}: no
*/
public static boolean isAdaptScreen() {
final DisplayMetrics systemDm = Resources.getSystem().getDisplayMetrics();
final DisplayMetrics appDm = Utils.getApp().getResources().getDisplayMetrics();
return systemDm.density != appDm.density;
}
建议
新老项目都可以用这套方案,老项目中如果有新的 Activity 加进来,那么可以对其使用该方案来适配,然后在启动其他老的 Activity 时候 cancelAdaptScreen 即可。新项目我建议采用我工具类中的使用,可以让你爽到极致,在 BaseActivity 中 setContentView(xx) 之前调用适配代码即可,记得第二个参数一定要传入设计图的实际像素尺寸,不再是曾经的 dp 尺寸了