一、先讲一个真实的尴尬
你花了两周做了一个漂亮的App,在手机上跑得完美。老板说:“拿平板演示一下。”
你打开平板,界面确实能跑。但列表项拉得老长,一行文字从屏幕左边一直延伸到右边,你得转头才能读完。卡片变得又扁又宽,图片被拉伸得变了形。底部导航栏孤零零地贴在屏幕最下方,中间隔着巨大的空白。
老板看了一眼,说:“看着像个手机应用被放大了。”
这句话的潜台词是:你没有认真对待平板用户。
更尴尬的场景还在后面。用户把折叠屏展开,你的应用瞬间变成一块小平板,布局全乱了。用户把手机横过来,你的界面还是竖屏的样子,只是被压扁了。用户在ChromeOS的窗口里拖动边缘调整大小,你的界面纹丝不动。
这些问题有一个共同的解法:自适应布局。让界面根据“窗口给了多少空间”和“设备当前什么姿态”来自动调整,而不是根据“这是一台什么设备”来硬编码判断。
这一课,我们就来把自适应布局彻底讲清楚。
二、核心原则:看窗口,不看设备
2.1 为什么不能按设备类型判断
很多开发者的第一反应是:“我判断是不是平板不就行了?”于是代码里出现这样的逻辑:
// 错误的方向valisTablet=context.resources.configuration.smallestScreenWidthDp>=600if(isTablet){// 平板布局}else{// 手机布局}这是错误的方向。官方文档明确指出,避免根据物理硬件值来确定布局。原因很简单:
- 在平板上,你的应用可能运行在多窗口模式下,只占屏幕的一半。
- 在ChromeOS上,应用可能在可调整大小的窗口里运行。
- 折叠屏展开和折叠时,同一台设备的可用空间完全不同。
物理屏幕尺寸和“你的应用实际有多少空间可以用”是两回事。你应该根据分配给应用的窗口区域来决定布局,而不是根据设备型号或屏幕尺寸。
2.2 窗口大小类:三个断点
Compose提供了WindowSizeClassAPI,把窗口的可用空间分成三个标准断点:
- Compact(紧凑):宽度小于600dp。典型场景是手机竖屏。
- Medium(中等):宽度600dp到839dp。典型场景是手机横屏、小平板竖屏、折叠屏展开但未达到平板宽度。
- Expanded(展开):宽度大于等于840dp。典型场景是平板横屏、大折叠屏展开、桌面窗口。
这三个断点来自Material Design的布局指南,目的是“灵活地针对大多数独特情形优化应用,又不至于实现起来太过困难”。
2.3 获取窗口大小类
使用Material 3 Adaptive库的currentWindowAdaptiveInfo()函数:
importandroidx.compose.material3.adaptive.currentWindowAdaptiveInfo@ComposablefunMyApp(){valwindowSizeClass=currentWindowAdaptiveInfo().windowSizeClass// windowSizeClass.windowWidthSizeClass: Compact / Medium / Expanded// windowSizeClass.windowHeightSizeClass: Compact / Medium / Expanded}需要添加依赖:
dependencies{implementation("androidx.compose.material3.adaptive:adaptive:1.3.0")}这个函数会在窗口大小变化时自动更新,你的界面会跟着重组。
2.4 用WindowSizeClass驱动布局
拿到窗口大小类之后,用when或if来决定显示哪种布局:
@ComposablefunAdaptiveApp(){valwindowSizeClass=currentWindowAdaptiveInfo().windowSizeClasswhen(windowSizeClass.windowWidthSizeClass){WindowWidthSizeClass.Compact->{// 紧凑布局:单窗格,底部导航CompactLayout()}WindowWidthSizeClass.Medium->{// 中等布局:导航栏MediumLayout()}WindowWidthSizeClass.Expanded->{// 展开布局:导航栏 + 多窗格ExpandedLayout()}}}关键原则:把屏幕尺寸的判断逻辑限制在单个位置,然后通过参数把结果显式传递给子组件。子组件不需要知道窗口大小,只需要接收一个布尔值或枚举,决定“显示导航栏还是底部栏”。
三、Material 3 Adaptive库:构建块
Material 3 Adaptive库提供了一组可组合项和工具,让自适应布局的实现变得简单。核心模块有三个:
- adaptive:底层构建块,如
currentWindowAdaptiveInfo()、HingeInfo、Posture。 - adaptive-layout:自适应布局组件,如
ListDetailPaneScaffold、SupportingPaneScaffold。 - adaptive-navigation:带导航的自适应布局,如
NavigableListDetailPaneScaffold。
3.1 导航组件:NavigationSuiteScaffold
自适应应用需要根据窗口大小切换导航形式:
- Compact:底部导航栏(
NavigationBar)。 - Medium:导航栏(
NavigationRail)。 - Expanded:导航栏或永久抽屉。
NavigationSuiteScaffold自动帮你做这个切换:
@ComposablefunAdaptiveApp(){valnavController=rememberNavController()NavigationSuiteScaffold(navigationSuiteItems={item(selected=currentDestination?.hierarchy?.any{it.hasRoute(HomeRoute::class)}==true,onClick={navController.navigate(HomeRoute)},icon={Icon(Icons.Default.Home,contentDescription="首页")},label={Text("首页")})item(selected=currentDestination?.hierarchy?.any{it.hasRoute(SearchRoute::class)}==true,onClick={navController.navigate(SearchRoute)},icon={Icon(Icons.Default.Search,contentDescription="搜索")},label={Text("搜索")})}){innerPadding->NavHost(navController=navController,startDestination=HomeRoute,modifier=Modifier.padding(innerPadding)){// ...}}}窗口变窄时,它自动用底部导航栏;变宽时,自动用导航栏。你不需要写任何when判断。
四、列表-详情布局:自适应的经典场景
“列表-详情”是自适应布局中最经典的模式:左边一个列表,右边显示选中项的详情。在手机上,它应该变成“列表 → 点击 → 详情页”;在平板上,它应该左右并排显示。
4.1 使用NavigableListDetailPaneScaffold
NavigableListDetailPaneScaffold把列表-详情布局和导航逻辑封装在一起:
@ComposablefunAdaptiveListDetail(){valnavigator=rememberListDetailPaneScaffoldNavigator<Long>()NavigableListDetailPaneScaffold(navigator=navigator,listPane={AnimatedPane{ArticleList(onArticleClick={id->navigator.navigateTo(ListDetailPaneScaffoldRole.Detail,id)})}},detailPane={AnimatedPane{valarticleId=navigator.currentDestination?.contentKeyif(articleId!=null){ArticleDetail(articleId)}else{Text("选择一篇文章查看详情")}}})}它的行为:
- 大窗口(Expanded):列表和详情并排显示。
- 小窗口(Compact/Medium):一次只显示一个窗格。点击列表项后,详情页滑入,返回键回到列表。
4.2 三个窗格的布局
ListDetailPaneScaffold还支持第三个可选的“额外窗格”(Extra pane),用于显示额外上下文。三个窗格的显示策略由库自动管理,你不需要手动判断什么时候显示哪个窗格。
五、折叠屏与姿态感知
5.1 折叠屏的两个独特信息
折叠屏给开发者带来了两个手机和平板上不存在的信息:
折叠边(Fold):屏幕可以弯折的物理区域。应用应该避免把重要内容放在折叠边上,而是把折叠边当作自然的分隔线。
设备姿态(Posture):设备当前怎么“摆”。比如“桌面模式”(Tabletop)——手机半折放在桌面上,上半屏显示内容,下半屏可以当支架或键盘。
5.2 使用collectFoldingFeaturesAsState
Compose提供了collectFoldingFeaturesAsState()来观察折叠信息:
@ComposablefunFoldAwareVideoPlayer(){valfoldingFeaturesbycollectFoldingFeaturesAsState()valfoldFeature=foldingFeatures.firstOrNull()if(foldFeature?.isSeparating==true){// 折叠边把屏幕分成了两部分when(foldFeature.orientation){FoldingFeature.Orientation.HORIZONTAL->{// 水平折叠:上半屏视频,下半屏控制TabletopLayout()}FoldingFeature.Orientation.VERTICAL->{// 垂直折叠:左边视频,右边评论BookModeLayout()}}}else{// 没有折叠边分离,用普通布局FlatLayout()}}collectFoldingFeaturesAsState()返回一个Compose State,设备姿态变化时会自动触发重组,不需要手动管理生命周期。
5.3 FoldingFeature的关键属性
FoldingFeature对象包含以下关键信息:
- state:
FLAT(完全展开)或HALF_OPENED(半折叠)。 - orientation:
HORIZONTAL(水平折叠,像笔记本)或VERTICAL(垂直折叠,像书)。 - occlusionType:折叠边是否遮挡了屏幕内容(
NONE或FULL)。 - isSeparating:折叠边是否把窗口分成了两个独立的逻辑区域。
只有isSeparating == true时,折叠边才真正把屏幕分成了两部分,你的布局才需要做分屏处理。如果折叠边只是屏幕中间的一条线(比如某些设备的折痕),不应该改变布局。
六、自适应设计的最佳实践
6.1 用窗口大小类,不用物理屏幕尺寸
这是最重要的一条。用currentWindowAdaptiveInfo().windowSizeClass,不要用smallestScreenWidthDp或设备型号。
6.2 把尺寸判断集中在一处
窗口大小类的判断应该发生在应用或屏幕的顶层,然后把结果(比如“显示导航栏还是底部栏”)作为参数传递给子组件。子组件不应该自己去读窗口大小。
6.3 让子组件灵活可复用
一个列表项不应该假设“我永远在左边”或“我永远占满宽度”。它应该能在不同的容器里正常工作。如果一个组件只能放在特定位置、特定尺寸下才能用,它的复用性就很差。
6.4 支持所有方向,不要锁死屏幕
官方建议应用应该在竖屏和横屏模式下都能正常运行,而不限制屏幕方向。很多应用通过android:screenOrientation="portrait"锁死竖屏来逃避适配问题,但这在平板、折叠屏和ChromeOS上体验很差。
6.5 在预览中测试不同尺寸
用@Preview同时预览Compact、Medium、Expanded三种尺寸:
@Preview(name="Compact",widthDp=360,heightDp=640)@Preview(name="Medium",widthDp=700,heightDp=800)@Preview(name="Expanded",widthDp=1000,heightDp=800)@ComposablefunAdaptivePreview(){MyAppTheme{AdaptiveApp()}}6.6 不要在子组件里直接读窗口大小
// 不推荐:子组件自己去读窗口大小@ComposablefunArticleCard(){valwindowSizeClass=currentWindowAdaptiveInfo().windowSizeClassif(windowSizeClass.windowWidthSizeClass==Expanded){// 横向布局}}窗口大小判断应该在顶层,结果通过参数传递。
6.7 忘记处理折叠边遮挡
如果折叠边有occlusionType == FULL(真正遮住了屏幕),内容放在折叠边上会被挡住。用isSeparating判断,并把折叠边当作分隔线。
6.8 只测试手机尺寸
只在手机模拟器上测试,忽略了平板、折叠屏、ChromeOS。用Android Studio的Resizable模拟器或@Preview测试多种尺寸。
七、常见陷阱速查
| 陷阱 | 后果 | 解决方案 |
|---|---|---|
| 用设备类型判断 | 多窗口/折叠屏下失效 | 用WindowSizeClass |
| 子组件直接读窗口大小 | 耦合、难复用 | 顶层判断,参数传递 |
| 锁死屏幕方向 | 平板/折叠屏体验差 | 支持所有方向 |
| 忽略折叠边遮挡 | 内容被挡住 | 用isSeparating判断 |
| 只测手机尺寸 | 大屏问题未发现 | 用@Preview多尺寸测试 |
| 硬编码布局宽度 | 大屏留白/小屏溢出 | 用fillMaxWidth()+weight |
| 列表项固定宽度 | 大屏拉伸变形 | 用widthIn(max = ...)限制最大宽度 |
八、综合实战:一个自适应新闻应用
我们把这一课的知识串起来,做一个自适应新闻应用。
8.1 顶层布局切换
@ComposablefunNewsApp(){valwindowSizeClass=currentWindowAdaptiveInfo().windowSizeClasswhen(windowSizeClass.windowWidthSizeClass){WindowWidthSizeClass.Compact->{// 手机:底部导航 + 单窗格CompactNewsApp()}WindowWidthSizeClass.Medium->{// 小平板/折叠屏:导航栏 + 单窗格MediumNewsApp()}WindowWidthSizeClass.Expanded->{// 平板/桌面:导航栏 + 列表-详情并排ExpandedNewsApp()}}}8.2 紧凑布局(手机)
@ComposablefunCompactNewsApp(){valnavController=rememberNavController()Scaffold(bottomBar={NavigationBar{NavigationBarItem(selected=false,onClick={navController.navigate(NewsListRoute)},icon={Icon(Icons.Default.Home,contentDescription="首页")},label={Text("首页")})NavigationBarItem(selected=false,onClick={navController.navigate(SearchRoute)},icon={Icon(Icons.Default.Search,contentDescription="搜索")},label={Text("搜索")})}}){padding->NavHost(navController=navController,startDestination=NewsListRoute,modifier=Modifier.padding(padding)){composable<NewsListRoute>{NewsListScreen(onArticleClick={id->navController.navigate(ArticleDetailRoute(id))})}composable<ArticleDetailRoute>{backStackEntry->valroute=backStackEntry.toRoute<ArticleDetailRoute>()ArticleDetailScreen(articleId=route.articleId)}composable<SearchRoute>{SearchScreen()}}}}8.3 展开布局(平板/桌面)
@ComposablefunExpandedNewsApp(){valnavigator=rememberListDetailPaneScaffoldNavigator<Long>()NavigationSuiteScaffold(navigationSuiteItems={item(selected=true,onClick={},icon={Icon(Icons.Default.Home,contentDescription="首页")},label={Text("首页")})item(selected=false,onClick={},icon={Icon(Icons.Default.Search,contentDescription="搜索")},label={Text("搜索")})}){padding->NavigableListDetailPaneScaffold(navigator=navigator,listPane={AnimatedPane{NewsListScreen(onArticleClick={id->navigator.navigateTo(ListDetailPaneScaffoldRole.Detail,id)})}},detailPane={AnimatedPane{valarticleId=navigator.currentDestination?.contentKeyif(articleId!=null){ArticleDetailScreen(articleId=articleId)}else{Box(modifier=Modifier.fillMaxSize(),contentAlignment=Alignment.Center){Text("选择一篇文章查看详情",color=MaterialTheme.colorScheme.onSurfaceVariant)}}}},modifier=Modifier.padding(padding))}}8.4 折叠屏适配
@ComposablefunFoldableNewsApp(){valfoldingFeaturesbycollectFoldingFeaturesAsState()valfoldFeature=foldingFeatures.firstOrNull()if(foldFeature?.isSeparating==true&&foldFeature.orientation==FoldingFeature.Orientation.VERTICAL){// 垂直折叠:左右分屏Row(modifier=Modifier.fillMaxSize()){Box(modifier=Modifier.weight(1f)){NewsListScreen(onArticleClick={})}Box(modifier=Modifier.weight(1f)){ArticleDetailScreen(articleId=1)}}}else{// 普通布局NewsApp()}}8.5 预览测试
@Preview(name="手机竖屏",widthDp=360,heightDp=640)@Preview(name="手机横屏",widthDp=640,heightDp=360)@Preview(name="小平板",widthDp=700,heightDp=800)@Preview(name="大平板",widthDp=1000,heightDp=800)@Preview(name="桌面窗口",widthDp=1200,heightDp=900)@ComposablefunAdaptiveNewsPreview(){MyAppTheme{NewsApp()}}九、自适应的思维模型
最后,用一张“思维模型”来总结这一课:
第一层:为什么需要自适应。同一份代码要跑在手机、平板、折叠屏、桌面、ChromeOS上,每个设备的可用空间和姿态都不同。
第二层:用什么判断。用WindowSizeClass(Compact/Medium/Expanded),不用设备类型或物理屏幕尺寸。
第三层:怎么组织代码。顶层判断窗口大小,把结果作为参数传递给子组件。子组件只关心“我该显示什么”,不关心“为什么”。
第四层:用什么组件。NavigationSuiteScaffold做自适应导航,NavigableListDetailPaneScaffold做列表-详情布局,collectFoldingFeaturesAsState做折叠屏感知。
第五层:怎么测试。用@Preview同时预览多种尺寸、多种姿态。
贯穿始终的原则:看窗口,不看设备。窗口给了多少空间,就用什么布局。设备是什么型号,不关你的事。
十、小结与下一课预告
这一课我们搞定了Compose的自适应布局。关键点回顾:
- 窗口大小类(WindowSizeClass):Compact、Medium、Expanded三个断点,根据窗口可用空间而不是设备类型判断。
currentWindowAdaptiveInfo():获取当前窗口大小类的Compose函数,窗口变化时自动更新。- Material 3 Adaptive库:提供
NavigationSuiteScaffold(自适应导航)、NavigableListDetailPaneScaffold(列表-详情布局)等组件。 - 折叠屏感知:用
collectFoldingFeaturesAsState()观察折叠边和姿态,避免内容被折叠边遮挡,支持桌面模式等姿态。 - 最佳实践:尺寸判断集中在一处、子组件灵活可复用、支持所有方向、在预览中测试多种尺寸。
- 常见陷阱:用设备类型判断、子组件直接读窗口大小、忽略折叠边遮挡、锁死屏幕方向。
- 综合实战:自适应新闻应用,涵盖顶层切换、紧凑布局、展开布局、折叠屏适配、多尺寸预览。
自适应不是“额外工作”,而是“基本要求”。用户不会因为你只做了手机布局就原谅你在平板上的糟糕体验。看窗口,不看设备——这是自适应布局的第一性原理。
下一课,我们会讲Compose的可访问性(Accessibility)。一个真正专业的应用,不仅要好看、好用,还要让所有人都能用——包括使用TalkBack屏幕阅读器的视障用户、使用开关控制或语音控制的运动障碍用户。Compose提供了语义树、内容描述、角色标记、自定义操作等能力,让你的应用对每个人都是友好的。这是从“会写Compose”到“写出有责任感的Compose”的关键一课。
课后练习建议:找一个你之前写的Compose页面,用@Preview在三种尺寸下预览,看看哪些地方在大屏上“不得体”。然后用这一课的方法改造它——顶层判断窗口大小,用NavigationSuiteScaffold替换固定导航,用NavigableListDetailPaneScaffold实现列表-详情。改完之后,你会惊讶于同一份代码在不同设备上的表现差异。