服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

Jetpack Compose 实现完美屏幕适配 | 开发者说·DTalk

日期: 来源:Android 开发者收集编辑:业志陈

本文原作者: 业志陈原文发布于: 字节数组


"受益" 于目前 Android 手机各类屏幕尺寸长短不定、宽高比例大小不一的现状,屏幕适依然是 Android 应用开发时绕不开的问题。

我们在日常开发中使用得最多的尺寸单位应该是 dp 了,Google 也推荐开发者尽量使用 dp 作为单位值,因为系统会根据屏幕的实际情况来完成 dp 和 px 之间的对应换算,使得同个 dp 值的显示效果在不同手机屏幕上不会相差太大。但直接使用 dp 值后的最终显示效果只能说不会和设计稿相差太远,想要做到完美适配还远远不够。

举个例子。我们在进行 UI 开发时,一般都是按照设计稿的规定来给 View 设定宽高和间距,假设设计稿是按照 1080 x 1920 px,420 dpi 的屏幕标准来进行设计的,也即设计稿的宽高是 411 x 731 dp,那对于一个希望占据屏幕一半宽度的 ImageView 来说,在设计稿中的宽即 205.5 dp。

那么,对于一台 1440 x 2880 px,560 dpi 的真机,其宽高是 411 x 822 dp,此时我们在布局文件中就可以直接使用设计稿中给出来的宽度值 205.5 dp,使得 ImageView 在这台真机上就占据了屏幕的一半宽度。虽然设计稿和这台真机的屏幕像素尺寸并不相同,但由于屏幕像素密度的存在,使得两者的 dp 宽度是一样的,从而让开发者可以直接套用设计稿给出的 dp 值就能满足要求。

既然有了 dp,那我们为什么还需要进行屏幕适配呢?当然也是因为 dp 只能适用于大部分正常情况了。以上情况之所以能够实现完美适配,那也是因为举的例子刚好也是完美的: 1440 / 1080 = 560 / 420 = 1.3333,两者的 px 宽度比例和像素密度比例刚好相等,所以此时使用 dp 才能刚好适用。

再来看一个不怎么完美的例子,以另外两台真机为例:

  • 华为 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

可以看到,在像素宽度相同的情况下,不同手机的像素密度是有可能不一样的。手机厂家有可能是根据屏幕像素和屏幕尺寸来共同决定该值的大小,但不管怎样,这就造成了应用的实际效果与设计稿之间无法对应的情况: 对于一个 180 dp 宽度的 View 来说,在华为 nova5 上能占据一半的屏幕宽度,但在三星 Galaxy S10 上却只有 180 / 411 = 0.43,这就造成了一定偏差。

以上就是之所以需要进行屏幕适配的原因了。



View 体系的适配方案



在去年我详细地介绍了 Android 开发中目前处于主流或曾经主流的屏幕适配方案,共有三种适配方案:
  • 今日头条方案

  • 宽高限定符方案

  • 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 默认的适配机制



再来看下 Jetpack Compose 默认是如何进行屏幕适配的。

在 View 体系下,不管我们在布局文件中使用的是什么尺寸单位,最终系统在使用时都需要将其转换为 px,对应 TypedValue 的 applyDimension 方法。例如,dp 值最终都需要通过 px = dp * density 公式来完成换算,这个规则对于 Jetpack 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;}


目前,我们在 Jetpack Compose 中只能采用 dp 单位来设定尺寸大小,对应 Dp 类
以 Modifier 的扩展函数 Modifier.size(width: Dp, height: Dp) 为例,其宽高大小均是 Dp 类型,在生成尺寸约束 Constraints 时,也是将 dp 转换为 px 后使用,对应 Dp.roundToPx() 方法。

继续跟踪 Dp.roundToPx() 方法,可以看到 dp 和 px 之间的转换方式和原生 View 体系一样,也是按照 px = dp * density 的公式来进行转换的,density 值由 Density 接口来定义和提供。

此外,Density 接口还定义了 fontScale 变量,这就很容易让人联想到 sp 单位了。实际上,Density 接口的确提供了多个方法,用来实现 dp、px、sp 等单位之间的互相转换。

通过一步步查找 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 实现完美适配



