上周末的Google DevFest 2025,郭霖在主会场那场《庖丁解牛:Android 16自适应的秘密》分享,现场几乎坐满了人。很多开发者是冲着“自适应”这三个字来的,毕竟Android 16这代系统把自适应和大屏适配提到了前所未有的高度,而郭霖又是国内少有的能把复杂系统机制讲明白的人。整场听下来,我最大的感受是:自适应这件事,以前我们当“加分题”做,现在真得当成“必答题”来对待了。
这篇博客我不会复述郭霖的PPT,而是结合他现场分享的核心思路、加上我自己在项目里实践Android 16自适应的经验,把“为什么现在要重视自适应”“到底怎么落地”“会踩哪些坑”这三块内容一次性讲清楚。如果你正准备适配Android 16,或者手里的应用已经收到targetSdk 36的升级要求,这篇内容应该能帮你少走不少弯路。
1. 开场:DevFest上的Android 16自适应
1.1 郭霖这次分享了什么
郭霖的分享主线非常清晰:从Android 16系统对开发者的强制要求出发,拆解“自适应”不只是UI缩放,而是一整套从布局策略、窗口管理、输入法适配到多设备形态兼容的工程体系。
他在现场反复强调一个观点:Android 16之后,自适应不再是“适配一下平板和折叠屏”这种选择性工作,而是所有应用默认必须具备的基础能力。系统在安装、运行、恢复、分屏各个阶段都会对应用的自适应能力做实际检验,目标SDK版本不达标、窗口尺寸变化处理不到位的应用,会直接在用户体验层面被拉开差距。
这背后的推动力并不难理解。Android设备形态已经高度碎片化,从横折、竖折、大屏折叠到桌面模式的窗口自由缩放,应用如果还抱着“手机竖屏黄金比例”的思维不放,在16:9时代写死的布局,到了大屏上就会出现大块空白、内容拉伸、点击热区错位这些问题。郭霖用“庖丁解牛”这个词,实际上是在告诉开发者:不要对着一个屏幕适配,而是要把屏幕抽象成“窗口尺寸”和“可用显示区域”这两个变量,只要把这两个变量处理好了,应用在什么形态的设备上都能游刃有余。
1.2 为什么“自适应”突然成了Android 16的头号话题
很多开发者会问:Android自适应不是早就有了吗?尺寸限定符、sw600dp、可折叠窗口这些概念也不是一天两天了,为什么Android 16要单独把它拎出来讲?
关键在于“强制范围”变了。Android 16 对targetSdkVersion提出了更高要求,同时系统对resizeableActivity相关行为的处理也发生了变化。过去的很多“自适应”是应用自己选择要不要适配,系统不强制;而Android 16里,系统默认所有应用都要能响应窗口尺寸变化。如果你的应用不允许调整尺寸、不处理onConfigurationChanged,那么在自由窗口、分屏、折叠屏展开场景下,体验就是断崖式的。
另一个重要变化是边到边(edge-to-edge)成为Android 16的强制视觉规范。这就很要命了。以前很多应用的做法是把内容整体下移,避开状态栏和导航栏,或者说直接在布局里写死一个paddingTop。但Android 16要求应用内容绘制到系统栏后面,再由开发者通过WindowInsets来动态处理避让。这套逻辑如果没理顺,自适应就只做了表面功夫,真正显示到折叠屏、横屏、桌面窗口时,问题会全部暴露出来。
郭霖现场给了个比喻:过去我们做适配,像在给不同身材的人分别裁衣服;Android 16希望我们做的是“一件有弹性的衣服”,面料和剪裁方式统一,但能根据穿着者自动贴合。这个比喻很形象,后面所有技术细节,本质上都在围绕“弹性的衣服”这个目标展开。
2. 自适应的底层逻辑与设计思路拆解
2.1 从手机到大屏:Android要解决的真问题
在做Android 16适配之前,我先把手里几个应用在不同设备上的截图铺开对比了一下。得出的结论非常扎心:同一个界面放在手机、折叠屏内屏、横屏平板上,问题各不相同,但根子几乎都是同一个——布局被“定死”了。
常见问题包括:RecyclerView的item宽度写死、两栏布局只在小屏上启用、字体大小用sp固定、底部操作栏在不同窗口下遮住内容、Dialog宽度绑定到屏幕宽度而不是窗口宽度。这些问题的本质是没有区分“窗口”(Window)和“屏幕”(Screen)。
在Android 16的概念里,应用看到的世界不是整个物理屏幕,而是系统分配给它的窗口。普通手机竖屏下,窗口几乎等于屏幕;但到了分屏、折叠屏展开、桌面窗口模式下,窗口和屏幕就完全是两回事了。自适应的第一性原理,就是让你的应用在所有可能的窗口尺寸下都能正常工作,而不是只在“默认手机尺寸”下正常工作。
郭霖在分享里专门提到,Google现在推荐的切入点是“窗口尺寸类”(Window Size Class)。它不是精确的像素值,而是一个离散化的断点标准:宽度小于600dp视为Compact(紧凑),600dp到840dp视为Medium(中等),大于840dp视为Expanded(展开)。为什么用dp而不是像素?因为在不同密度的设备上,像素值没有可比性,dp才是用户在物理空间里感知到的尺寸。而且窗口尺寸类是在运行时动态变化的,折叠屏展开的一瞬间,宽度会从Compact跳到Expanded,应用必须处理这种实时变化,而不是只在启动时读取一次屏幕宽度。
2.2 Window Size Class:最小的适配单元
Window Size Class这个概念,我最早接触是Material Design 3的响应式布局指南,但在Android 16里,它被提升到了“系统级适配单元”的位置。原因也很简单:Android碎片化设备太多了,罗列具体型号做适配根本不现实,但把尺寸归类成三档,再针对每档设计布局,工作量就可控了。
简单说,Window Size Class就是把你应用当前可用宽度映射成三个枚举值:
- Compact:参考手机竖屏,通常宽度在600dp以下;
- Medium:参考手机横屏或小尺寸可折叠设备展开态,宽度在600dp到840dp之间;
- Expanded:参考平板、大折叠屏展开态、桌面窗口,宽度在840dp以上。
郭霖现场建议每个应用都先做一个“三层布局骨架”:Compact层用单列列表,Medium层用列表加细节面板的双栏,Expanded层用导航栏加内容的完整三栏。这样不是让你写三套完全独立的布局,而是让布局结构随窗口尺寸类变化,内容模块是复用的。
具体到代码层面,最直接的方式是用AndroidX的windowManager库。在build.gradle里加上依赖后,可以用下面这种方式获取当前的窗口尺寸类:
implementation("androidx.window:window:1.3.0")import androidx.window.core.layout.WindowSizeClass import androidx.window.layout.WindowMetricsCalculator class MyActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val metrics = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) val width = metrics.bounds.width().toFloat() / resources.displayMetrics.density val windowSizeClass = when { width < 600f -> WindowSizeClass.COMPACT width < 840f -> WindowSizeClass.MEDIUM else -> WindowSizeClass.EXPANDED } setContentView(R.layout.activity_main) applyWindowSizeClass(windowSizeClass) } }需要注意的是,这种手动计算只能在界面初始化时拿到当前值。Android 16强调的“动态响应”,要求的是当窗口尺寸变化时布局自动跟着变。所以更推荐的方式是用rememberWindowWidthSizeClass(如果是Compose),或者在View体系里通过addOnConfigurationChangedListener监听尺寸变化。
2.3 可折叠与多窗口:自适应的终极场景
如果说Window Size Class是自适应的地基,那么可折叠设备和多窗口模式就是出考题最狠的考场。郭霖在分享里专门拿折叠屏展开来做演示:手机状态时,应用是一个竖长的单列;展开到内屏时,窗口宽度瞬间从500多dp跳到800多dp。这时候如果你只在onCreate里根据初始宽高选一次布局,展开后应用就会出现左右大片空白,或者列表被拉得非常宽,视觉上极其不协调。
真正的做法是响应窗口变化事件,在尺寸类改变时重新布局。Android 16系统对折叠屏的展开动画支持也比之前版本更强,窗口尺寸是平滑过渡的,应用如果能实时监听并调整布局,整个体验会非常跟手。这也意味着你的布局不能是启动时“一锤定音”的,而要具备“热切换”能力。
多窗口模式下,问题更复杂。比如用户把窗口从竖屏四分之一大小拖到全屏,这个过程中窗口宽度是连续变化的。如果应用layout逻辑写得好,这个过程应该没有任何闪烁和跳动;如果写不好,就会出现列表重新加载、状态丢失、甚至闪退。
郭霖现场给了一个非常实用的建议:把自适应的判断逻辑收拢到单独的函数里,不要散落在Activity的各个回调中。比如定义一个applySizeClass(size: WindowSizeClass)函数,onCreate、onConfigurationChanged都会调用它。这样不管窗口怎么变,布局策略永远只有一处逻辑来源,排查问题会轻松很多。
3. 实操:在项目里落地Android 16自适应
3.1 资源限定符与布局重组的配合
很多从老Android时代过来的开发者,最熟悉的自适应方案就是资源限定符,res/layout-w600dp、res/layout-sw600dp这样一套。这个方案在Android 16仍然有效,但郭霖提醒了一个关键点:资源限定符的切换是“Activity重建”级别的,窗口宽度跨越断点后,系统会销毁当前Activity并重新创建。如果应用状态没有保存好,用户会明显感知到页面刷新,在折叠屏展开这个高频场景里,体验很糟糕。
我自己的实践结论是:资源限定符适合做“会话开始时”的首选布局,比如Phone和Tablet两套差异很大的结构,适合用layout和layout-sw600dp分别定义。但运行中的尺寸变化,尤其是折叠屏展开、收起、自由窗口拖拽这类场景,尽量用代码逻辑来做增量调整。
举个例子。一个典型的列表详情双栏结构,手机上列表整页显示,点击进去是详情;平板上左侧列表、右侧详情。这种结构差异大,适合用资限定符定义两套布局。但实际在折叠屏展开过程中,从单栏变双栏,如果Activity重建,用户正在读的文章位置可能就丢了。所以更稳健的方案是,两套资源可以保留,但要把Activity的状态保存机制做好。
郭霖在分享里特别提到,Android 16对Activity重建过程中的状态恢复也有改进,但前提是开发者要正确使用ViewModel和rememberSaveable。如果你还在用静态变量存状态,那换了什么系统都救不了你。
3.2 用Adaptive Layout库实现响应式布局
Google为了降低自适应门槛,推出了androidx.adaptive布局库,里面包含了ListDetailPaneLayout等布局容器,可以很方便地实现“同一个布局在不同尺寸下自动调整显示方式”的效果。
这个库的思路是:你把内容分成几个面板(Pane),声明它们的匹配策略,库内部会根据窗口尺寸自动决定是显示单栏、双栏还是折叠导航。对大多数内容型应用来说,比手写两套布局再手动切换要省事得多。
依赖如下:
implementation("androidx.adaptive:adaptive:1.0.0") implementation("androidx.adaptive:adaptive-layout:1.0.0")在View体系里,ListDetailPaneLayout的使用大概是这样的逻辑:它包含两个Pane,List和Detail,库会根据当前窗口尺寸类的判定结果,自动选择“只显示List”“并排显示List和Detail”或“显示Detail并带返回箭头”。你不用自己写展开收起动画,库里面都处理好了。
这个库我用下来,最大的优势是省心,尤其是折叠屏适配,它在宽度从Compact转到Expanded时会自动把“单栏”切换成“双栏”,并且有内置的过渡动画。当然它的灵活性不如完全手工控制,如果你的界面是强定制风格,可能还是得自己写响应式逻辑。但作为兜底方案,或者作为大多数CRUD类应用的通用方案,完全够用。
3.3 边到边视觉强化与安全区域
Android 16在视觉上最强制的一件事就是边到边。系统栏(状态栏和导航栏)变成半透明或全透明,应用内容要延伸到系统栏后面去。这让应用看起来更沉浸,但也给内容排版带来了新的麻烦:内容顶部可能被状态栏挡住,底部可能被导航栏或者手势条挡住。
郭霖在分享里把这块讲得很透。他说边到边的核心不是“内容不要被遮挡”,而是“内容要被正确避让”。Android提供的工具是WindowInsets,它告诉你安全区域距离每个边缘有多少像素。应用要做的,不是统一加一个安全的padding,而是在不同的窗口形态下,根据实际Insets动态调整内容的padding。
在我的实际项目中,用View系统时,可以监听系统栏变化来动态调整根布局的padding:
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.updatePadding( left = insets.left, top = insets.top, right = insets.right, bottom = insets.bottom ) // 返回消费后的insets,避免向上层重复传递 WindowInsetsCompat.CONSUMED }这里有几个容易踩的细节。第一,如果根布局上还有被要求延伸到边到边的背景,比如侧边抽屉、图片头图,那背景应该绘制到安全区域后,内容层再单独做避让。第二,键盘弹起时,键盘Insets也会随之变化,底部padding需要在显示键盘时调整为键盘高度,否则输入框会被挡住。第三,在三个不同窗口尺寸类之间切换时,Insets的值都会重新计算,所以监听逻辑一定要写在不会因为尺寸切换而失效的地方。
3.4 关键代码示例:一套布局适配手机和折叠屏
上面讲了理论,下面我给一个可以直接抄作业的示例片段。假设我们的应用主页是一个列表界面,用户点击条目后进入详情。我们要做到的效果是:手机竖屏时只显示列表,点条目跳转到详情页面;折叠屏展开或平板横屏时,列表和详情并排显示。
方案明确的思路是采用单Activity架构,配合ListDetailPaneLayout。核心步骤包括:
第一步,在布局文件中定义ListDetailPaneLayout,分别放置列表容器和详情容器:
<androidx.adaptive.layout.ListDetailPaneLayout android:id="@+id/listDetailLayout" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.fragment.app.FragmentContainerView android:id="@+id/listPane" android:layout_width="match_parent" android:layout_height="match_parent" /> <androidx.fragment.app.FragmentContainerView android:id="@+id/detailPane" android:layout_width="match_parent" android:layout_height="match_parent" /> </androidx.adaptive.layout.ListDetailPaneLayout>第二步,在Activity里初始化ListDetailPaneLayout,并设置内容:
class MainActivity : AppCompatActivity() { private lateinit var listDetailLayout: ListDetailPaneLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) listDetailLayout = findViewById(R.id.listDetailLayout) // 设置List内容 listDetailLayout.listPaneFragment = ListFragment() // 初始状态下如果没有详情选择,可以不设置Detail } }第三步,列表条目点击时,不要直接启动新的Activity,而是把详情Fragment填充到detailPane:
class ListFragment : Fragment() { private var currentId: String? = null fun onItemClick(itemId: String) { val activity = requireActivity() if (activity is MainActivity) { // 如果处于双栏模式,直接在详情面板显示 activity.showDetail(itemId) } } companion object { fun newInstance(): ListFragment = ListFragment() } }fun showDetail(itemId: String) { val detailFragment = DetailFragment.newInstance(itemId) supportFragmentManager.beginTransaction() .replace(R.id.detailPane, detailFragment) .commit() // 如果是单栏模式,ListDetailPaneLayout会自动让详情面板覆盖列表 listDetailLayout.setShowDetail(true) }如果是在单栏紧凑模式下,ListDetailPaneLayout会自动让详情面板全屏显示;当设备展开到双栏时,详情面板又会回到右侧,列表继续显示在左侧。整个过程中,你不需要手工检查屏幕方向或dp值,库内部会根据窗口尺寸判断。
4. 运行态适配与动态窗口调整
4.1 Configuration变化与onResize
Android 16之前,屏幕方向和配置变化导致的Activity重建是一个老生常谈的问题。很多团队为了省事,直接在AndroidManifest里给Activity加上了android:configChanges="orientation|screenSize",试图让系统不重建Activity。但在Android 16里,这种粗暴做法在新设备形态下并不稳妥,尤其是折叠屏展开,窗口尺寸类变化并不只对应orientation和screenSize两个配置位。
郭霖在分享里点明了一个趋势:Android系统正在从“配置变化即重建”走向“配置变化即更新”。在大屏多窗口场景下,用户随时可能拖拽窗口大小,如果每一次拖拽都要重建整个界面状态,体验是不可能好的。所以应用要主动响应配置或窗口尺寸变化,在Activity没有重建的情况下更新布局。
一个推荐的模式是,使用Activity的onConfigurationChanged回调来处理配置变化,包括dark mode、语言区域、屏幕方向等。同时,如果你的Activity声明了处理这些configChanges,系统就不会走重建流程。但需要注意,如果你声明处理了configChanges却又没有正确更新布局,反而会导致界面错乱。所以你要么完全交给系统重建,要么就自己把生命周期和界面更新管好。
折叠屏展开场景下最顺畅的体验方式是,在onConfigurationChanged里重新读取窗口尺寸类并触发重新布局:
override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val metrics = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) val widthDp = metrics.bounds.width().toFloat() / resources.displayMetrics.density val newSize = when { widthDp < 600f -> WindowSizeClass.COMPACT widthDp < 840f -> WindowSizeClass.MEDIUM else -> WindowSizeClass.EXPANDED } if (newSize != currentSizeClass) { currentSizeClass = newSize applySizeClass(newSize) } }4.2 状态栏、导航栏与WindowInsets适配
边到边模式下,状态栏和导航栏的处理是自适应里最细碎也最容易出问题的地方。系统栏高度、导航条模式、刘海屏缺口、三按钮导航和手势导航的差异,都会影响WindowInsets的具体值。
郭霖给了一个比较巧妙的经验总结:不要试图自己计算状态栏高度,不同机型的差异很大;直接用系统提供的WindowInsets类型,让系统告诉你需要避让多少。常见类型包括:
- systemBars():状态栏加导航栏的并集;
- statusBars():仅状态栏;
- navigationBars():仅导航栏;
- systemBarsIgnoringVisibility():不管栏是否可见都返回对应Insets;
- ime():输入法,键盘弹起时可用;
- displayCutout():刘海、打孔屏的安全区域。
在自适应布局里,我建议先获取systemBars的Insets作为基础安全区,有沉浸式需求时再针对statusBars和navigationBars单独调整。遇到键盘弹起要移动底部表单的场景,需要额外监听ime()类型,而且要注意设置android:windowSoftInputMode="adjustResize",键盘变化才会传递到View树里触发重新布局。
有一个细节可能很多开发者不知道:在Android 15/16上,三按钮导航模式下导航栏高度会比手势导航条高很多,如果你只做了一套padding,那必然有一端不合适。正确的做法是让系统bar的颜色跟随界面主题变化,但内容避让始终以Insets为准,而不是以固定值。
4.3 测试自适应:用Resizable Emulator与多尺寸检测
代码写完了,最头疼的还是怎么测。没有折叠屏真机,怎么验证展开收起的适配效果?郭霖在分享里推荐了Resizable Emulator,这确实是一个很香的方案。
Resizable Emulator是Android Studio自带的一种模拟器类型,你可以在虚拟设备里模拟手机、折叠屏展开、平板等形态,甚至可以在运行过程中动态切换设备形态。切换的瞬间,应用的窗口尺寸会实时变化,正好用来验证自适应布局是否正常工作。
我个人的测试习惯是,先把应用安装到Resizable Emulator上,然后在设置里把屏幕模式从“手机”切到“折叠屏展开”,再切到“平板”。每个切换都观察几个关键点:列表是否变成双栏、详情页是否保留当前浏览位置、工具栏是否有重叠、底部导航是否被手势条遮挡。有条件的话,再配合真机和平板做一轮回归,基本就能覆盖大部分问题。
这里提醒一句,模拟器和真机在键盘、折叠铰链相关表现上还是有些差异的,尤其涉及铰链遮蔽区、后置摄像头位置等因素,建议大版本上线前还是借几台主流设备实测一轮。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 折叠屏展开后界面还是手机窄条 | 布局没有根据Window Size Class切换 | 监听窗口尺寸变化,触发重新布局 |
| 内容被状态栏或导航栏遮挡 | 未做边到边Insets避让 | 根布局设置OnApplyWindowInsetsListener |
| 键盘弹起把输入框顶出屏幕 | 未处理ImeInsets | 动态更新底部padding为ime()高度 |
| 平板横屏时列表被拉得特别宽 | 没有使用约束布局或宽度限制 | 设置android:maxWidth或用Medium/Expanded布局 |
| 点击列表项跳转后返回丢位置 | Activity重建丢状态 | 使用ViewModel + rememberSaveable保存滚动位置 |
| 分屏拖拽时界面闪烁 | 频繁重建或布局计算量过大 | 合并配置变化,优化布局层级 |
这个表格里的场景都是我在实际适配过程中真实遇到的。最典型的是第一项,很多应用只处理了“启动时是折叠屏”的情况,没有处理“运行中从手机形态切换到展开形态”的情况。原因也很简单,大部分开发者手头没有折叠屏设备,测试时根本不可能发现这种问题。
5.2 独家避坑经验分享
第一,不要用Build.VERSION.SDK_INT做设备形态判断。我看到很多项目里的代码是“SDK 33以上就按大屏处理”,这完全搞错了。SDK版本和设备形态没有直接对应关系,Android 16可能跑在手机上,也可能跑在折叠屏上。你应该永远基于当前窗口的尺寸和限制信息去做决策。
第二,适配不要只考虑dp,还要考虑字体缩放。如果用户把系统字体调大,sp单位的文字实际渲染会变大,同样的dp容器可能就装不下了。自适应的布局一定要预留文字放大的空间,不要给TextView设置固定高度,尽量用包裹内容。
第三,列表和详情双栏模式下,不要自动同步选中数据。我最初做双栏,会在列表选中变化时立即刷新详情,结果用户发现列表滑到一半,详情一直在跳。后来改成点击时才刷新详情,或者增加一定延迟,体验就好了很多。
第四,自适应的不止是布局,还有逻辑。比如在Expanded宽度下,列表和详情同时可见,用户可能同时操作两栏;而在Compact宽度下,一次只有一个界面。如果业务逻辑对这个差异不敏感还好,一旦有全局刷新、轮询之类的操作,就要考虑双栏模式下是否要暂停或降低频率,避免资源浪费。
第五,也是郭霖现场反复强调的:测试一定要形成自动化习惯。自适应的回归场景多,光靠人工点,很容易漏掉某个断点组合。可以给项目加一个UI测试,模拟不同窗口尺寸并断言关键View可见性。比如:
@Test fun testWideLayoutShowsDetailPane() { val scenario = ActivityScenario.launch(MainActivity::class.java) scenario.onActivity { activity -> // 模拟宽窗口切换逻辑,或直接断言两个面板都显示 assertNotNull(activity.findViewById(R.id.listPane)) assertNotNull(activity.findViewById(R.id.detailPane)) } }虽然这种测试没法完全模拟折叠屏的实时过渡,但对于防止布局回归已经很有价值了。
结尾:我的一点个人体会
整场DevFest听完,又在项目里实际折腾了个把月,我对“Android 16自适应”最大的感受是:它本质上是一场从“面向屏幕开发”到“面向窗口开发”的思维转变。屏幕是硬件,窗口是系统分配给应用的软性空间;应用应该关心的是自己手里这块可变空间,而不是物理设备的碎屏尺寸。
郭霖分享里那句话我特别认同:自适应的最终目标,是让用户在不同设备上都能无感地使用同一个应用。它没有银弹,Window Size Class也好,ListDetailPaneLayout也好,都只是工具。真正的核心是你是否愿意把过去“定死”的布局习惯放下来,用一套动态的逻辑去应对各种窗口形态。
如果你正被Android 16的targetSdk升级逼得手忙脚乱,我的建议是:别急着改一堆像素值,先从Window Size Class入手把布局骨架搭起来,再处理边到边Insets,最后用Resizable Emulator做一轮形态切换自测。把这个流程走完,你会发现Android 16的自适应,其实没有传说中那么可怕。