Android 以后可能不会再有横竖屏适配了

0 阅读11分钟

cover.png

各位 Android 爱好者们,大家好。

多年来,不少 Android 项目都把屏幕方向当成了 UI 适配的主要依据。

例如下面这样的代码:

if (configuration.orientation == ORIENTATION_LANDSCAPE) {
    LandscapeScreen()
} else {
    PortraitScreen()
}

但问题是,这套做法越来越不够用了。

到了 2026 年 10 月,Android 设备的屏幕比例已经很丰富了,从 16:9、4:3,到接近 √2:1 的比例都有。

你的应用可能在平板上竖屏运行,也可能在横向显示器里占据一个狭窄的桌面窗口,或是与另一个应用分屏显示,又或是铺满折叠屏的内屏。设备方向已经无法告诉你,UI 实际能用多大的空间。

或者说,横竖屏的判断本身,已经不够用了!

Android 16 开始对以 API 36 为目标版本的应用,在符合条件的大屏上默认忽略方向、窗口大小调整和宽高比限制,推动应用适应不同窗口。

Android 17 则完成了这次过渡:当应用在 Android 17 上运行、且以 API 37 或更高版本为目标时,在最小宽度至少为 600dp 的显示屏上,系统会忽略屏幕方向、窗口可调整大小和宽高比限制。Android 16 提供的临时退出机制也被移除了。在这种环境下,平台可以忽略 screenOrientation、resizeableActivity、minAspectRatio、maxAspectRatio,以及传给 setRequestedOrientation() 的横竖屏方向值。通过 android:appCategory 标记的游戏和小于 sw600dp 的显示屏仍属例外。用户在设备宽高比设置中明确选择应用默认行为时,也属于例外。

这倒不是说横屏和竖屏这两种物理状态被废弃了,而是说,它们不再能作为应用布局的可靠前提。

替代思路其实很简单:

别再围绕设备方向设计布局,而要围绕应用当前拥有的窗口来设计。

到底变了什么

先区分两件事:API 被废弃,和系统忽略某项限制,并不是一回事。

下面的系统行为针对前述 Android 17、目标 API 和大屏条件:

过去的假设Android 17 在 sw600dp+ 大屏上的实际行为
screenOrientation="portrait" 能保证竖屏显示系统可能忽略这项设置
resizeableActivity="false" 能阻止窗口大小调整系统仍会将应用视为可调整大小的应用
maxAspectRatio 能让内容区域保持手机屏幕的比例这项限制不再生效
setRequestedOrientation(...) 能控制大屏的显示方向横屏和竖屏请求都可能被忽略
横屏意味着“宽度足够放下两个窗格”横屏应用窗口的可用宽度仍可能不足以放下两个窗格
竖屏意味着“单列的手机 UI”竖屏平板窗口也可能属于展开尺寸类别

对于以 API 36 为目标版本的应用,Android 16 默认采用上述行为,但也提供了一个临时兼容性属性。到了 Android 17,以 API 37 为目标版本的应用就不能再通过这个属性退出了。

所以,迁移的目标不是换一个控制屏幕方向的 API,而是采用自适应 UI 架构。

窗口、尺寸类别和窗格

大多数横竖屏判断,都可以用下面三个概念替代:

  1. 窗口(Window):系统实际分配给应用的区域,而不是物理显示屏。
  2. 窗口尺寸类别(Window size class):通过一组稳定的断点,对可用宽度和高度进行分类。
  3. 窗格(Pane):一个内容区域。在紧凑窗口中可以单独显示,在较大窗口中可以与相关内容并排显示。

为什么要这样区分?同一台平板,几秒钟内就可能让应用从展开尺寸的全屏窗口,变成中等尺寸的分屏窗口,再变成紧凑尺寸的桌面悬浮窗口。Android 官方指南也明确建议,根据应用窗口决定布局,而不是先判断设备属于手机还是平板。

用布局策略替代方向判断

使用 Material 3 Adaptive 1.2.0,Compose 应用可以读取当前的 WindowSizeClass,并在应用窗口变化时作出响应。这个稳定版本还通过 WindowManager 1.5.0 支持了 Large 和 Extra-large 宽度类别。

dependencies {
    implementation("androidx.compose.material3.adaptive:adaptive:1.2.0")
}

我们可以定义一个简单的布局策略,避免在 UI 各处散落方向判断:

import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.window.core.layout.WindowSizeClass