所以说,在默认情况下,View 体系和 Jetpack Compose 都会存在和设计稿稍有偏差的问题。而上文介绍的几种 View 体系适配方案,单纯从实现效果上来说的话,今日头条方案应该是属于最优解了,但隐性成本相对也是最高的: 由于修改了系统的 density 值,因此会影响到应用全局,对于已经迭代了很久的项目来说,中途引入此方案大概率会影响到现有的适配方案;即使是新项目,又需要考虑到此方案对于三方库的影响,不能由于主项目的变动导致三方库自身界面变形。

可以看出来,如果想要采用今日头条方案的话,前提就在于: 是否能够细粒度地控制方案的作用范围,将生效范围控制在特定 Activity、特定 Fragment、甚至特定 View 内部。

想要满足此前提,对于 View 体系来说是很麻烦的,但对于 Jetpack Compose 来说,今日头条方案则真正成为了最优解: UI 还原度最高、无需生成多套 dimens 文件、作用范围自由可控、我甚至想不到会有什么缺点。

看一个小例子:

假设设计稿的屏幕宽度是 360 dp,内部有一个 180 dp 的控件,UI 设计师希望在不同屏幕上该控件均能占据屏幕的一半宽度。

以下代码中嵌套了四个 Spacer 控件,每个 Spacer 的宽度均是 180 dp,但第二个 Greeting() 函数我将其放到了修改过后的 LocalDensity 中,按照今日头条方案给出的公式计算出此设备的目标 density 值,将其作为新的 LocalDensity。
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()            }        }    )}

此外,smallestWidth 方案也一样适用于 Jetpack Compose,androidx.compose.ui:ui:xxx 库中就提供了 dimensionResource 方法用于获取 dimension 值,此方法会将获取到的值转换为 Dp 类型。因此如果读者项目中原本已经使用了 smallestWidth 方案的话,在 Jetpack Compose 中也依然可以继续使用。



长按右侧二维码

查看更多开发者精彩分享




"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。




 点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk" 




相关阅读

  • 432Hz 回声 | 大连旭辉·铂辰时代

  • 项目位于滨海之都,素有“京津门户”之称的大连,依山靠海,洗尽铅华;它包容并蓄,多元立体,它现代、都市、时尚、律动、幽蓝,它有着2211公里长海岸线的辽阔与浪漫,有着北纬 39° 的文艺
  • 试管婴儿的流程及费用

  • 试管婴儿医疗流程需要经过术前检查、建档、确定促排降调方案、取卵取精、移植、验血查HCG、怀孕保胎等过程。术前检查在试管婴儿前,医生会询问患者的经期、怀孕史、病史、手
  • 微步TDP获“2022信息技术应用创新年度推荐”

  • 信息技术应用创新是加快新型基础设施建设的重要支点,也是新形势下经济结构调整和经济发展的新动能,已经成为经济数字化转型、提升产业链发展的关键。近日,《中国信息化》杂志社
  • VR硬件趋势渐朗,关注光学及显示产业链

  • 报告摘要VR行业:商业化破晓,长期有望再造手机市场。VR通过光学原理给人以深刻沉浸感,是科技创新又上新阶的结晶,有望接过传统消费电子创新接力棒,长期再造手机市场。政策端,2022年
  • 云厂商之战,战至“边缘”

  • 随着ChatGPT的爆火,人工智能产业对于算力的需求,迎来了空前的爆发。当前,以传统计算集群为主的算力解决方案,已然不能满足企业发展需要。一场关乎国内外云大厂的算力革命,正在被
  • 又安排了1笔巨额退休金

  • 图:Gyoza最近延迟退休的话题挺火,上了好几次热搜了。我这边的读者,年龄大多在25岁-40岁之间,不出意料的话,咱们能赶上延迟退休。haha,到时候会不会等65岁才能拿到退休金啊?没享受几

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

  • 轻松实现相机预览 | Camera Viewfinder 全新上线

  • 作者 / Android 开发者关系工程师 Francesco Romano经过多年的不断发展,Android 设备现在具有各种尺寸和形状,并且屏幕大小和功能也大不相同。但无论如何变化,手机拍照从一开始