androidGreenHand

V1

2022/04/07阅读:95主题:默认主题

本地版Android屏幕适配

本地版Android屏幕适配

字节跳动屏幕适配的思想

大佬实现的方案

本地app屏幕存在的问题

问题

目前我们的UI适配是按dp直接适配,因屏幕尺寸、屏幕密度碎片化,在不同屏幕UI显示效果的不一致性

原因分析

尺寸不一致原因直接使用密度无关像素 dp适配

密度类型 代表的分辨率(px) 屏幕密度(dpi) 换算(px/dp)
低密度(ldpi) 240x320 120 1dp=0.75px
中密度(mdpi) 320x480 160 1dp=1px
高密度(hdpi) 480x800 240 1dp=1.5px
超高密度(xhdpi) 720x1280 320 1dp=2px
超超高密度(xxhdpi) 1080x1920 480 1dp=3px

屏幕尺寸、分辨率、像素密度三者关系
一部手机的分辨率是宽x高,屏幕大小是以寸为单位,那么三者的关系是:

举个例子:屏幕分辨率为:1920*1080,屏幕尺寸为5吋的话,那么dpi为440。

若UI设计图是按屏幕宽度为360dp(720px)来设计的,那么在上述设备上,屏幕宽度其实为1080/(440/160)=392.7dp,实际屏幕是比设计图要宽的。

前言

此方案的具体核心也是字节跳动的屏幕适配思想,与大佬的实现方案不太一样,但也是参考AndroidAutoSize工程。阅读本文前,请先了解字节跳动屏幕适配的思想。

今日头条屏幕适配核心原理

今日头条屏幕适配方案的核心原理在于,根据以下公式算出density