enum class ContentLayout {
    SinglePane,
    TwoPane,
}
@Composable
fun currentContentLayout(): ContentLayout {
    val windowSizeClass = currentWindowAdaptiveInfo(
        supportLargeAndXLargeWidth = true,
    ).windowSizeClass
    return if (
        windowSizeClass.isWidthAtLeastBreakpoint(
            WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND,
        )
    ) {
        ContentLayout.TwoPane
    } else {
        ContentLayout.SinglePane
    }
}

这样,功能页面响应的就是可用空间,而不是设备标签:

@Composable
fun MessageRoute(
    state: MessageUiState,
    onMessageSelected: (String) -> Unit,
) {
    when (currentContentLayout()) {
        ContentLayout.SinglePane -> {
            if (state.selectedMessage == null) {
                MessageList(
                    messages = state.messages,
                    onMessageSelected = onMessageSelected,
                )
            } else {
                MessageDetail(message = state.selectedMessage)
            }
        }

        ContentLayout.TwoPane -> {
            Row(Modifier.fillMaxSize()) {
                MessageList(
                    messages = state.messages,
                    onMessageSelected = onMessageSelected,
                    modifier = Modifier.weight(0.4f),
                )
                VerticalDivider()
                MessageDetail(
                    message = state.selectedMessage,
                    modifier = Modifier.weight(0.6f),
                )
            }
        }
    }
}

这里省略了 MessageUiState 和子组件的定义,重点是布局切换。双窗格模式下还没有选中消息时,MessageDetail 需要能接收 null 并显示占位内容;单窗格模式还需要处理从详情返回列表的操作。

同一个判断,就能用于旋转后的手机、展开的折叠屏、平板、ChromeOS 窗口、桌面窗口和外接显示屏。不需要 isTablet,不需要 isLandscape,也不用维护两套重复的横竖屏界面树。

实际项目中的列表与详情流程,优先考虑 NavigableListDetailPaneScaffold。

相比手写 Row,它对单窗格与多窗格展示、窗格导航和预测性返回的处理更完整。类似地,NavigationSuiteScaffold 可以根据空间变化,把顶层导航从底部导航栏切换为侧边导航栏。

自适应不仅仅是多一个窗格

窗口变大,并不意味着每个组件都可以无限拉伸。

在 360dp 宽度下看起来很合适的登录表单,放到桌面显示器上,可能就成了一组一米宽的输入框。在整体布局适应窗口的同时,局部组件也要适应各自的空间:

@Composable
fun SignInContent(
    email: String,
    onEmailChanged: (String) -> Unit,
    onSubmit: () -> Unit,
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.TopCenter,
    ) {
        Column(
            modifier = Modifier
                .widthIn(max = 640.dp)
                .fillMaxWidth()
                .verticalScroll(rememberScrollState())
                .imePadding()
                .padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            OutlinedTextField(
                value = email,
                onValueChange = onEmailChanged,
                modifier = Modifier.fillMaxWidth(),
                label = { Text("Email") },
            )
            Button(
                onClick = onSubmit,
                modifier = Modifier.fillMaxWidth(),
            ) {
                Text("Continue")
            }
        }
    }
}

相比只考虑竖屏的布局,这里有三个细节能让页面更好地适应窗口变化:

  • widthIn(max = 640.dp) 避免表单在视觉上被过度拉宽。
  • verticalScroll(...) 让用户在高度较小的横屏或分屏窗口里,仍然能滚动到各个控件。
  • imePadding() 为输入法留出空间,避免键盘压缩可用高度后遮挡主要操作。

Android 的适配检查清单专门列出了这些常见问题:组件拉伸、操作按钮超出屏幕、相机预览异常,以及窗口变化时状态丢失。

窗口变化,状态不能丢

有时,应用锁定方向是为了避免 Activity 重建,而不是因为体验确实需要固定方向。这种状态管理方式一直都很脆弱。

把业务状态和页面状态放到 ViewModel 中;对于由 composable 自己管理的少量 UI 状态,使用 rememberSaveable。需要注意,ViewModel 能跨配置变更保留状态,但不能独自应对系统终止进程;需要恢复的少量状态还应使用 SavedStateHandle 等保存机制,业务数据则按需持久化:

@Composable
fun SearchRoute(viewModel: SearchViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    var expandedFilter by rememberSaveable { mutableStateOf<String?>(null) }

    SearchScreen(
        state = uiState,
        expandedFilter = expandedFilter,
        onFilterExpanded = { expandedFilter = it },
        onQueryChanged = viewModel::onQueryChanged,
    )
}

用户应该能够在表单中输入内容,然后旋转设备、展开折叠屏、调整窗口大小,再从刚才的位置继续操作。不要把 android:configChanges 当成解决所有状态丢失问题的补丁。如果选择不让系统因相应配置变化而重建 Activity,应用就需要负责正确更新所有受影响的资源和 UI 元素;进程终止等其他原因仍然可能导致重建。处理配置变更

