Android屏幕适配的前世今生(三)

/ Android必知必会 / 没有评论 / 453浏览

上篇一口气没把所有的适配方案介绍完,今天来收个尾。

smallestwidth限定符

smallestwidth限定符和我们上篇提到的宽高限定符的套路一样, 只不过是以dp作为单位,并以设备最短边的dp值作为匹配依据。

设计稿以iphone6为标准,屏幕尺寸为4.7英寸,分辨率为750x1334density2

我们先将设计稿宽度像素值转换dp值。 px怎么转dp还记得否?公式再来一次。

px = dp * densityDpi / 160 = dp * density 
750px = 750 / density = 375dp

接着,我们计算待适配手机的最小宽度dp值,我们还是以小米9Pixel xl为例:

机型分辨率屏幕尺寸ppidpi
小米91080x23406.39英寸403440
Pixel xl1440x25605.5英寸534560

1080<2340, 所以小米9最小宽度取1080px, 转换为dp值为:

1080px = 1080 / (densityDpi / 160 ) = 1080 / (440 / 160) = 393dp

1440<2560, 所以Pixel xl最小宽度取1440px, 转换为dp值为:

1440px = 1440 / (densityDpi / 160 ) =1440 / (560 / 160) = 411dp

如设计稿中某控件宽度为10dp,为了保证比例,我们需要将设计稿dp值转换为实际dp值,转换公式为:

实际dp =  设计稿尺寸 * (设备最小宽度/设计稿最小宽度) 

那么在小米9上应该是10.48dp = (10 * 395/375)

Pixel xl上应该是10.96dp =(10* 560/375)

接着我们在values-sw393dpdimens文件和values-sw411dpdimens文件中分别定义10dp转换后的值。

      res/
          values/
            dimens.xml 
          values-sw393dp/
            dimens.xml --> <dimen name="dp_10">10.48dp</dimen>
          values-sw411dp/
            dimens.xml --> <dimen name="dp_10">10.96dp</dimen>

这样app在运行时,就根据最小宽度取对应资源文件夹下的尺寸了。且视觉效果保持一致:

10/375 = 10.48/393 = 10.93/411 ≈ 2.66%

在实际开发中我们怎么操作呢?跟宽高限定符类似。

  1. 计算设计稿的最小宽度dp值N
  2. 枚举所有待适配继续最小宽度的dp值W, 创建values-sw${W}dp文件夹;
  3. 将最小宽度分成N份,通过转换公式,计算每份在各机型上的实际dp值;

同样上述的步骤随便写个脚本就可以自动生成, 我们把上篇的demo改改

package cc.wecando.dpadaptergenerator


import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.math.BigDecimal


class DpAdapterGenerator(private val designWidth: Int, private val adapterSmallWidths: List<Int>) {
    fun generate() {
        adapterSmallWidths.forEach {
            val endFix = "sw${it}dp"

            val dir = File("values-${endFix}")
            if (!dir.exists() || !dir.isDirectory) {
                dir.mkdirs()
            }
            val dimensFile = File(dir, "dimens.xml")
            if (dimensFile.exists()) {
                dimensFile.delete()
            }

            val bufferedWriter = BufferedWriter(FileWriter(dimensFile))
            bufferedWriter.write("""<?xml version = "1.0" encoding = "utf-8"?>""")
            bufferedWriter.newLine()
            bufferedWriter.write("""    <resources >""")
            bufferedWriter.newLine()

            for (i in 1..designWidth) {
                val dp = BigDecimal(it).multiply(BigDecimal(i)).divide(BigDecimal(designWidth))
                    .setScale(2, BigDecimal.ROUND_HALF_UP).toFloat()

                bufferedWriter.write("""     <dimen name="dp_${i}">${dp}dp</dimen>""")
                bufferedWriter.newLine()
            }

            bufferedWriter.write("""     </resources>""")
            bufferedWriter.close()
        }

    }
}


fun main() {
    val adapterSizes = listOf(
        393,
        411
    )
    val generator = DpAdapterGenerator(375, adapterSizes)
    generator.generate()
}

alt

smallestwidth限定符原理与宽高限定符相同,都是利用Android资源匹配规则,实现在不同设备上使用不同的dp值。我们需要做的就是针对最小宽度的设备,计算每份对应的dp值。

同样,他的缺点跟宽高限定符差不多, 并且只能以最小宽度一个维度进行适配。

今日头条方案

我们屏幕适配的核心是什么? 无非是将设计稿上的尺寸,经过转换,保证在各种分辨率上 转换后的尺寸所占比例与设计稿保持一致。

这个转换过程,宽高限定符,smallestwidth限定符是利用Android资源匹配策略。AndroidAutoLayout是通过自定义控件,收集所有尺寸相关的属性动态转换。

回过头想想, Android Api本身不就有这种转换过程吗?

第一篇文章我们已经分析过了dppxsppxAndroid框架中计算尺寸最终都会调用TypedValue#applyDimension方法。 将各种单位的尺寸转换px

    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;
    }

其实今日头条的方案原理超级简单,就是利用这里的转换做文章。

dp举例,布局文件中的dp值,最终会转换为px。

        case COMPLEX_UNIT_DIP:
            return value * metrics.density;

假如宽度为designWidth = 750pxdensity2designWidthDpi=375dp的设计稿中,定义一个空间宽度为designDp =10dp

