ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 16自适应架构:从窗口到状态的三刀重构

Android 16自适应架构:从窗口到状态的三刀重构 如果只看一场2025年的Android技术分享我会推荐Google DevFest上郭霖讲Android 16的那一场。话题压在“自适应”三个字上一开始我也有点不以为然觉得又是老调重弹讲大屏适配、讲折叠屏兼容。但听完才发现谷歌这次把自适应从“适配技巧”直接抬到了“应用架构”层面你的App能不能随窗口尺寸连续变化不再是一个加分项而是一个生存项。这篇文章就是我对这场分享的复盘和补充适合所有做Android UI开发的同学尤其是手上有老项目要上Google Play的团队。如果你还没听过这场分享我建议带着一个问题读下去自适应到底是在“适配屏幕”还是在“重建架构”。1. 为什么Android 16把“自适应”抬到核心位置1.1 屏幕尺寸已经不是“碎片化”而是“连续谱”以前我们做Android适配心里默认的设备模型是手机一档、平板一档、最多加个电视和手表。但现在再看设备形态你会发现在手机和平板之间折叠屏已经铺满了一条完整的连线。同一台折叠屏设备折叠起来是6寸手机展开是8寸小平板再连上桌面底座又能变成一台带鼠标键盘的“电脑”。这意味着什么意味着你的应用会在同一个用户手里被塞进从320dp到840dp以上的任意宽度窗口里。Google官方对屏幕尺寸的分类已经从“手机/平板”这种粗粒度变成了连续的宽度区间。这不是碎片化因为碎片化的意思是“几个离散的屏幕规格”而折叠屏、自由窗口、分屏、桌面模式带来的是“用户可以随时拖动窗口边框实时改变你的应用宽度”。任何“为某一个宽度写死布局”的做法在这条连续谱面前都是来不及反应的。这也是Android 16把自适应提上重中之重的根本原因不是谷歌想为难开发者而是设备形态已经把这条路堵死了。1.2 Google Play的强制策略正在压缩旧打法的生存空间以前做Android适配最省事的方案无非三种锁竖屏、固定宽度dp、能滑动就滑动。这套打法在单一尺寸的手机上确实好用但Android 16这一代政策层面开始动真格了。Google Play明确要求新上架和更新的应用必须支持resizable也就是应用要能调整大小并且要适配大屏幕与多窗口场景。说得直白一点如果你还是一个不支持分屏、一到大屏就把自己拉伸得没法看的App商店审核这一关就很难过去。郭霖在现场把Google官方文档里相关的强制项逐一过了一遍核心结论非常直接不处理自适应你可能连上架都困难。这里我要插一句个人判断政策只是推手真正的压力来自设备形态本身。用户手里就是那台折叠屏他展开之后看到你的应用左右两边全是空白他不会骂谷歌他只会去应用商店给你打一星。所以对开发者来说自适应已经不是“要不要做”的问题而是“怎么做得不痛苦”的问题。1.3 郭霖的“庖丁解牛”框架窗口、布局、状态三刀整场分享我一直记得一个点郭霖用“庖丁解牛”这个词把自适应切成三刀。第一刀切“窗口”搞清楚应用当前活在多大的画布里第二刀切“布局”让内容随着画布尺寸自己生长第三刀切“状态”保证窗口变化时数据和界面状态不能丢。对应到技术上分别是Window Info、Layout Strategy和State Retention。这个框架我后来自己在项目里用效果比我以前一上来就打开XML改布局要好太多了。以前我做适配是先纠结按钮间距、列表列数改到一半发现整个信息架构都得换。用“三刀”的方式你会先回答三个问题我的应用最少能承受多窄的窗口我的信息在这个宽度下如何排列切换布局时我丢了什么想清楚这三个问题再动手写代码效率完全不一样。这篇复盘我也按这个顺序来展开。2. 自适应到底在自什么核心机制逐层拆解2.1 resizable四要素先让你的App“能被改变”要让系统认为你的App可以调整尺寸第一件事是检查AndroidManifest。在application或activity节点上需要声明android:resizableActivitytrue。同时要处理一个历史包袱maxAspectRatio。当年为了应对Android 14、15上大屏拉伸的视觉问题不少App在manifest里写了一行超大宽高比限制比如android:maxAspectRatio2.4。现在如果不删掉这类限制系统会认为你只想全屏运行即便声明了resizableActivity也可能触发奇怪的行为。郭霖现场演示了一个案例把限制删掉前后同一个Activity在自由窗口模式下的表现完全像两个应用。我建议按下面四项给所有页面过一遍清单application或activity上声明resizableActivitytrue删除maxAspectRatio、minAspectRatio这类比例限制删除不必要的screenOrientation锁定检查启动模式避免固定尺寸的启动方式比如fixed的task尺寸这里要特别提醒resizableActivity可以放在application级别对全App生效也可以下放到单个activity后面这种做法适合个别页面暂时不想参与多窗口的情况比如摄像头预览页、自定义键盘页。但长期来看所有页面都应该支持尺寸变化因为用户不会理解为什么你的设置页能分屏拍照页一进分屏就崩溃。2.2 用WindowSizeClass把连续窗口翻译成决策档位自适应不是让你针对每一档尺寸写一套布局那样永远写不完。Google给出的做法是用WindowSizeClass做“量化”把连续变化的窗口宽度分成三个稳定区间。宽度0-600dp是Compact600-840dp是Medium840dp以上是Expanded。注意这里说的是窗口宽度不是屏幕物理宽度分屏、自由窗口都会直接影响它。窗口宽度级别常见设备/场景布局倾向0-600dpCompact竖屏手机、分屏窄栏单栏堆叠600-840dpMedium折叠屏展开、平板竖屏双栏预览840dpExpanded平板横屏、桌面窗口多栏/导航并入内容实际项目中Medium和Expanded往往可以共用一套大屏布局Compact单独一套。你不需要为每个宽度写死只需要在这三档之间切换。郭霖在现场给了一个生动的类比窗口宽度像水温WindowSizeClass像是把水温和“冷/温/热”对应起来你不需要测量到每一度只要感知到是哪个档位就知道该穿什么衣服。这个冷热感知就来自官方提供的WindowSizeClass API。2.3 配置变更不再销毁重建状态保留的底层变化尺寸变化时系统默认会给应用一次重新创建的机会。这其实是最不用开发者操心的部分你只要把状态放在ViewModel或者rememberSaveable里面新窗口创建之后自己恢复出来。但老项目最常见的坑是把状态随手塞进静态变量或者单例里一改尺寸界面还在数据没了。Android 16这代对SavedStateHandle也做了强化让ViewModel在配置变更中能更稳定地恢复复杂数据包括列表、Parcelable对象等不再需要开发者自己写一堆Bundle序列化的样板代码。郭霖讲这一部分时反复强调一个观点状态保留不是写一个空的onSaveInstanceState方法就算完事而是要让关键状态真的能“跨窗口活着”。什么叫关键状态用户正在看哪一篇文章、输到一半的搜索关键词、列表滚动到第几个位置。这些状态在尺寸切换前后必须持续存在而不是等用户重新操作一遍。判断你的界面能不能称得上“自适应”有一个最简单的标准把窗口从小拖到大再拖回来用户刚才的浏览位置还在不在。2.4 从“套模板”到“动态组合”UI组件开始自决策如果你是Compose用户Android 16时代有一个明显的变化自适应组件开始帮你做“布局决策”。比如Material3 Adaptive里提供的ListDetailPaneScaffold可以根据当前宽度自动决定是左侧列表加右侧详情还是列表和详情各占一整屏。NavigationSuiteScaffold能在窗口宽度足够的时候把底部导航自动切换成侧边导航。这类组件的价值在于它把“根据宽度切换布局”这个决策封装进了组件内部你只需要声明“我有列表和详情两种内容”剩下的摆放方式交给组件判断。对还在用View体系的项目也有对应的方案比如用ConstraintLayout配合VisualState或者干脆手动在代码里根据WindowSizeClass切换两套XML布局。区别是后者的维护成本更高需要你自己保证两套布局的组件状态一致。所以如果你正在做新模块我建议优先考虑Compose这套自适应组件老模块则可以用“渐进式迁移”的方式先把最影响体验的首页和列表详情页迁过去不需要一次性把整个App重写。3. 手把手实操把App改造成自适应架构3.1 第一步清理Manifest别让系统不让你改尺寸知道原理之后最该问的就是我手上的老项目怎么改下面我按步骤写一个迁移路径不追求一步到位但每一步都能落地。第一步把manifest里的限制清理干净。打开AndroidManifest.xml全局搜索screenOrientation、maxAspectRatio、resizeableActivity注意旧拼写历史版本有个拼写错误现在写resizableActivity才识别逐个检查这些属性。一个比较干净的声明长这样application android:labelstring/app_name android:supportsRtltrue android:resizableActivitytrue ... activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application注意resizableActivity既可以放在application级也可以放在单个activity级。前者是全局生效适合大多数项目后者适合个别页面暂时不希望被拖拽调整尺寸比如AR取景页。但我的建议是能全局开就全局开。因为一旦你允许用户在自由窗口模式里把某个页面拉得很窄他会期望其他页面也支持而不是遇到一个突然崩溃的页面。3.2 第二步用WindowSizeClass制定三档决策代码在Compose里获取窗口尺寸等级常用的是Material3 Adaptive组件库提供的LocalWindowSizeClass底层依赖androidx.window库。拿到等级之后再决定整个页面的骨架OptIn(ExperimentalMaterial3AdaptiveApi::class) Composable fun HomeScreen() { val sizeClass LocalWindowSizeClass.current.windowWidthSizeClass when (sizeClass) { WindowSizeClass.Compact - CompactHome() WindowSizeClass.Medium - MediumHome() WindowSizeClass.Expanded - ExpandedHome() } }这里有一个很容易踩的误区三个档位不是三套完全不同的界面。更合理的做法是把数据流放到外层列表、详情、操作栏这些区块各自封装在Compact下是列表页在Medium下变成列表详情双栏在Expanded下再并排加一个操作栏或元信息面板。也就是说你要做的是把“信息架构”摆成不同姿势而不是复制三份页面。同一个ViewModel、同一份数据源到三种骨架里重新“排座位”这才叫自适应而不是“多写两个Activity”。3.3 第三步替换写死布局用自适应组件“以换代填”接下来是组件层的替换。很多老界面不好改不是因为逻辑复杂而是因为用错了容器。我整理了一份替换清单基本可以覆盖绝大多数情况。旧写法自适应替代方案适用场景LazyColumnLazyVerticalGrid / LazyVerticalStaggeredGrid宽屏下需要多列展示卡片固定dp宽度的ColumnBoxWithConstraints 宽度分支内容在窄屏收窄、宽屏展开Row WeightFlowRow标签、按钮组自动换行BottomNavigationNavigationSuiteScaffold宽窗口下自动变成侧边导航手写详情双栏ListDetailPaneScaffold列表到详情的主次关系View体系下也能做类似的事情核心是用ConstraintLayout的VisualState按宽度切换约束集合但代码量和维护成本明显更高。如果你正好在维护一个大项目我的建议是新写的页面一律用Compose自适应组件老的View页面只做“最小改动”把影响最大的两三个页面先迁过来其他页面随后跟进。一次性全量重写风险太大而且很容易在某一步把状态弄丢。3.4 第四步把状态保存责任交给框架状态保存是自适应改造里最“隐形”但最容易翻车的一环。在Compose里UI字段状态用rememberSaveable数据层状态用ViewModel两件套基本能覆盖绝大多数场景。Android 16这代对SavedStateHandle的增强在于它可以更稳定地保存复杂类型列表数据的恢复不再需要你自己拼接Bundle。Composable fun ArticleScreen(viewModel: ArticleViewModel viewModel()) { var selectedArticleId by rememberSaveable { mutableStateOfString?(null) } val articles by viewModel.articles.collectAsState() // 窗口尺寸切换时rememberSaveable保留用户选中的文章ID // ViewModel保留文章列表布局重组后自动恢复。 }这里要特别提醒rememberSaveable只能保存能进Bundle的轻量数据如果你把整个列表对象塞进去轻则序列化开销大重则直接崩溃。正确做法是列表数据放ViewModel或Room里UI里只保存一个ID、一个索引、一个滚动位置。滚动位置在Lazy列表里甚至不用自己操心rememberLazyListState在配置变更后是自动恢复的。你只需要保证自己管理的那部分状态足够“轻”。3.5 第五步用“连续尺寸”把App拖一遍改完之后怎么验证我推荐至少三个方式。第一开启开发者选项里的自由尺寸模式把App塞进一个可以手动拖动的窗口然后慢慢拉宽度观察从最窄到最宽有没有白屏、错位、或者“闪一下就跳布局”的现象。第二在Android Studio里可以用带“Resizable”模板的模拟器它能模拟常见设备的连续尺寸切换比真机更容易控制变量。第三真机上开分屏配合折叠屏展开合拢做一轮回归。有一个便宜好用的测试技巧连续拖动窗口宽度每停留一档就点击一个关键按钮确认功能可用。这样能同时覆盖“布局是否正确切换”和“生命周期操作是否正常”。测试时要把系统字体大小调到1.3倍甚至2倍再跑一遍因为字体缩放会影响组件占用的宽度dp值很多自适应布局在默认字体下没问题字体一大就开始挤成一团。这个细节郭霖也专门提过属于常规项目里最容易忽略的盲区。4. 实战中高频踩坑与排查技巧4.1 dp与px、密度与宽度自适应的第一个暗礁代码里写死px、或者用resources.displayMetrics.widthPixels直接做逻辑分支是自适应改造里最先炸的雷。窗口宽度是逻辑dp不是物理像素。同一台设备的真实屏幕物理宽度没变但分屏后logical width会变小这时候你要是拿像素去判断结果就是错误的。正确的做法是拿dp和WindowSizeClass做判断像素只用于最终绘制层。另外很多老项目会维护一套dimens.xml命名里带着sw360dp、sw600dp这本身没问题但如果某个页面混用了两套dimen族就会出现“这个变量在手机上取的值和平板上冲突”的情况。排查这类问题没有捷径只能全局搜索把同一个控件引用的多个dimen来源统一起来。我的建议是新代码不要依赖swNdp这类dimens直接用WindowSizeClass在代码层面控制分支比资源目录更直观也更容易测试。4.2 键盘弹起、imePadding与可用高度变化键盘弹起会改变可用窗口这是个老问题但在自适应场景下会被放大因为自由窗口模式下adjustResize经常不生效。如果你的界面上有输入框切分屏、切自由窗口、或者键盘弹起时内容底部可能会被键盘遮住而由于窗口本身还在变化你很难用传统的onSizeChanged去统一处理。安全的做法是给内容区加上imePadding()让Compose根据输入法可见性自动调整padding而不是依赖系统在window级帮你调整。我见过不少项目因为键盘避让问题被迫把整个页面包进ScrollView这其实是饮鸩止渴。如果你用的是View体系也要优先检查windowSoftInputModeadjustResize是否在目标设备的分屏模式下真的生效不生效就自己处理padding。4.3 焦点、onResume与状态恢复的“幽灵问题”分屏和自由窗口会给App带来一种以前很少遇到的时序问题焦点变化和生命周期回调频繁触发。用户点一下分屏里的另一个App你的App就进onStop再点回来又走onStart、onResume。如果你把数据加载逻辑放在onResume里就会反复触发刷新如果你在onSaveInstanceState里做了清理又可能还没到真正销毁就把状态清掉了。排查这类问题最快的方法是在关键回调里打日志把尺寸变化、焦点变化、生命周期变化的时间轴拉出来看看有没有“尺寸变了两次但状态只恢复了一次”的错位。更稳妥的做法是把状态恢复职责完全交给ViewModel和rememberSaveable不要在Activity回调里手工维护“当前是哪个状态”。框架能做的事你越少自己掺和越不容易出错。4.4 自适应测试矩阵搭一张表把尺寸空间圈起来测试自适应最怕的是靠肉眼“看了几台设备就完事”。我建议每个项目建一个测试矩阵至少覆盖下面这些维度。测试项建议覆盖范围窗口宽度360dp、600dp、840dp、1200dp方向竖屏、横屏各一轮窗口模式全屏、分屏1:1、自由窗口窄条字体缩放默认、1.3倍、2倍语言方向LTR与RTL各一轮为什么一定要测RTL因为自适应布局大量使用start/end而不是left/right在从右到左的语言环境下列表和详情的主次关系会镜像翻转。很多在LTR下看起来完美的布局一改成RTL间距、位置、组件顺序全乱。如果你的应用暂时不需要RTL也要在布局里坚持使用start/end对齐否则未来接出海版本的代价会成倍增加。5. 这套“三刀”思路不止适用于Android 165.1 连续窗口是通用规律不是平台特性把郭霖那天的分享消化完你会发现“窗口、布局、状态”这三刀其实是通用的界面架构方法论。iOS有Size ClassesFlutter的布局系统天然支持宽度自适配Compose Multiplatform现在也把同一套自适应组件带到了桌面端Web前端更是早就用断点和流式布局处理这类问题。真正的规律是当显示区域可以连续变化时你应该先感知区域窗口再决策骨架布局最后保住现场状态这个顺序是固定的。所以我在做完Android 16的自适应改造之后最大的收获反而不是学了多少新API而是被训练出了一种习惯写任何一个界面之前先问窗口可能有哪些宽度而不是问设计稿里的宽度是多少。这个习惯带到Flutter、带到SwiftUI、带到鸿蒙应用开发里同样成立。平台会变API会换但连续窗口下的自适应架构逻辑不会变。5.2 把“庖丁解牛”用在你的项目排期里如果你不想让这次自适应改造变成一个大工程我建议按这个优先级排期第一优先级是高曝光页面比如首页、详情页、个人中心第二优先级是流程关键页比如登录、支付、设置第三优先级是低频长尾页。每个页面内部也按“窗口感知→布局决策→状态保留”的顺序做。先跑通一个完整流程再批量复制会比一口吃成胖子稳妥很多。我在自己项目里还有一个经验不要单独建一个“自适应改造分支”闷头干几个星期最好是每个迭代都带三五个页面进去让测试和产品同事持续在真机上感受变化。这样反馈是连续的问题在早期就会被发现而不是最后合并时一起爆发。这篇文章写到这里内容已经覆盖了郭霖那场分享的核心骨架也补了一部分我实际改造项目时踩过的坑。最后再分享一点个人体会以前我做“屏幕适配”是从设计稿里挑一个手机尺寸然后把px换算成dp让界面在小屏上凑合能看。做Android 16的自适应更像是在造一张高度可调的桌子桌面会变大变小、桌腿会伸长缩短、位置会来回挪但结构要让每个组件在任何宽度下都从容。郭霖在DevFest上的分享给我最大的启发是当你把“自适应”当成一种设计哲学而不是一项技术难点时很多决策反而简单了。从今天起新写的界面一律以WindowSizeClass为准不要为一个屏宽写布局老界面按文章第三节的清单逐个迁移。一次迁移几个页面半年后回头看你会感谢现在动手的自己。
返回列表