ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【Jetpack Compose娓娓道来 】第19课:自适应布局与多设备适配——一套代码,处处得体

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

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进