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 值将设备分为小、中、大三类。
接下来完善我们的适配方案,支持以 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 = "问答"
)
}
}
折叠设备
可折叠设备目前没有测试机,无法测试折叠中间状态,以后有条件再补充。
WindowSize 和 折叠设备适配都可以参考下面的官方练习
developer.android.com/guide/pract…