Android 大屏与折叠屏适配全链路:从 WindowSizeClass 窗口尺寸分级到 Compose 自适应布局的声明式响应架构

两年前我接手一个平板适配项目,第一件事就是改布局 XML —— 给十几个页面分别加 layout-sw600dplayout-sw840dp 资源目录。改到第 5 个页面我就不干了:维护两套独立布局文件,UI 调一处就得同步改三处,不出三个月必然出现”平板端改了竖屏忘了横屏”的线上事故。

Android 12L 引入的 WindowSizeClass 窗口尺寸分级机制,正是冲着这个问题来的:它不再让你对着像素宽度写死断点,而是把窗口抽象为”紧凑-中等-扩展”三个语义级别,布局逻辑跟着语义走,而不是跟着具体数值走。

WindowSizeClass:三个级别解决所有屏幕

WindowSizeClass 按窗口可用宽度和高度分别分级,宽度分三级:

级别断点典型设备场景
Compact< 600dp手机竖屏、折叠屏折叠态
Medium600dp ~ 840dp手机横屏、小平板竖屏、折叠屏展开态(窄)
Expanded≥ 840dp平板横屏、折叠屏全展开、桌面模式

断点值 600dp 和 840dp 是官方推荐值,Material 3 的规范也围绕这两条线设计。获取当前窗口级别的代码:

@Composable
fun MainScreen() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
    val widthClass = windowSizeClass.windowWidthSizeClass
    
    when (widthClass) {
        WindowWidthSizeClass.COMPACT -> CompactLayout()
        WindowWidthSizeClass.MEDIUM -> MediumLayout()
        WindowWidthSizeClass.EXPANDED -> ExpandedLayout()
        else -> CompactLayout()
    }
}

currentWindowAdaptiveInfo() 返回的不只是宽度级别,还包含 windowHeightSizeClasswindowPosture(折叠屏姿态)。高度级别在横屏场景不能忽略 —— 手机横屏时宽度是 Medium,高度通常还是 Compact,列表项的高度和间距必须跟上,否则内容会被纵向压缩得没法看。

WindowSizeClass 的值是响应式的:折叠屏展开/折叠、分屏模式拖拽、窗口大小调整,都会触发 recomposition,不需要手动监听 Configuration 变化。后面 Compose 自适应布局能跑通,靠的就是这个响应能力。

实际项目中的断点微调

官方推荐 600dp/840dp,我在项目里把 Medium 的下限从 600dp 调到了 500dp。原因是团队测试发现:Pixel Fold 展开时内屏宽度约 585dp,按官方断点会被归为 Compact,但那个尺寸显然不适合堆叠式布局,用双列布局体验更好。

调整方式不依赖官方 API 预置值:

enum class AdaptiveWindowSize(
    val minWidthDp: Float
) {
    COMPACT(0f),
    MEDIUM(500f),    // 自定义:500dp 起
    EXPANDED(840f)
}

@Composable
fun rememberAdaptiveSize(): AdaptiveWindowSize {
    val widthDp = with(LocalDensity.current) {
        LocalConfiguration.current.screenWidthDp.toFloat()
    }
    return when {
        widthDp >= AdaptiveWindowSize.EXPANDED.minWidthDp -> AdaptiveWindowSize.EXPANDED
        widthDp >= AdaptiveWindowSize.MEDIUM.minWidthDp -> AdaptiveWindowSize.MEDIUM
        else -> AdaptiveWindowSize.COMPACT
    }
}

踩过一个坑:不要直接用 screenWidthDp 判断,它在分屏模式下代表的是物理屏幕宽度而非窗口宽度。正确做法是从 WindowMetricsCalculator 获取当前窗口的实际 bounds。上面那段简化写法只在全屏场景准确,分屏场景会误判。如果你的 App 需要支持分屏或多窗口,用这个版本:

val windowMetrics = WindowMetricsCalculator.getOrCreate()
    .computeCurrentWindowMetrics(activity)
val widthDp = windowMetrics.bounds.width() / 
    resources.displayMetrics.density

Compose 中的声明式自适应布局

有了 WindowSizeClass,Compose 层的自适应布局就不是”先判断尺寸再选布局”,而是用声明式 API 描述”当前空间下内容应该如何排布”。Material 3 的 adaptive layout 组件封装了三种核心布局模式,覆盖不同窗口宽度级别。

列表-详情双面板:Expanded 场景

最常见的场景是”列表-详情”双面板,Expanded 下左右并排,Compact 下分两页。M3 的 ListDetailPaneScaffold 专门处理这个模式:

@Composable
fun ConversationsScreen() {
    val navigator = rememberListDetailPaneScaffoldNavigator<ListItem>()
    
    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            AnimatedPane {
                ConversationList(onItemClick = { item ->
                    navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, item)
                })
            }
        },
        detailPane = {
            AnimatedPane {
                navigator.currentDestination?.content?.let { item ->
                    ConversationDetail(item)
                } ?: Placeholder()
            }
        }
    )
}

