写给小白的Jetpack Compose之屏幕适配

3,015 阅读5分钟

前言

由于Android屏幕的碎片化,屏幕适配始终是我们每一个应用必须面对的问题,常见的适配方案如下:

  • dp适配方案
  • 宽高限定符适配方案
  • AndroidAutoLayout适配方案
  • sw限定符适配方案
  • AndroidAutoSize适配方案

上面的适配方案都各有优缺点,具体分析可查看Android全面的屏幕适配方案解析.除了AndroidAutoLayout适配方案,其它方案在Compose也是通用的,但是Compose还提供了一个新的思路——CompositionLocalProvider

CompositionLocalProvider屏幕适配

CompositionLocalProvider 通过动态提供 LocalDensity,实现了局部的屏幕适配。它的原理是通过重新组合相关的Composable树,并在布局计算时使用新的 Density 值来影响dppx的转换,从而适配不同分辨率的屏幕。在实际应用中,合理使用 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 屏幕内容
    }
}

上手也很简单,一句话来描述就是根据屏幕宽度或者高度对densityfontScale进行修改,修改后在这个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")
    }
}

image.png 可以看到,在Activity的组件中,当displayMetrics#density小于3.0(对应360dp)时,顶部灰色120dp高度的区域是占满全屏的,说明CompositionLocalProviderActivity里使用是正常的,接下来我们进入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("点我跳转")
                    }
                }
            }

        }
    }
}

image.png 可以看到虽然我们有使用Modifier.fillMaxSize()来充满这个屏幕,但是出现的效果却并没有达到我们的预期。

这里我们再次回顾一下density(屏幕密度)

density 在每个设备上都是固定的,DPI / 160 = density屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度

  • 设备 1,屏幕宽度为 1080px480DPI,屏幕总 dp 宽度为 1080 / (480 / 160) = 360dp
  • 设备 2,屏幕宽度为 1080320DPI,屏幕总 dp 宽度为 1080 / (320 / 160) = 540dp

因为此刻屏幕的屏幕是320DPI,屏幕宽度为1080px,因此此刻的density应该是3.375f

而我们设置的density2.75f,在屏幕宽度不变,density减小的情况下,则此刻屏幕宽度增加到392.73dp,因此360dp不能占满全屏.

虽然这里是一个乌龙,但是也确实引出了一个问题:为什么CompositionLocalProviderActivity的组件中适配屏幕没有问题,但是到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设置的density2.75,如果Dialog里面也使用这个density呢?这里模拟使用AndroidAutoSize来做屏幕适配的场景,即View代码适配时修改了Density,Compose在Dialog里面使用同一个屏幕密度来适配呢?这里我们直接修改CompositionLocalProvider(LocalDensity provides Density(density = 2.75f)),然后看看代码效果:

image.png 看来还是不能修改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
            ),
        ) {}
}

如此即可解决问题。最后,如果有ViewCompose混合开发的情况,也建议使用 sw限定符适配方案,这样View适配的时候所有长度都使用资源文件,不修改Density,而Compose也可以使用资源文件,或者使用CompositionLocalProvider

当然,如果觉得每个长度都得使用资源文件比较繁琐,也可以使用AndroidAutoSize,这样的话使用Dialog就得单独处理才行。

总结

本文讲解了在组件中如何使用CompositionLocalProvider来实现屏幕适配,可以根据屏幕宽度或者屏幕高度,可以在组件中支持字体大小的适配。

然后还提到了Dialog内使用组件可能会遇到的异常,并给出了本人在使用AndroidAutoSize做屏幕适配时,给出的解决方案。其实PopupDialog一样,也存在同一个问题,解决方案也是一样的。

最后,作者将"为什么CompositionLocalProviderActivity的组件中适配屏幕没有问题,但是到Dialog有问题了呢"这个问题向Google提出了issue,期待官方能给出一个解决方案。