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

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

smallestwidth限定符

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

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

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

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

接着,我们计算待适配手机的最小宽度dp值,我们还是以小米9Pixel 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-sw393dpdimens文件和values-sw411dpdimens文件中分别定义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/4112.66%

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

  1. 计算设计稿的最小宽度dp值N
  2. 枚举所有待适配继续最小宽度的dp值W, 创建values-sw${W}dp文件夹;
  3. 将最小宽度分成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.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

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 = 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

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、’scaleDensitydensityDpi的值,这里提供下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
// 修改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库,解决了上述提到的各种问题。

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

作者

sadhu

发布于

2020-06-11

更新于

2020-06-11

许可协议