☰
【Jetpack Compose娓娓道来 】第19课:自适应布局与多设备适配——一套代码,处处得体
2026/9/26 4:05:51 网站建设 项目流程

一、先讲一个真实的尴尬

你花了两周做了一个漂亮的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实现列表-详情。改完之后,你会惊讶于同一份代码在不同设备上的表现差异。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询