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

相关推荐
HLC++12 小时前
Linux的进程间通信
android·linux·服务器
爱笑鱼16 小时前
Binder(二):AIDL 生成的 Proxy、Stub 和 Parcel 到底在做什么?
android
爱笑鱼16 小时前
Binder(一):一次方法调用,究竟怎样跨进程执行?
android
壮哥_icon17 小时前
【Android 系统开发】使用 BAT 脚本高效自动化管理 /system/priv-app/ 系统应用(安装与卸载)
android·运维·自动化
用户693717500138418 小时前
Claude Code终端日志Token占用实测:一个过滤器砍掉60%-90%
android·前端·后端
蝉蜕日记19 小时前
AccumuPDF高级版 v2.57 | 视图文转换PDF,合并分割压缩
android·智能手机·pdf·生活·软件需求
吐了啊取名字太难19 小时前
美颜系统AI修图本地跑并支持Mac、win、安卓、iOS不卡顿
android·人工智能·windows·数码相机·mac·ai编程
阿pin20 小时前
Android随笔-Retrofit
android·retrofit
想取一个与众不同的名字好难20 小时前
安卓自定义颜色选择器
android
我命由我1234521 小时前
Android 开发问题:java.lang.NoSuchMethodError:No virtual method readAllBytes()
android·java·java-ee·kotlin·android studio·android-studio·android runtime