对方向敏感的功能怎么办

有些体验确实在意显示区域的形状。视频播放器可能提供横屏按钮,相机预览需要协调传感器方向与 UI Surface 的关系,绘图应用也可能针对宽画布优化工具的位置。

这些需求没有消失,只是需要在更局部的层面实现。

  • 小于 sw600dp 的手机,仍然可以响应受支持的方向请求。
  • 对于以 API 37 为目标版本、运行在大屏上的应用,要让功能适应系统提供的窗口。
  • 视频的控件和内容缩放应适应容器,不要假定自己占据整块物理显示屏。
  • 相机预览的变换应根据传感器、预览 Surface、旋转角度和缩放类型计算。不要只凭“竖屏”或“横屏”推断它们之间的关系。
  • 对于需要宽阔空间的创作功能,宽度允许时可以提供专门优化的宽屏布局,同时也要保留可用的紧凑布局。

某个功能可以更适合某种窗口形状,但不应该让整个应用都依赖这种形状。

现有项目怎么改🌰

1. 找出隐藏的方向假设

在现有代码中搜索下面这些关键词:

screenOrientation
requestedOrientation
setRequestedOrientation
resizeableActivity
minAspectRatio
maxAspectRatio
ORIENTATION_PORTRAIT
ORIENTATION_LANDSCAPE
LocalConfiguration.current.orientation

逐个判断搜索结果属于哪一类:真正的功能约束、布局决策,还是规避状态丢失的临时方案。

2. 从功能接口中移除屏幕方向

避免这样的 API:

fun DetailsScreen(isLandscape: Boolean)

改用含义明确、也更容易测试的布局策略:

fun DetailsScreen(layout: DetailsLayout)

应用外层可以根据当前窗口推导出 DetailsLayout,功能内部只需要决定每种布局模式下如何展示和交互。

3. 先让组件适应各种尺寸

在构建精致的双窗格布局之前,先保证现有页面在各种不太理想的尺寸下都能正常使用:

  • 移除硬编码的屏幕宽高;
  • 为阅读区域、表单和对话框设置最大宽度;
  • 让高度受限的内容可以滚动;
  • 考虑系统栏和输入法(IME)占用的空间;
  • 避免根据缓存的显示屏尺寸计算动画位置;
  • 检查较大字体缩放比例下的文字显示。

4. 加入自适应应用结构

然后,再引入收益较大的适配:

  • 底部导航栏 → 侧边导航栏;
  • 先列表、后详情 → 列表与详情并排;
  • 只显示主内容 → 主内容加辅助窗格;
  • 固定网格列数 → 根据宽度自适应的网格;
  • 占满宽度的表单 → 居中且有宽度上限的表单。

5. 测试窗口切换,而不只是设备

不要只测“手机竖屏”和“平板横屏”,还要测试这些切换过程和场景:

  • 紧凑 → 展开 → 紧凑;
  • 全屏 → 分屏;
  • 折叠 → 展开;
  • 编辑表单时从竖屏切到横屏;
  • 调整桌面窗口大小,使其跨过布局断点;
  • 在高度较小的窗口中打开键盘;
  • 完成选择或导航操作后,重建应用进程。

可以从 Pixel Tablet 和 Pixel Fold 模拟器开始测试。

Android 兼容性框架也支持启用 UNIVERSAL_RESIZABLE_BY_DEFAULT,用于迁移测试。Compose 自动化测试应注入表达布局含义的模式或窗口尺寸类别,这样就能独立于特定模拟器,验证紧凑和展开布局的行为。

对了,希望这篇文章可以帮到你——适配小米“中”折屏,非得买一台吗。

新的 UI 架构是什么样

未来的 Android 界面,不再是过去那种固定的“屏幕”,而是一个可以调整大小、按需显示一个或多个窗格的窗口。用户操作的过程中,窗口形状也可能发生变化。

这会让职责划分更清楚:

  • 平台管理窗口;
  • 应用外层判断可用空间;
  • 功能提供含义明确的布局模式;
  • 组件在各个模式下灵活适应空间;
  • 状态在每次切换后都能保留。

横屏和竖屏依然存在,只不过它们是更复杂运行环境中的两个输入条件,不宜再作为决定 UI 布局的唯一依据。

如果应用以 Android 17 为目标版本,与其只问“怎么让这个页面一直保持竖屏”,不如先考虑:在用户选择的窗口里,这个功能怎样呈现才最合适?