Compose 大屏适配别乱做!这几个坑 90% 的人都在踩

Android App 现在不能只按手机竖屏设计。同一个 App 可能跑在手机、平板、折叠屏、ChromeOS、桌面窗口和分屏模式里。同一台设备的窗口大小,也可能在运行时变化。

Compose 自适应布局的核心问题不是"这是不是平板",而是"当前 App 窗口还有多少空间"。

先读窗口,不读设备

很多老代码会这样写:

bash 复制代码
if (isTablet) {
    TabletHomeScreen()
} else {
    PhoneHomeScreen()
}

这类判断在今天很不友好。因为平板可以进入小窗口分屏,折叠屏可以展开或合上,ChromeOS 窗口可以被用户随手拖动。设备类型没变,但 App 可用空间已经变了。

Compose Material 3 Adaptive 提供了 currentWindowAdaptiveInfo()。

它读的是当前窗口信息,不是设备型号。

bash 复制代码
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    val width = windowSizeClass.widthSizeClass
    val height = windowSizeClass.heightSizeClass
}

常用宽度分成三档:

bash 复制代码
WindowWidthSizeClass.Compact
WindowWidthSizeClass.Medium
WindowWidthSizeClass.Expanded

这三档比 isTablet 更接近真实 UI 决策。

双栏还要看高度

很多列表详情页会写成:

bash 复制代码
val showTwoPane = width == WindowWidthSizeClass.Expanded

只看宽度还不够。横屏手机可能有较宽的窗口,但高度很低。这个时候强行显示列表 + 详情,两个 pane 都会显得挤。

更稳的判断是把高度也放进去:

bash 复制代码
val showTwoPane =
    width == WindowWidthSizeClass.Expanded &&
        height != WindowHeightSizeClass.Compact

这条规则不是标准答案,但适合大多数 list-detail 页面。

宽度够,说明能放下两个 pane;高度不紧,说明详情页还有可阅读空间。

列表详情是最常见模式

邮件、设置、消息、笔记、文档、订单管理,很多页面都是 list-detail。

小屏上,用户先看到列表,点进去再看详情。大屏上,左边保留列表,右边显示选中项详情。这个模式的价值不是"利用大屏填满空间",而是减少来回导航。用户切换邮件、设置项或文档时,不需要不断返回列表。

bash 复制代码
data class Mail(
    val id: Long,
    val sender: String,
    val subject: String,
    val body: String,
)

屏幕根节点只维护一个核心状态:当前选中的 item。

bash 复制代码
var selectedMailId by rememberSaveable {
    mutableStateOf<Long?>(null)
}

val selectedMail = mails.firstOrNull {
    it.id == selectedMailId
}

不要为手机和平板各维护一套状态。

同一个 selectedMailId,在小屏驱动"列表 / 详情切换",在大屏驱动"左侧选中态 + 右侧详情"。

根节点决定排列方式

自适应布局最容易失控的地方,是把 phone / tablet 分支写到每个组件里。

更好的方式是:根节点判断布局结构,子组件保持可复用。

bash 复制代码
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
    val info = currentWindowAdaptiveInfo()
    val width = info.windowSizeClass.widthSizeClass
    val height = info.windowSizeClass.heightSizeClass

    val showTwoPane =
        width == WindowWidthSizeClass.Expanded &&
            height != WindowHeightSizeClass.Compact

    val mails = remember { sampleMails() }
    var selectedMailId by rememberSaveable { mutableStateOf<Long?>(null) }
    val selectedMail = mails.firstOrNull { it.id == selectedMailId }

    if (showTwoPane) {
        TwoPaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            selectedMailId = selectedMailId,
            onMailSelected = { selectedMailId = it }
        )
    } else {
        SinglePaneMailContent(
            mails = mails,
            selectedMail = selectedMail,
            onMailSelected = { selectedMailId = it },
            onBack = { selectedMailId = null }
        )
    }
}

这段代码的重点不是 Row 怎么写。

重点是状态没有分叉,只有布局结构分叉。

小屏只显示一个 pane

小屏里,如果没有选中邮件,就显示列表。

如果已经选中邮件,就显示详情。

bash 复制代码
@Composable
private fun SinglePaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    onMailSelected: (Long) -> Unit,
    onBack: () -> Unit,
) {
    if (selectedMail == null) {
        MailList(
            mails = mails,
            selectedMailId = null,
            onMailSelected = onMailSelected,
            modifier = Modifier.fillMaxSize()
        )
    } else {
        MailDetail(
            mail = selectedMail,
            showBackButton = true,
            onBack = onBack,
            modifier = Modifier.fillMaxSize()
        )
    }
}

小屏不需要硬塞一个窄详情 pane。

这种布局里,返回逻辑也要跟着布局走。

详情页可见时,返回应该清空 selectedMailId,让用户回到列表。

bash 复制代码
if (!showTwoPane && selectedMail != null) {
    BackHandler {
        selectedMailId = null
    }
}

大屏保留上下文

大屏写法可以很直接:

