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 的窗口形态已经变成动态条件,布局代码也应该跟着窗口走。