在布局文件中直接使用designDp,若要完美适配,则在宽度为targetWidth的设备上,dp转换为targetPx后,应满足如下公式:

公式

因为

alt

alt

alt

代入等式后:

alt

设计稿确定后,designWidthDpi也就固定了,设备的宽度targetWidth也是固定值没法修改,要想保持等式成立,那么只能修改metrics.density。 恰好DisplayMetrics#densitypublic的,因此我们只需要根据公式,算出density,然后修改设备的density值,就可以完成dp的适配了。

根据公式 计算出density

小米9density = targetWidht/designWidthDpi = 1080/375=2.88 Pixel xldensity= targetWidht/designWidthDpi = 1440/375=3.84

假设设计稿中存在控件宽度分别是20px,40px,布局文件中设置:android:layout_width=10dp,android:layout_width=20dp

修改小计9,Pixel xl的density,dp转换为px后分别是29px,58px38px,77px

20/750 = 29/1080 = 38/1440 = 2.6%

40/750 = 58/1080 = 77/1440 = 5.3%

同理,我们修改scaledDensity就可以适配sp了。当调整设置中的缩放比例时,需要重新计算scaledDensity,在自定义application中通过registerComponentCallbacks注册相应的callbacks就行了。

对于图片的decode,参考BitmapFactory#decodeResourceStream方法:

    public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
            @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
        if (opts.inTargetDensity == 0 && res != null) {
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
    }

所以还修改densityDpi.

总结一下,今日头条的方案实际很简单,就是修改设备density、’scaleDensitydensityDpi的值,这里提供下demo

class BaseApplication : Application() {
    private val isBaseOnWidth = true
    private var mInitDensity = -1f
    private var mInitDensityDpi = -1
    private var mInitScaledDensity = -1f
    private var mInitWidth = -1
    private var mInitHeight = -1

    private val activityLifecycleCallback = object : AdapterActivityLifecycleCallbacks() {
        override fun onActivityStarted(activity: Activity) {
            Log.d("yaocai", "BaseApplication onActivityStarted")
            adapter(activity)
        }

        override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
            Log.d("yaocai", "BaseApplication onActivityCreated")
            adapter(activity)
        }

    }


    private val componentCallbacks = object : ComponentCallbacks {
        override fun onLowMemory() {
        }

        override fun onConfigurationChanged(newConfig: Configuration) {
            if (newConfig.fontScale > 0) {
                if (newConfig.fontScale > 0) {
                    mInitScaledDensity =
                        Resources.getSystem().displayMetrics.scaledDensity
                }
                val systemService = getSystemService(WindowManager::class.java)
                val metrics = DisplayMetrics()
                systemService.defaultDisplay.getMetrics(metrics)
                mInitWidth = metrics.widthPixels
                mInitHeight = metrics.heightPixels
            }
            Log.d("yaocai", "BaseApplication onConfigurationChanged")
        }


    }

    override fun onCreate() {
        super.onCreate()
        initDisplayMetricsInfo()
        registerActivityLifecycleCallbacks(activityLifecycleCallback)
        registerComponentCallbacks(componentCallbacks)
    }

    private fun initDisplayMetricsInfo() {
        val systemDisplayMetrics = Resources.getSystem().displayMetrics
        val systemService = getSystemService(WindowManager::class.java)
        val metrics = DisplayMetrics()
        systemService.defaultDisplay.getMetrics(metrics)
        mInitDensity = systemDisplayMetrics.density
        mInitDensityDpi = systemDisplayMetrics.densityDpi
        mInitScaledDensity = systemDisplayMetrics.scaledDensity
        mInitWidth = metrics.widthPixels
        mInitHeight = metrics.heightPixels
    }


    private fun adapter(activity: Activity) {
        val targetDensity: Float
        if (isBaseOnWidth) {
            targetDensity = mInitWidth / 375f
        } else {
            targetDensity = mInitHeight / 667f
        }
        val targetDensityDpi = (targetDensity * 160).toInt()
        val scaledDensity =
            targetDensity * mInitScaledDensity / mInitDensity
        // 修改application displayMetrics
        val applicationDisplayMetrics = this.resources.displayMetrics
        applicationDisplayMetrics.density = targetDensity
        applicationDisplayMetrics.densityDpi = targetDensityDpi
        applicationDisplayMetrics.scaledDensity = scaledDensity
        // 修改activity displayMetrics
        val activityDisplayMetrics = activity.resources.displayMetrics
        activityDisplayMetrics.density = targetDensity
        activityDisplayMetrics.densityDpi = targetDensityDpi
        activityDisplayMetrics.scaledDensity = scaledDensity
    }
}

同样是动态计算,该方案比AndroidAutoLayout要简单很多,完全基于系统Api。不过也一些问题:

  1. 只能以设计稿宽或者高为基准;
  2. 系统控件和第三库控件可能会变形, 因为他们的设计稿尺寸可能与我们的不同;
  3. 部分Rom可能修改了Android源码,对Resource的操作略有不同;

当然在实际开发过程中还需要考虑一些边界问题,譬如横竖屏切换、跨进程适配、部分页面以宽为维度、部分页面以高为维度等等。

这里推荐大家使用基于今日头条方案的AndroidAutoSize库,解决了上述提到的各种问题。

到这里整个系列就结束,屏幕适配就是一个血泪史,好在随着技术的沉淀,涌现出各式各样的适配方案,感谢同行们的分享。