前言
由于Android屏幕的碎片化,屏幕适配始终是我们每一个应用必须面对的问题,常见的适配方案如下:
dp适配方案- 宽高限定符适配方案
AndroidAutoLayout适配方案sw限定符适配方案AndroidAutoSize适配方案
上面的适配方案都各有优缺点,具体分析可查看Android全面的屏幕适配方案解析.除了AndroidAutoLayout适配方案,其它方案在Compose也是通用的,但是Compose还提供了一个新的思路——CompositionLocalProvider
CompositionLocalProvider屏幕适配
CompositionLocalProvider通过动态提供LocalDensity,实现了局部的屏幕适配。它的原理是通过重新组合相关的Composable树,并在布局计算时使用新的Density值来影响dp到px的转换,从而适配不同分辨率的屏幕。在实际应用中,合理使用CompositionLocalProvider可以实现灵活的适配策略。
上面的文字是ChatGPT回答我的,关于CompositionLocalProvider屏幕适配的原理,我们接下来看看如何使用CompositionLocalProvider实现屏幕适配?
@Composable
fun MainScreen() {
// 获取 displayMetrics
val displayMetrics = LocalContext.current.resources.displayMetrics
// 获取屏幕宽度(单位:像素)
val widthPixels = displayMetrics.widthPixels
// 获取屏幕高度(单位:像素)
val heightPixels = displayMetrics.heightPixels
// 获取当前字体大小
val fontScale = LocalDensity.current.fontScale
CompositionLocalProvider(
LocalDensity provides Density(
density = widthPixels / 360.0f,// 以宽度360dp为基准,进行一个适配
// density = heightPixels / 640.0f,也可以根据屏幕高度进行一个适配
fontScale = fontScale // 是否对文字的大小进行适配,建议使用1倍不根据系统设置或者直接屏幕大小进行变化
)
) {
// TODO 屏幕内容
}
}
上手也很简单,一句话来描述就是根据屏幕宽度或者高度对density和fontScale进行修改,修改后在这个Composable作用域内大多数情况都是正常的,不正常的情况后面单独描述。接下来看一个示例:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DialogApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.White
) {
MainScreen()
}
}
}
}
}
@Composable
fun MainScreen(){
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()
}
}
}
@Composable
fun 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)
)
}
}

