14.3 Compose 头条屏幕适配实现

1,466 阅读4分钟

Android 开发绕不过屏幕适配的问题,这是 Android 系统本身开源导致碎片化的缘故。Compose 屏幕适配是 Android 屏幕适配中添加自己实现。头条的屏幕适配方案在 Compose 中实现起来真的是 so easy。

头条适配方案

头条适配方案基础——dp

Android 设备屏幕除了有不同的大小之外,相同大小的屏幕还可能有不同的分辨率,使用像素来作为单位设计 UI 没有办法兼顾这两种变化。

像素密度(dpi dots per inch): 屏幕物理区域每英寸内像素个数,是由屏幕大小和分辨率共同决定的。

通常我们说屏幕的像素密度一般指的是对角线 dpi ,3.5 英寸 320 x 480  屏幕的 dpi 计算为 √ (320^2 + 480^2) / 3.5 = 164.8

dp 是基于像素密度的单位, Android 中 160 dpi 的屏幕上 1px =  1dp, 不同 dpi 设备上 dp 和 px 的转换通式为:

px = dp * (dpi / 160)

屏幕密度(density):屏幕 dpi 与标准 dpi 的比,简化后的通式为:

px = dp * density  

1dp 在 160dpi 的屏幕上代表 1 个像素点,在 320dpi 的屏幕上代表两个像素点。

以 dp 为单位实现了以下两点:

  • 每英寸上像素的比例化
  • 淡化屏幕的物理属性,我们不在关心屏幕的物理尺寸、分辨率,只关心像素密度或者说只关心屏幕密度。

Compose 实现头条适配方案

头条适配方案实现——修改 density

以 dp 为基础,将需要适配的设备的宽/高 dp 值修改成跟设计的宽/高 dp值一样。

假如设计稿 ScreenWidth = 320dp * density   , 要使适配后设备的 ScreenWidth 也等于 320dp * density  ,答案很明显就是修改 density

Compose 头条适配方案实现

虽然我们使用 dp 来实现 UI 设计,但最后参与测绘的还是使用 px 值来跟硬件协作。

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()
    }
    @Stable
    fun Dp.roundToPx(): Int {
        val px = toPx()
        return if (px.isInfinite()) Constraints.Infinity else px.roundToInt()
    }
    
    @Stable
    fun Dp.toPx(): Float = value * density
    

默认情况下 Compose 中的 density 是从 DisplayMetrics 中获取的

internal class AndroidComposeView(context: Context) :
    ViewGroup(context), Owner, ViewRootForTest, PositionCalculator, DefaultLifecycleObserver {

    override var density = Density(context)
        private set
}
fun Density(context: Context): Density =
    Density(
        context.resources.displayMetrics.density,
        context.resources.configuration.fontScale
    )

但是 density 是使用 CompositionLocal的方式向下级元素传递的

@ExperimentalComposeUiApi
@Composable
internal fun ProvideCommonCompositionLocals(
    owner: Owner,
    uriHandler: UriHandler,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalDensity provides owner.density,
        content = content
    )
}

查看源码可以看到 Compose 组件中 density 的取值都是这行代码

val density = LocalDensity.current

所以 Compose 中实现头条适配方案只需要在 Compose 最外层修改 LocalDensity 。

在 WanAndroidTheme 中实现头条屏幕适配

@Composable
fun WanAndroidTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    designSize:DpSize? = null,
    content: @Composable () -> Unit
) {    
    val displayMetrics = LocalContext.current.resources.displayMetrics
    var density = displayMetrics.density
    //计算 density
    designSize?.let {
        val orientation = LocalConfiguration.current.orientation
        val screenWidth = displayMetrics.widthPixels
        density= if (orientation == Configuration.ORIENTATION_PORTRAIT){
            //竖屏以设计的宽为基准
            screenWidth / it.width.value
        }else{
            //横屏以设计的高为基准
            screenWidth / it.height.value
        }
    }
    val fontScale = LocalDensity.current.fontScale
    CompositionLocalProvider(
        LocalSpacing provides Spacing(),
        //修改 LocalDensity
        LocalDensity provides Density(density = density, fontScale = fontScale)
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            content = content
        )
    }
}

设置适配尺寸

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WanApp(){

    val wanAppState = rememberWanAppState()

    WanAndroidTheme(designSize = DpSize(360.dp,640.dp)) {
        ......
    }
}.. } }

不到 20 行代码我们就在 Compose 中完美实现了头条适配方案 (如果应用只在非折叠屏手机这类设备上)

支持不同的 WindowSize

大概是因为 Application 级别的 Window 默认是填满屏幕的所以一直就叫屏幕适配吧?

就应用而言 Android 屏幕适配更准确的说应该是基于 Window 的 UI 适配。使用 dp 之后我们不在关心屏幕的物理尺寸、分辨率,所有的工作都在 Window 上完成的。

Android 根据 Window 的宽/高 dp 值将设备分为小、中、大三类。

51BAFD6C-6BFB-4C90-A4E1-F6A89D1AF577.png

