Jetpack Compose 实现完美屏幕适配 | 开发者说·DTalk
The following article is from 字节数组 Author 业志陈
本文原作者: 业志陈,原文发布于: 字节数组
再来看一个不怎么完美的例子,以另外两台真机为例:
华为 nova5: 1080 x 2259 px,480 dpi,屏幕宽度为 1080 / (480 / 160) = 360 dp 三星 Galaxy S10: 1080 x 2137 px,420 dpi,屏幕宽度为 1080 / (420 / 160) = 411 dp
View 体系的适配方案
今日头条方案
宽高限定符方案
smallestWidth 方案
今日头条方案。基于系统将 dp 转换为 px 的公式 px = dp * density 来实现适配,通过在运行时动态修改 density 值的大小,使得修改后计算出的屏幕宽度就等于设计稿的宽度,从而使得在不同屏幕尺寸下我们都可以直接使用设计稿给出的 dp 值,且无需准备多套 dimens 文件。
宽高限定符方案。通过穷举市面上所有 Android 手机的屏幕像素尺寸来实现适配,通过比例换算来为不同分辨率的屏幕分别准备一套 dimens 文件,应用在运行时再去引用和当前设备完全匹配的 dimens 文件,以此来实现屏幕适配。
smallestWidth 方案。适配原理和宽高限定符方案一样,也是通过比例换算来为不同尺寸的屏幕分别准备一套 dimens 文件,应用在运行时再去引用和当前设备最匹配的 dimens 文件,以此来实现屏幕适配。
今日头条方案。优点: 可以直接使用设计稿中的 dp 值,无需准备多套 dimens 文件进行映射,因此不会增大 apk 体积,且在三种方案中 UI 还原度最高,其它两种方案都需要精准命中屏幕尺寸后才能达到此方案的还原度。缺点: 由于此方案会影响到应用全局,如果我们引入了一些第三方库的话,三方库中的界面也会随之被影响到,可能会造成效果变形,此时就需要进行额外处理了。
smallestWidth 方案。优点: 容错率高,在 320 ~ 460 dp 之间每 10 dp 就提供一套 dimens 文件就足够使用了,想要囊括更多设备的话也可以再缩短步长,基本不用担心最终效果会与设计稿偏差太多,且不会影响到三方库。缺点: 需要生成多套 dimens 文件,增大了 apk 体积。
Compose 默认的适配机制
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.roundToPx() 方法,可以看到 dp 和 px 之间的转换方式和原生 View 体系一样,也是按照 px = dp * density 的公式来进行转换的,density 值由 Density 接口来定义和提供。
通过一步步查找 Density 接口的实现类,最终可以定位到 AndroidDensity.android 类,Jetpack Compose 就是在这里通过 Context 来获取对应的 density 和 fontScale。
此外,看过一些 Jetpack Compose 内部源码的同学应该知道,连接 Compose 和 View 体系之间的桥梁是 AndroidComposeView 类,而 AndroidComposeView 就通过 fun Density(context: Context) 方法来初始化其内部声明的 density 对象,CompositionLocals 类的 ProvideCommonCompositionLocals 方法又通过 LocalDensity 来将 AndroidComposeView 持有的 density 对象暴露给外部,从而使得框架内部和开发者均可以通过 LocalDensity.current 来获取到当前的 Density 对象,也即通过此方法拿到了 Android 系统的 density 和 fontScale 两个参数。
根据以上线索,我们可以推断出 Jetpack Compose 目前采用的屏幕适配机制其实就和 Android 原生的 View 体系一样,都是以屏幕像素密度作为适配基础,这使得 Jetpack Compose 一样存在文章开头介绍的问题,在不同手机屏幕上的显示效果相比设计稿都会有一点点误差。
在我看来,Jetpack Compose 设计 Density 接口的初衷就是为了实现多平台。如果 Jetpack Compose 仅仅是用于 Android 平台的话,直接获取当前设备的 density 值完成单位转换即可,Density 接口的存在意义并不大;而为了方便 Compose Multiplatform 实现跨平台,Google 官方才设计了 Density 接口。例如,Compose Multiplatform 支持在 Android 和 Windows 平台之间复用同一套 Compose UI,而相同的 dp 值在电脑屏幕上必须显示得更加大才行,通过抽象出 Density 接口,Compose Multiplatform 就可以为 Windows 平台提供更加合适的 density 值,从而使得显示效果更加适合电脑屏幕。
Compose 实现完美适配
看一个小例子:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { Column( modifier = Modifier .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { val displayMetrics = LocalContext.current.resources.displayMetrics val fontScale = LocalDensity.current.fontScale val density = displayMetrics.density val widthPixels = displayMetrics.widthPixels val widthDp = widthPixels / density val display = "density: $density\nwidthPixels: $widthPixels\nwidthDp: $widthDp" Text(text = display) Greeting() CompositionLocalProvider( LocalDensity provides Density( density = widthPixels / 360.0f, fontScale = fontScale ) ) { Greeting() } } } } } }}
@Composablefun Greeting() { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Spacer( modifier = Modifier .size( width = 180.dp, height = 100.dp ) .background(color = Color.Green) .align(alignment = Alignment.Start) ) Spacer( modifier = Modifier .size( width = 180.dp, height = 100.dp ) .background(color = Color.Cyan) .align(alignment = Alignment.End) ) }}在三台不同分辨率的模拟器上运行代码,查看显示效果。
很明显就可以看出来,三台模拟器的屏幕宽度并非刚好就是 360 dp,因此前两个 Spacer 控件并没有达到预期效果;而第二个 Greeting() 函数仅仅是多嵌套在了一个 CompositionLocalProvider 中而已,直接套用设计稿给出的尺寸就让两个 Spacer 控件在不同屏幕上均占据了屏幕的一半宽度,完美达到了设计稿的要求。
由于我们可以在单独一个 Activity 中使用 Jetpack Compose,甚至可以在某个组合函数中局部修改 LocalDensity 值,因此我们可以细粒度地控制今日头条方案的生效范围,使其只在局部生效而不用担心会影响到其它业务模块,真正做到了完美适配且引入成本极低。
此外,我们主动修改 LocalDensity 还有一个好处: 可以自由控制应用内文字的显示大小。在默认情况下,采用了 sp 作为文字单位的应用,文字的显示大小是会随系统字体大小的变化而变化的,这是因为 sp 转换为 px 的公式 px = sp * fontScale * density 多了一个参数 fontScale,fontScale 代表的就是当前系统字体的缩放比例,当我们调大系统字体后,fontScale 会随之变大,连锁导致计算出的 px 值也随之变大。因此,通过主动修改 LocalDensity,我们还可以很方便地设置 Jetpack Compose 的字体大小,进一步完善应用最终的显示效果。
结尾
得益于 Jetpack Compose 预留了 LocalDensity 这个入口,使得今日头条方案在 Jetpack Compose 中显得十分简单易用,读者也可以将这部分代码抽取到 MaterialTheme 中,使其默认生效,进一步减少代码量。
@Composablefun MyApplicationTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colors = if (darkTheme) { DarkColorPalette } else { LightColorPalette } val fontScale = LocalDensity.current.fontScale val displayMetrics = LocalContext.current.resources.displayMetrics val widthPixels = displayMetrics.widthPixels MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = { CompositionLocalProvider( LocalDensity provides Density( density = widthPixels / 360.0f, fontScale = fontScale ) ) { content() } } )}长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向