directivevalue 由 navigator 根据当前窗口宽度自动驱动:Expanded 时双面板同时展示,Compact 时导航到详情覆盖列表。不需要手动写 when(widthClass) 判断。

AnimatedPane 让面板切换有过渡动画,但这层动画在配置变化(折叠屏展开/合上)时可能掉帧。我的做法是在 isConfigurationChanging 时临时禁用动画,变化完成后再恢复。

辅助面板:Medium 场景的补充

Medium 宽度不上不下 —— 放两个完整面板太挤,放一个面板太浪费。这种场景用 SupportingPaneScaffold,在主导航旁边加一个窄辅助面板:

@Composable
fun DocumentEditor() {
    SupportingPaneScaffold(
        mainPane = { DocumentCanvas() },
        supportingPane = { ToolPalette() }
    )
}

Expanded 时辅助面板常驻显示,Compact/Medium 时收起到侧边抽屉或底部 Sheet。supportingPane 的宽度由框架控制在约 240dp-360dp 之间,不会挤压主面板。

我在文档编辑场景中用它放工具栏:平板横屏时工具栏固定在右侧,折叠屏竖屏时自动收到 overflow menu 里,零额外代码。

自适应导航栏:全宽度段适配

底部导航栏在手机上只有 3-5 个 tab,但大屏上底部 tab 的操作热区偏大、视觉也不够紧凑。NavigationSuiteScaffold 根据窗口尺寸自动切换导航样式:

@Composable
fun AppNavigation() {
    NavigationSuiteScaffold(
        navigationSuiteItems = {
            tabs.forEach { tab ->
                item(
                    icon = { Icon(tab.icon, contentDescription = tab.label) },
                    label = { Text(tab.label) },
                    selected = currentTab == tab,
                    onClick = { currentTab = tab }
                )
            }
        }
    ) {
        // 内容区域
    }
}

默认行为:Compact → 底部导航栏,Medium → 侧边导航栏(带图标+文字),Expanded → 常驻侧边抽屉。可以通过 NavigationSuiteType 参数覆盖。我对 Medium 做了定制 —— 7-8 寸小平板用侧边栏效果不错,但折叠屏展开态(更接近方形)用侧边栏会挤压内容宽度,所以改成了 NavigationSuiteScaffoldLayout.Compact

折叠屏姿态感知:不只是大小变化

折叠屏除了窗口尺寸变化,还有**姿态(Posture)**变化。WindowLayoutInfo 提供两类关键信息:

  • FoldingFeature:设备折叠/展开状态,折叠轴位置和角度
  • DisplayFeature:屏幕是否存在物理分离区域(如铰链遮挡区)

Compose 中获取姿态信息:

@Composable
fun FoldableAwareLayout() {
    val windowInfo = currentWindowAdaptiveInfo().windowPosture
    val hingePosition = remember(windowInfo) {
        (windowInfo as? WindowPosture.Folded)?.hingePosition
    }
    
    if (hingePosition != null && isSeparating(hingePosition)) {
        // 铰链两侧各自独立布局,避免内容被折叠区域遮挡
        HingeSeparatedLayout(hingePosition)
    } else {
        NormalLayout()
    }
}

一个容易栽跟头的地方:hingePosition 返回的是相对于窗口的像素坐标,不同设备铰链物理位置差异很大。Galaxy Fold 的铰链在正中间,Surface Duo 的铰链有明确物理宽度。不要假设铰链位置,一定从 API 拿实际值再计算两侧可用空间。

isSeparating 这个判断也值得单独拿出来说:同一个姿态下,有些 App 需要在铰链两侧分别布局(避免内容被遮挡),有些 App 需要跨越铰链铺满(如地图)。这不是技术层面的问题,是产品设计决策。

适配策略:两套设计稿就够了

回到开头”维护三套布局”的问题。我现在的策略是:只维护 Compact 和 Expanded 两套 UI 设计稿,Medium 从 Expanded 的组件中自动降级

具体做法:Medium 宽度段复用 Expanded 的双面板结构,但把辅助面板收起到可呼出状态,或者直接用单面板 + BottomSheet 的变体。Medium 不需要独立设计稿,布局框架本身就能给出合理的默认行为。

这套策略的前提是在 Compose 层用自适应组件代替硬编码宽度判断。ListDetailPaneScaffoldSupportingPaneScaffoldNavigationSuiteScaffold 三个组件覆盖了 80% 以上的日常布局场景,剩下 20% 用 BoxWithConstraints 配合 WindowSizeClass 手动处理。

效果:之前 16 个页面需要 48 个布局文件(每页面 3 套),现在每个页面 1 个 Composable 函数,内部用自适应组件处理所有宽度段,新增折叠屏设备形态时基本零代码改动。