上篇 一口气没把所有的适配方案介绍完,今天来收个尾。
smallestwidth限定符 smallestwidth限定符和我们上篇提到的宽高限定符的套路一样, 只不过是以dp
作为单位,并以设备最短边的dp值 作为匹配依据。
设计稿以iphone6
为标准,屏幕尺寸为4.7
英寸,分辨率为750x1334
,density
为2
。
我们先将设计稿宽度像素值转换dp
值。px
怎么转dp
还记得否?公式再来一次。
1 2 px = dp * densityDpi / 160 = dp * density 750px = 750 / density = 375dp
接着,我们计算待适配手机的最小宽度dp
值,我们还是以小米9
和Pixel xl
为例:
机型
分辨率
屏幕尺寸
ppi
dpi
小米9
1080x2340
6.39英寸
403
440
Pixel xl
1440x2560
5.5英寸
534
560
1080<2340
, 所以小米9
最小宽度取1080px
, 转换为dp
值为:
1 1080px = 1080 / (densityDpi / 160 ) = 1080 / (440 / 160 ) = 393dp
1440<2560
, 所以Pixel xl
最小宽度取1440px
, 转换为dp
值为:
1 1440px = 1440 / (densityDpi / 160 ) =1440 / (560 / 160 ) = 411dp
如设计稿中某控件宽度为10dp
,为了保证比例,我们需要将设计稿dp
值转换为实际dp值,转换公式为:
1 实际dp = 设计稿尺寸 * (设备最小宽度/设计稿最小宽度)
那么在小米9
上应该是10.48dp = (10 * 395/375)
;
Pixel xl
上应该是10.96dp =(10* 560/375)
。
接着我们在values-sw393dp
的dimens
文件和values-sw411dp
的dimens
文件中分别定义10dp
转换后的值。
1 2 3 4 5 6 7 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
在运行时,就根据最小宽度取对应资源文件夹下的尺寸了。且视觉效果保持一致:
1 10 /375 = 10.48 /393 = 10.93 /411 ≈ 2.66 %
在实际开发中我们怎么操作呢?跟宽高限定符类似。
计算设计稿的最小宽度dp值N
;
枚举所有待适配继续最小宽度的dp值W
, 创建values-sw${W}dp
文件夹;
将最小宽度分成N
份,通过转换公式,计算每份在各机型上的实际dp
值;
同样上述的步骤随便写个脚本就可以自动生成, 我们把上篇的demo
改改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 package cc.wecando.dpadaptergeneratorimport java.io.BufferedWriterimport java.io.Fileimport java.io.FileWriterimport java.math.BigDecimalclass 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() }
smallestwidth限定符
原理与宽高限定符
相同,都是利用Android
资源匹配规则,实现在不同设备上使用不同的dp值。我们需要做的就是针对最小宽度
的设备,计算每份对应的dp值。
同样,他的缺点跟宽高限定符
差不多, 并且只能以最小宽度
一个维度进行适配。
今日头条方案 我们屏幕适配的核心是什么? 无非是将设计稿上的尺寸,经过转换 ,保证在各种分辨率上 转换后的尺寸所占比例与设计稿保持一致。
这个转换过程 ,宽高限定符
,smallestwidth限定符
是利用Android
资源匹配策略。AndroidAutoLayout
是通过自定义控件,收集所有尺寸相关的属性动态转换。
回过头想想, Android Api
本身不就有这种转换过程吗?
第一篇文章我们已经分析过了dp
转px
,sp
转px
,Android
框架中计算尺寸最终都会调用TypedValue#applyDimension
方法。 将各种单位的尺寸转换 为px
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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。
1 2 case COMPLEX_UNIT_DIP: return value * metrics.density;
假如宽度为designWidth = 750px
,density
为2
,designWidthDpi=375dp
的设计稿中,定义一个空间宽度为designDp =10dp
。
在布局文件中直接使用designDp
,若要完美适配,则在宽度为targetWidth
的设备上,dp
转换为targetPx
后,应满足如下公式:
因为
代入等式后:
设计稿确定后,designWidthDpi
也就固定了,设备的宽度targetWidth
也是固定值没法修改,要想保持等式成立,那么只能修改metrics.density
。 恰好DisplayMetrics#density
是public
的,因此我们只需要根据公式,算出density
,然后修改设备的density
值,就可以完成dp
的适配了。
根据公式 计算出density
小米9
的density = targetWidht/designWidthDpi = 1080/375=2.88
Pixel xl
的density= targetWidht/designWidthDpi = 1440/375=3.84
假设设计稿中存在控件宽度分别是20px
,40px
,布局文件中设置:android:layout_width=10dp
,android:layout_width=20dp
。
修改小计9
,Pixel xl
的density,dp
转换为px
后分别是29px
,58px
和38px
,77px
1 2 3 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
方法:
1 2 3 4 5 6 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
、’scaleDensity
、densityDpi
的值,这里提供下demo
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 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 val applicationDisplayMetrics = this .resources.displayMetrics applicationDisplayMetrics.density = targetDensity applicationDisplayMetrics.densityDpi = targetDensityDpi applicationDisplayMetrics.scaledDensity = scaledDensity val activityDisplayMetrics = activity.resources.displayMetrics activityDisplayMetrics.density = targetDensity activityDisplayMetrics.densityDpi = targetDensityDpi activityDisplayMetrics.scaledDensity = scaledDensity } }
同样是动态计算,该方案比AndroidAutoLayout
要简单很多,完全基于系统Api
。不过也一些问题:
只能以设计稿宽或者高为基准;
系统控件和第三库控件可能会变形, 因为他们的设计稿尺寸可能与我们的不同;
部分Rom可能修改了Android
源码,对Resource
的操作略有不同;
当然在实际开发过程中还需要考虑一些边界问题,譬如横竖屏切换、跨进程适配、部分页面以宽为维度、部分页面以高为维度等等。
这里推荐大家使用基于今日头条方案的AndroidAutoSize 库,解决了上述提到的各种问题。
到这里整个系列就结束,屏幕适配就是一个血泪史,好在随着技术的沉淀,涌现出各式各样的适配方案,感谢同行们的分享。