bash 复制代码
@Composable
private fun TwoPaneMailContent(
    mails: List<Mail>,
    selectedMail: Mail?,
    selectedMailId: Long?,
    onMailSelected: (Long) -> Unit,
) {
    Row(Modifier.fillMaxSize()) {
        MailList(
            mails = mails,
            selectedMailId = selectedMailId,
            onMailSelected = onMailSelected,
            modifier = Modifier.weight(0.4f)
        )

        VerticalDivider()

        MailDetail(
            mail = selectedMail,
            showBackButton = false,
            onBack = null,
            modifier = Modifier.weight(0.6f)
        )
    }
}

0.4f / 0.6f 只是起点。

列表密度高,就给列表更多空间;详情内容长,就给详情更多空间。不要把比例写成设计系统规则。

大屏里还要显示选中态。

小屏打开详情后列表不可见,选中态没有意义;大屏列表一直在左边,选中态能告诉用户右侧内容来自哪一项。

导航也要自适应

页面内容要自适应,App 主导航也要自适应。

小屏一般用 bottom navigation。

中大屏更适合 navigation rail,导航项固定在左侧,不占用底部高度。

手写版本大概是这样:

bash 复制代码
@Composable
fun AdaptiveAppShell(
    useBottomBar: Boolean,
    content: @Composable (Modifier) -> Unit,
) {
    if (useBottomBar) {
        Scaffold(
            bottomBar = {
                NavigationBar {
                    NavigationBarItem(
                        selected = true,
                        onClick = {},
                        icon = { Icon(Icons.Default.Home, null) },
                        label = { Text("Home") }
                    )
                }
            }
        ) { padding ->
            content(Modifier.padding(padding))
        }
    } else {
        Row(Modifier.fillMaxSize()) {
            NavigationRail {
                NavigationRailItem(
                    selected = true,
                    onClick = {},
                    icon = { Icon(Icons.Default.Home, null) },
                    label = { Text("Home") }
                )
            }
            content(Modifier.weight(1f))
        }
    }
}

真实项目里,可以优先看 NavigationSuiteScaffold。

它会根据窗口大小和设备姿态,在 navigation bar、navigation rail 等形态之间切换。

依赖是:

bash 复制代码
dependencies {
    implementation("androidx.compose.material3:material3-adaptive-navigation-suite")
}

手写逻辑适合少量页面,官方 scaffold 适合整站导航。

官方 scaffold 不是必须一开始就上

Material 3 Adaptive 里还有几类组件:

bash 复制代码
ListDetailPaneScaffold
NavigableListDetailPaneScaffold
NavigationSuiteScaffold

ListDetailPaneScaffold 负责标准 list-detail 结构。

NavigableListDetailPaneScaffold 在它之上处理 pane 间导航和返回动画。

NavigationSuiteScaffold 处理 App 主导航形态切换。

列表详情相关依赖通常是:

bash 复制代码
dependencies {
    implementation("androidx.compose.material3.adaptive:adaptive")
    implementation("androidx.compose.material3.adaptive:adaptive-layout")
    implementation("androidx.compose.material3.adaptive:adaptive-navigation")
}

如果页面很简单,手写 if (showTwoPane) Row(...) else ... 可读性更高。

如果详情 pane 里还有自己的导航、返回行为变复杂、需要 pane 级保存恢复,再上 NavigableListDetailPaneScaffold。

不要为了"用了官方自适应组件"而让简单页面变复杂。

最后

Compose 自适应布局的基本思路很朴素:读当前窗口,保留同一份状态,只改变布局排列。

小窗口单栏,大窗口双栏;小窗口底部导航,大窗口侧边导航。

真正要避免的是把"平板适配"写成一个单独工程。Android 的窗口形态已经变成动态条件,布局代码也应该跟着窗口走。

#Android #JetpackCompose #自适应布局 #Material3

相关推荐
浪潮IT馆14 分钟前
Android Studio 非最新版本下载安装教程
android·ide·android studio
xiaozongt19891 小时前
MasterGo + Claude Code 生成 Android 布局:完整实践指南
android
Anhty2 小时前
2026最新免费手机音频处理工具 !!
android·功能测试·ios·智能手机·音视频
2501_915909065 小时前
iOS应用从开发到上架App Store的完整发布流程与步骤指南
android·ios·小程序·https·uni-app·webview
狗凯之家源码网5 小时前
AI 步数修改提交系统源码应用场景与落地指南
android·数据库·人工智能·运动步数
ZHOUPUYU6 小时前
PHP 9.0 前瞻:下一代 PHP 会带来哪些新功能?
android·开发语言·php
JMchen6 小时前
性能优化——让Native代码飞起来
android·c++·性能优化
ZHOUPUYU6 小时前
PHP 8.2 的核心高级特性,包括只读类、类型系统增强
android·性能优化·php
ZHOUPUYU7 小时前
PHP 8.0 高级技术实战:从新特性到性能优化
android·性能优化·php
不爱说话郭德纲7 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app