95D0E398-B262-483A-B558-DAC68B8FD32D.png

58DD836D-9865-4CEA-A180-F1D64B40CBAE.png

接下来完善我们的适配方案,支持以 WindowSize 级别的指定设计尺寸。

添加依赖

"androidx.compose.material3:material3-window-size-class:1.0.0-beta01"

添加 WindowSize.kt

interface WindowCompatConfig {
    //小型设备设计稿宽高
    val designCompactSize: DpSize?
    //中型设备设计稿宽高
    val designMediumSize: DpSize?
    //大型设备设计稿宽高
    val designExpandSize: DpSize?

    companion object None:WindowCompatConfig{
        override val designCompactSize: DpSize?
            get() = null
        override val designMediumSize: DpSize?
            get() = null
        override val designExpandSize: DpSize?
            get() = null
    }
}



data class AutoWindowInfo(
    val density: Float, // 适配后的 density
    val screenWidthDp:Dp,// 适配后宽度 dp
    val screenHeightDp:Dp,
    val windowSizeClass: WindowSizeClass,//当前 WindowSize 级别
)

val LocalAutoWindowInfo = staticCompositionLocalOf <AutoWindowInfo> { error("No AutoWindowInfo provided") }

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun getAutoWindowInfo(activity: Activity, config: WindowCompatConfig): AutoWindowInfo{
    val sizeClass = calculateWindowSizeClass(activity)
    val orientation = LocalConfiguration.current.orientation
    val displayMetrics = LocalContext.current.resources.displayMetrics
    val screenWidth = displayMetrics.widthPixels
    val screenHeight = displayMetrics.heightPixels
    var density = displayMetrics.density
    if (orientation == Configuration.ORIENTATION_PORTRAIT){
        when (sizeClass.widthSizeClass) {
            WindowWidthSizeClass.Compact -> {
                config.designCompactSize?.let {
                    density = getDensity(screenWidth,it.width)
                }
            }
            WindowWidthSizeClass.Medium -> {
                config.designMediumSize?.let {
                    density = getDensity(screenWidth,it.width)
                }
            }
            WindowWidthSizeClass.Expanded -> {
                config.designExpandSize?.let {
                    density = getDensity(screenWidth,it.width)
                }
            }
            else -> {
            }
        }
    }else{
        when (sizeClass.heightSizeClass) {
            WindowHeightSizeClass.Compact -> {
                config.designCompactSize?.let {
                    density = getDensity(screenWidth,it.height)
                }
            }
            WindowHeightSizeClass.Medium -> {
                config.designMediumSize?.let {
                    density = getDensity(screenWidth,it.height)
                }
            }
            WindowHeightSizeClass.Expanded -> {
                config.designExpandSize?.let {
                    density = getDensity(screenWidth,it.height)
                }
            }
            else -> {
            }
        }
    }

    val screenWidthDp = (screenWidth / density).roundToInt().dp
    val screenHeightDp = (screenHeight / density).roundToInt().dp
    
    return AutoWindowInfo(density,screenWidthDp,screenHeightDp,sizeClass)
}

private fun getDensity(deviceWidth:Int,designWidth: Dp): Float = deviceWidth / designWidth.value

修改 WanAndroidTheme

@Composable
fun WanAndroidTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    windowCompatConfig: WindowCompatConfig = WindowCompatConfig.None,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColors
        else -> LightColors
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
            ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
        }
    }

    val autoWindowInfo = getAutoWindowInfo(activity = LocalContext.current as Activity, config = windowCompatConfig)
    val fontScale = LocalDensity.current.fontScale
    CompositionLocalProvider(
        LocalSpacing provides Spacing(),
        LocalAutoWindowInfo provides autoWindowInfo,
        LocalDensity provides Density(density = autoWindowInfo.density, fontScale = fontScale)
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            content = content
        )
    }
}

修改 WanApp

fun WanApp(){

    val wanAppState = rememberWanAppState()
    //只改变配置 CompactSize 级别设备的 density
    //未配置的 WindowSize 不会改变其 density
    WanAndroidTheme(windowCompatConfig = object : WindowCompatConfig {
        override val designCompactSize: DpSize
            get() = DpSize(360.dp,640.dp)
        override val designMediumSize: DpSize?
            get() = null
        override val designExpandSize: DpSize?
            get() = null

    }) 

测试

@Composable
fun UiFqa() {
    Column(modifier = Modifier.fillMaxSize()) {
        Box(modifier = Modifier.height(60.dp)
            .width(LocalAutoWindowInfo.current.screenWidthDp)
            .background(color = Color.Magenta)
        ){
            Text(text = "${LocalAutoWindowInfo.current.screenWidthDp}")
        }
        Text(
            modifier = Modifier.padding(horizontal = WanAndroidTheme.spacing.largePadding),
            text = "问答"
        )
    }
}

image.png

折叠设备

可折叠设备目前没有测试机,无法测试折叠中间状态,以后有条件再补充。

WindowSize 和 折叠设备适配都可以参考下面的官方练习

developer.android.com/guide/pract…

Git 地址