上面的代码和图片都是来自业志陈老师的 Jetpack Compose 实现完美屏幕适配
上面描述的都是正常情况,下面讲一下我遇到一些异常情况的处理方案,也算是一些注意事项了。
CompositionLocalProvider遇到Dialog
我们根据屏幕宽度的DPI来进行适配,如果屏幕的DPI小于我们设定的基准360dp时,,则出异常了。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppManager.attach(this)
enableEdgeToEdge()
setContent {
DialogApplicationTheme {
LocalContext.current.resources.displayMetrics.density = 2.75f
val widthPixels = LocalContext.current.resources.displayMetrics.widthPixels
val targetDensity = widthPixels / 360f
Log.e("TestDialog", "widthPixels:${widthPixels} ")
CompositionLocalProvider(LocalDensity provides Density(density = targetDensity)){
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var show by remember { mutableStateOf(false) }
Box(modifier.fillMaxSize()) {
Spacer(modifier = Modifier.fillMaxWidth()
.height(120.dp).background(Color.Gray))
Button(modifier = Modifier.align(Alignment.Center),onClick = {
show = !show
}) {
Text("点击测试动画")
}
if (show){
TestDialog{
show = !show
}
}
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
DialogApplicationTheme {
Greeting("Android")
}
}
可以看到,在
Activity的组件中,当displayMetrics#density小于3.0(对应360dp)时,顶部灰色120dp高度的区域是占满全屏的,说明CompositionLocalProvider在Activity里使用是正常的,接下来我们进入Dialog看看?
@Composable
fun TestDialog(onDismissCallback: () -> Unit) {
Dialog(
onDismissRequest = {
onDismissCallback.invoke()
},
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
),
) {
val status by WebActivity.webStatus.collectAsState()
val widthPixels = LocalContext.current.resources.displayMetrics.widthPixels
val targetDensity = widthPixels / 360f
CompositionLocalProvider(LocalDensity provides Density(density = targetDensity)) {
Box(
modifier = Modifier
.fillMaxSize() // 使整个容器占满屏幕
.background(Color.Red)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray)
) {
Text(
"当前状态:${status}",
style = TextStyle.Default.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(70.dp)
.background(color = Color.Cyan),
contentAlignment = Alignment.Center
){
Text("A")
}
Box( modifier = Modifier
.width(360.dp)
.height(70.dp)
.background(color = Color.Magenta),
contentAlignment = Alignment.Center
){
Text("A")
}
Box(
modifier = Modifier
.width(300.dp)
.height(70.dp)
.background(color = Color.Yellow),
contentAlignment = Alignment.Center
){
Text("B")
}
Box(
modifier = Modifier
.width(240.dp)
.height(70.dp)
.background(color = Color.Blue),
contentAlignment = Alignment.Center
){
Text("C")
}
Spacer(modifier = Modifier.height(32.dp))
val ctx = LocalContext.current
Button(onClick = {
WebActivity.start(ctx, "https://juejin.cn/post/7412819188853309503")
}) {
Text("点我跳转")
}
}
}
}
}
}
可以看到虽然我们有使用
Modifier.fillMaxSize()来充满这个屏幕,但是出现的效果却并没有达到我们的预期。
这里我们再次回顾一下density(屏幕密度)
density 在每个设备上都是固定的,DPI / 160 = density,屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度
- 设备 1,屏幕宽度为 1080px,480DPI,屏幕总 dp 宽度为 1080 / (480 / 160) = 360dp
- 设备 2,屏幕宽度为 1080,320DPI,屏幕总 dp 宽度为 1080 / (320 / 160) = 540dp
因为此刻屏幕的屏幕是320DPI,屏幕宽度为1080px,因此此刻的density应该是3.375f
而我们设置的density是2.75f,在屏幕宽度不变,density减小的情况下,则此刻屏幕宽度增加到392.73dp,因此360dp不能占满全屏.
虽然这里是一个乌龙,但是也确实引出了一个问题:为什么CompositionLocalProvider在Activity的组件中适配屏幕没有问题,但是到Dialog有问题了呢?
添加日志看看?
Dialog(
onDismissRequest = {
onDismissCallback.invoke()
},
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
),
) {
val status by WebActivity.webStatus.collectAsState()
val widthPixels = LocalContext.current.resources.displayMetrics.widthPixels
val targetDensity = widthPixels / 360f
Log.e("TestDialog", "widthPixels:${widthPixels} Dialog density:${LocalDensity.current.density}")
CompositionLocalProvider(LocalDensity provides Density(density = targetDensity)) {
Box(
modifier = Modifier
.fillMaxSize() // 使整个容器占满屏幕
.background(Color.Red)
.onSizeChanged {
Log.e("TestDialog", "Box width: ${it.width} height: ${it.height}")
}
) {
Log.e("TestDialog", "widthPixels:${widthPixels} Dialog Box density:${LocalDensity.current.density}")
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray)
.onSizeChanged {
Log.e("TestDialog", "Column width:${it.width} height:${it.height}")
}
) {
Log.e("TestDialog", "widthPixels:${widthPixels} Dialog Box Column density:${LocalDensity.current.density}")
Text(
"当前状态:${status}",
style = TextStyle.Default.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(70.dp)
.background(color = Color.Cyan),
contentAlignment = Alignment.Center
){
Text("A")
}
// Spacer(modifier = Modifier.height(32.dp))
Box( modifier = Modifier
.width(360.dp)
.height(70.dp)
.background(color = Color.Magenta),
contentAlignment = Alignment.Center
){
Text("A")
}
// Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier
.width(300.dp)
.height(70.dp)
.background(color = Color.Yellow),
contentAlignment = Alignment.Center
){
Text("B")
}
// Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier
.width(240.dp)
.height(70.dp)
.background(color = Color.Blue),
contentAlignment = Alignment.Center
){
Text("C")
}
Spacer(modifier = Modifier.height(32.dp))
val ctx = LocalContext.current
Button(onClick = {
WebActivity.start(ctx, "https://juejin.cn/post/7412819188853309503")
}) {
Text("点我跳转")
}
}
}
}
}
输出日志信息:
E widthPixels:1080 Dialog density:2.75
E widthPixels:1080 Dialog Box density:3.0
E widthPixels:1080 Dialog Box Column density:3.0
E Column width:880 height:1609
屏幕宽度 1080 / Dialog宽度 880 = 系统Density 3.375 / 设置的Density3.0
通过计算得知,虽然CompositionLocalProvider设置的Density传递正常,但是绘制时还是使用的系统Density,猜测原因应该和Dialog使用的原生Dialog相关.
其实还有一个场景我们可以试一下:
我们在Activity设置的density为2.75,如果Dialog里面也使用这个density呢?这里模拟使用AndroidAutoSize来做屏幕适配的场景,即View代码适配时修改了Density,Compose在Dialog里面使用同一个屏幕密度来适配呢?这里我们直接修改CompositionLocalProvider(LocalDensity provides Density(density = 2.75f)),然后看看代码效果:
看来还是不能修改
Density,不然Dialog始终会有异常。
既然不能修改Density,那么能不能将Density恢复到重置之前呢?AndroidAutoSize也提供了这个方案,它自身在Fragment、Activity做设置不支持适配时,也使用到了这个方案。代码如下:
@Composable
fun TestDialog(onDismissCallback: () -> Unit) {
// 恢复默认的density
AutoSizeConfig.getInstance().stop(AppManager.currentActivity())
Dialog(
onDismissRequest = {
//重新开启 AutoSize 相关配置
AutoSizeConfig.getInstance().restart()
onDismissCallback.invoke()
},
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
),
) {}
}
如此即可解决问题。最后,如果有View和Compose混合开发的情况,也建议使用 sw限定符适配方案,这样View适配的时候所有长度都使用资源文件,不修改Density,而Compose也可以使用资源文件,或者使用CompositionLocalProvider。
当然,如果觉得每个长度都得使用资源文件比较繁琐,也可以使用AndroidAutoSize,这样的话使用Dialog就得单独处理才行。
总结
本文讲解了在组件中如何使用CompositionLocalProvider来实现屏幕适配,可以根据屏幕宽度或者屏幕高度,可以在组件中支持字体大小的适配。
然后还提到了Dialog内使用组件可能会遇到的异常,并给出了本人在使用AndroidAutoSize做屏幕适配时,给出的解决方案。其实Popup和Dialog一样,也存在同一个问题,解决方案也是一样的。
最后,作者将"为什么CompositionLocalProvider在Activity的组件中适配屏幕没有问题,但是到Dialog有问题了呢"这个问题向Google提出了issue,期待官方能给出一个解决方案。