当前设备屏幕总宽度(单位为像素/设计图总宽度(单位为 dp) = density

density的意思就是1dp占当前设备多少像素

为什么要算出 density,这和屏幕适配有什么关系呢?

public static float applyDimension(int unit, float value,
                                       DisplayMetrics metrics)

    
{
        switch (unit) {
        case COMPLEX_UNIT_PX:
            return value;
        case COMPLEX_UNIT_DIP:
            return value * metrics.density;
        case COMPLEX_UNIT_SP:
            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;
    }

不管你在布局文件中填写的是什么单位,最后都会被转化为px,系统就是通过上面的方法,将你在项目中任何地方填写的单位都转换为px的。

所以我们常用的px 转 dp 的公式 dp = px / density,就是根据上面的方法得来的,density 在公式的运算中扮演着至关重要的一步 要看懂下面的内容,还得明白,今日头条的适配方式 这时我们只以高或宽其中的一个作为基准进行适配,就会有效的避免布局在高宽比不一致的屏幕上出现变形的问题

density

density,density在每个设备上都是固定的,DPI/160=density,屏幕的总px宽度/density = 屏幕的总dp宽度

  1. 设备 1,屏幕宽度为 1080px,480DPI,屏幕总 dp 宽度为 1080 / (480 / 160) = 360dp

  2. 设备 2,屏幕宽度为 1440,560DPI,屏幕总 dp 宽度为 1440 / (560 / 160) = 411dp

可以看到屏幕的总dp宽度在不同的设备上是会变化的,但是我们在布局中填写的 dp 值却是固定不变的。

假设我们布局中有一个 View 的宽度为 100dp,在设备1中该View的宽度占整个屏幕宽度的27.8% (100 / 360 = 0.278)

但在设备2中该View的宽度就只能占整个屏幕宽度的 24.3% (100 / 411 = 0.243),

可以看到这个 View 在像素越高的屏幕上,dp 值虽然没变,但是与屏幕的实际比例却发生了较大的变化,所以肉眼的观看效果,会越来越小,这就导致了传统的填写 dp 的屏幕适配方式产生了较大的误差

这时我们要想完美适配,那就必须保证这个 View 在任何分辨率的屏幕上,与屏幕的比例都是相同的

如果每个View的dp值是固定不变的,那我们只要保证每个设备的屏幕总 dp 宽度不变,就能保证每个 View 在所有分辨率的屏幕上与屏幕的比例都保持不变,从而完成等比例适配,并且这个屏幕总 dp 宽度如果还能保证和设计图的宽度一致的话,那我们在布局时就可以直接按照设计图上的尺寸填写 dp 值

屏幕的总px宽度/density = 屏幕的总dp宽度

在这个公式中我们要保证 屏幕的总 dp 宽度 和 设计图总宽度 一致,并且在所有分辨率的屏幕上都保持不变,我们需要怎么做呢?屏幕的总 px 宽度每个设备都不一致,这个值是肯定会变化的,这时今日头条的公式就派上用场了

当前设备屏幕总宽度(单位为像素)/ 设计图总宽度(单位为 dp) = density

这个公式就是把上面公式中 屏幕的总dp宽度换成设计图总宽度,原理都是一样的,只要density根据不同的设备进行实时计算并作出改变,就能保证 设计图总宽度不变,也就完成了适配

简单的来说,就是将系统的以对角线适配,变成以宽适配。

适配效果

上是适配前 下是适配后

  1. 很明显看到在适配后,屏幕宽度相同的机型,视图的高度也一致

适配收益

  1. 解决了android屏幕尺寸碎片化的问题,目前剔除android8.0以下和折叠屏外适配了同镇用户设备机型95%以上(数据详情见适配范围)
  2. 真正做到按UI设计图设置dp和sp值就可保证适配UI一致性效果
  3. 支持业务按界面activity进行适配

核心代码

 internal fun reflectResources(activity: Activity, designWidthInDp: Float = 0f): Boolean {
            val resources = activity.resources

            val screenAdaptationData =
                ScreenAdaptationData(activity, designWidthInDp)

            try {
                val activityClass = Class.forName("android.view.ContextThemeWrapper")
                val mResourcesFiled = activityClass.getDeclaredField("mResources")
                mResourcesFiled.isAccessible = true

                val customResourcesImpl = ScreenAdaptationResourcesImpl(
                    resources.assets,
                    resources.displayMetrics.apply {
                        density = screenAdaptationData.targetDensity
                        densityDpi = screenAdaptationData.targetDensityDpi
                        scaledDensity = screenAdaptationData.targetScaleDensity
                    },
                    resources.configuration,
                    screenAdaptationData
                )

                val systemResourcesClass = Class.forName("android.content.res.Resources")
                val mResourcesImplFiled = systemResourcesClass.getDeclaredField("mResourcesImpl")
                mResourcesImplFiled.isAccessible = true
                val systemResources = mResourcesImplFiled.get(resources)
                mResourcesImplFiled.set(customResourcesImpl, systemResources)

                removeResourcesImpl(systemResources)

                mResourcesFiled.set(activity as ContextThemeWrapper, customResourcesImpl)

            } catch (e: Exception) {
                Log.e(TAG, e.toString())
                Log.e(TAG, "反射失败,屏幕适配结束")
                return false
            }
            return true
internal class ScreenAdaptationResourcesImpl(
    assets: AssetManager,
    private val metrics: DisplayMetrics,
    config: Configuration,
    private val mScreenAdaptationData: ScreenAdaptationData
)
 : Resources(assets, metrics, config) 
{


    override fun getDisplayMetrics(): DisplayMetrics {

        return metrics.apply {
            density = mScreenAdaptationData.targetDensity
            densityDpi =
                mScreenAdaptationData.targetDensityDpi
            scaledDensity =
                mScreenAdaptationData.targetScaleDensity
        }
    }

    override fun getValueForDensity(
        id: Int,
        density: Int,
        outValue: TypedValue?,
        resolveRefs: Boolean
    )
 
{
        resetDisplayMetric()
        super.getValueForDensity(id, density, outValue, resolveRefs)
    }

    override fun getDimension(id: Int): Float {
        resetDisplayMetric()
        return super.getDimension(id)
    }

    override fun getDimensionPixelOffset(id: Int): Int {
        resetDisplayMetric()
        return super.getDimensionPixelOffset(id)
    }


    override fun getDimensionPixelSize(id: Int): Int {
        resetDisplayMetric()
        return super.getDimensionPixelSize(id)
    }


    override fun updateConfiguration(config: Configuration?, metrics: DisplayMetrics?) {
      //  super.updateConfiguration(config, metrics)
        Log.d("gzp","updateConfiguration")
    }
    private fun resetDisplayMetric() {
        super.getDisplayMetrics().apply {
            density = mScreenAdaptationData.targetDensity
            densityDpi =
                mScreenAdaptationData.targetDensityDpi
            scaledDensity =
                mScreenAdaptationData.targetScaleDensity
        }
    }
}

适配规范

开发人员

按页面适配的尺寸,如375,将设计图的宽度设置为375dp后,再进行布局。

设计人员

请按照此页面与开发人员协定好的宽度进行设计产品需求

适配范围

适配范围:native和RN都适用,Web页面不适配

适配系统:Android8.0以上(包含Android8.0手机) 8.0以下手机用户占比较少(3%)

适配宽度:屏幕宽度1440以上(包含1440)不适配 屏幕宽度1440以上占比1%

适配页面:适配整个Activity(包括Fragment,dialog,Toast)等

RN适配原因

RN页面默认的布局单位也是dp,在真正布局的时候也是将dp转换成px,因此也适用RN页面

@ReactPropGroup(
        names = {
                ViewProps.BORDER_WIDTH,
                ViewProps.BORDER_LEFT_WIDTH,
                ViewProps.BORDER_RIGHT_WIDTH,
                ViewProps.BORDER_TOP_WIDTH,
                ViewProps.BORDER_BOTTOM_WIDTH,
                ViewProps.BORDER_START_WIDTH,
                ViewProps.BORDER_END_WIDTH,
        },
        defaultFloat = YogaConstants.UNDEFINED)
public void setBorderWidth(ReactViewGroup view,int index,float width){
        if(!YogaConstants.isUndefined(width)&&width< 0){
             width=YogaConstants.UNDEFINED;
        }

        if(!YogaConstants.isUndefined(width)){
             width=PixelUtil.toPixelFromDIP(width);
        }

        view.setBorderWidth(SPACING_TYPES[index],width);
}

public class PixelUtil {
  
   public static float toPixelFromDIP(float value) {
      return TypedValue.applyDimension(1, value, DisplayMetricsHolder.getWindowDisplayMetrics());
   }
}

public static float applyDimension(int unit,float value,
        DisplayMetrics metrics)
{
           switch(unit){
           case COMPLEX_UNIT_PX:
           return value;
           case COMPLEX_UNIT_DIP:
           return value*metrics.density;
           case COMPLEX_UNIT_SP:
           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;
}

SDK 使用

基础使用

  1. Application 中 初始化 (非必须)
  2. 需要适配的Activity#attachBaseContext()中调用SDK方法:
    ScreenAdaptationUtil.Companion.currentActivityScreenAdaptation(this,375f);
  3. 如适配整个Application,Activity基类中调用此方法

进阶用法

  1. 横竖屏切换:横屏使用竖屏布局(也就是适配后的竖屏布局) android:configChanges的属性至少包含orientation|screenSize 这样Activity将不会重建 只会触发onConfigurationChanged方法
  2. 横竖屏切换:横屏不使用竖屏布局 android:configChanges的属性去掉 orientation|screenSize 这样Activity将会重建 触发onCreate方法

sdk地址

https://igit.58corp.com/com.wuba.wuxian.sdk/ScreenAdaptation

注意事项

  1. 适配整个Activity(包括Fragment,dialog,Toast)等
  2. 对Web页面不适配,Web页面使用的ResourcesImpl和Activity的ResourcesImpl不是同一个对象
  3. 适配android8.0以上(包含8.0手机),8.0以下手机用户占比较少(6%)
  4. 不建议对每个Fragment单独做适配

后期计划

  1. 支持横竖屏切换
  2. 系统改变显示大小或者更改字体大小后的适配
  3. 支持折叠屏和平板
  4. 层级更细腻,对Fragment级别的适配

对比第三方 AndroidAutoSize 存在的问题

1. 屏幕适配失效,页面布局异常(严重)

屏幕适配失效,页面布局异常
屏幕适配失效,页面布局异常
产生原因

AndroidAutoSize 是每次在加载布局时,修改 Resources 的DisplayMetrics#density,重新设置新的属性值。设置的新值会被系统或者第三库修改回去,导致适配失效。

解决方案

从set时机变成get时机。也就是我们确保适配页面取DisplayMetrics#density属性时,拿到的是自定义的值,每次在getDisplayMetrics()时,我们将此属性设置成为自定义的,就解决了上述问题。

解决问题
解决的问题
解决的问题

2.Dialog 和Toast适配问题 和多个Fragment 适配问题(严重)

产生原因

AndroidAutoSize 是每次在setContentView 之前对DisplayMetrics#density进行修改,要是对dialog或者Toast,fragment适配,需要在布局加载之前,单独调用方法适配。

解决方案

此方案是对Activity#Resources资源修改,只要此页面的弹窗和toast 上下文环境使用的是Activity的context,都会进行适配。

解决问题:
解决的问题
解决的问题

3. 当页面有WebView屏幕适配失效(严重)

产生原因:Resources 中的ResourcesImpl 是复用的。
1. 因此当修改此页面时,会导致对其他页面也进行修改。
2. 或者进入web页面时,Resources不再复用,重新生成ResourcesImp,用的是系统默认的DisplayMetrics#density
解决方案

将适配页面的ResourcesImpl从ResourcesImpl缓存池中移除

解决问题:

4. 状态栏高度问题 (较轻)

产生原因

Activity的DisplayMetrics#density 修改后,状态栏的高度将改变,用

解决方案

用Application的context获取状态栏的高度

解决问题:
解决的问题
解决的问题

5. 针对宽屏幕手机或者平板等适配问题(较轻)

产生原因

手机屏幕宽度过大,字体和图片将被等比拉伸,导致适配效果不理想。

解决方案

增加白名单,屏幕尺寸超过某阈值时,将不再适配。

解决问题
解决的问题
解决的问题

分类:

移动端开发

标签:

移动端开发

作者介绍

androidGreenHand
V1