深入 Android 16 强制 Edge-to-Edge:WindowInsets 分发机制重构与系统栏适配的全链路工程实践
升级 targetSdk 36 之后,测试同学反馈了一批截图:底部 TabBar 被导航栏遮住了一半,状态栏和通知图标叠在了 Toolbar 上。这不是个别 App 的问题——Android 16 Beta 3 把强制 edge-to-edge 列为破坏性变更,凡是 targetSdk ≥ 36 的应用,系统会直接忽略 Window.setStatusBarColor()、setNavigationBarColor() 的颜色设置,并强制让内容延伸到系统栏后面。
这篇文章不讲怎么”快速修复”,而是把 WindowInsets 的分发链路梳理清楚,再给出 View 体系和 Compose 体系各自的适配方案。
为什么 Google 要强制这件事
在 Android 10 引入 WindowCompat.setDecorFitsSystemWindows(window, false) 之前,应用默认运行在一个由系统裁剪过的安全区域里——内容不会出现在状态栏或导航栏下方,系统栏颜色由应用自行设置,通常是一片黑或白。代价是:在全面屏设备上,应用与系统栏之间有明显的割裂感,手势区域的交互也很生硬。
Android 16 的强制 edge-to-edge,本质上是把选择权从应用手里拿走。过去几个版本 Google 一直在”建议”开发者适配,但适配率不够理想,于是 targetSdk 36 直接变成了强制。根据官方描述,Window.setDecorFitsSystemWindows(false) 在 targetSdk 36 时自动生效,应用无法再通过任何 API 把内容”推回”到系统栏之上。
WindowInsets 的分发链路
系统通过 ViewRootImpl.dispatchApplyWindowInsets() 把 WindowInsets 对象注入 View 树的根节点,再沿着 View 层级逐级分发,每个 ViewGroup 调用 dispatchApplyWindowInsets(),由子 View 消费或转发。消费(consume)是单向的——一旦某个 View 调用了 insets.consumeSystemWindowInsets(),后续子 View 就收不到这部分 insets。
// View 的默认行为
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// 默认实现直接返回,不消费也不修改
return insets
}
WindowInsetsCompat 是 Jetpack 对 WindowInsets 的封装,提供向后兼容的 API,始终用它而不是平台原生类:
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insetsCompat ->
val systemBars = insetsCompat.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insetsCompat.getInsets(WindowInsetsCompat.Type.ime())
// systemBars 包含 statusBars + navigationBars + captionBar
v.updatePadding(
top = systemBars.top,
bottom = systemBars.bottom
)
insetsCompat // 不消费,继续分发给子 View
}
这里有个实际项目里很常见的坑:updatePadding 设置了 padding 之后,如果子 View 也在监听同一个 insets,就会出现双重 padding。正确做法是在 View 树中明确分层——顶层 View 处理 top insets,底部容器处理 bottom insets,中间不重复消费。
View 体系的适配
View 体系适配的核心是找到正确的 View 节点,在合适的位置应用 padding 或 margin,而不是在根布局统一加 padding 了事。
状态栏区域
Toolbar 或自定义顶部栏通常需要处理 statusBars insets:
ViewCompat.setOnApplyWindowInsetsListener(toolbar) { v, insets ->
val statusBar = insets.getInsets(WindowInsetsCompat.Type.statusBars())
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = statusBar.top
}
insets
}
用 margin 而不是 padding:Toolbar 通常有固定高度,改 padding 会把内容往下挤,改 margin 则是整体下移。
导航栏区域
底部导航或 FAB 的处理类似,但要区分手势导航和三段式导航:
ViewCompat.setOnApplyWindowInsetsListener(bottomNav) { v, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
v.updatePadding(bottom = navBar.bottom)
insets
}
手势导航模式下 navBar.bottom 通常是 tappable_element 的高度(约 20dp),三段式导航则是整个导航栏高度(约 48dp)。这个值绝对不能硬编码,必须从 insets 动态读取。
软键盘(IME)insets
在 targetSdk 36 下,软键盘弹起不再自动 resize Window,需要手动处理 ime insets。实际项目里这块最容易被漏掉:
ViewCompat.setOnApplyWindowInsetsListener(scrollView) { v, insets ->
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
// IME 出现时,bottom padding 取 ime 高度;收起时取导航栏高度
v.updatePadding(bottom = maxOf(imeInsets.bottom, navInsets.bottom))
insets
}
WindowInsetsAnimationCompat 可以做键盘动画同步,但那是另一个话题了。
Compose 体系的适配
Compose 在 1.5 之后引入了 WindowInsets 的原生支持,机制和 View 体系不同——Compose 的 insets 通过 CompositionLocal 传递,而不是通过 View 树分发。
Scaffold 已经内置了 insets 处理,如果架构是标准的 Scaffold + TopAppBar + BottomNavigationBar,大多数情况只需要确保参数正确:
Scaffold(
topBar = { TopAppBar(title = { Text("Title") }) },
bottomBar = { BottomNavigationBar() },
contentWindowInsets = WindowInsets.safeDrawing // 使用 safeDrawing 而不是 systemBars
) { paddingValues ->
// paddingValues 已经包含了系统栏的 insets
LazyColumn(
contentPadding = paddingValues
) { /* ... */ }
}
WindowInsets.safeDrawing 比 WindowInsets.systemBars 更全面,它包含了状态栏、导航栏和刘海区域(displayCutout)。在异形屏设备上,单独用 systemBars 会漏掉刘海区域,直接用 safeDrawing 一步到位。
手动消费 insets
Compose 提供了 consumeWindowInsets() modifier 来标记某个 Composable 已经消费了某类 insets,防止子级重复处理:
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars) // 应用 padding
.consumeWindowInsets(WindowInsets.statusBars) // 标记已消费
) {
// 子 Composable 不会再次收到 statusBars insets
}
不加 consumeWindowInsets 的后果:子 Composable 如果也调用了 windowInsetsPadding(WindowInsets.systemBars),会叠加一次 top padding,导致内容位置异常。这个 bug 在 Compose 混合层级里很难排查,因为视觉上看起来”只是多了点空白”。
View + Compose 混合场景
这才是最麻烦的情况。Compose 内容嵌在 ComposeView 里时,如果外层 View 已经消费了部分 insets,Compose 树拿到的 LocalWindowInsets 可能是被裁剪过的版本。
判断方法:在 Composable 里打 log 查看 WindowInsets.systemBars.asPaddingValues() 的值。如果 top 为 0 但实际有状态栏,说明外层 View 已经消费了 statusBars insets,Compose 层不需要再处理。反过来,外层 View 直接透传,Compose 内部就需要自己处理全量 insets。
迁移中的破坏性变更细节
除了强制 edge-to-edge,targetSdk 36 还有几个相关变更需要关注。
Window.setStatusBarColor() 和 setNavigationBarColor() 在 targetSdk 36 下被完全忽略,系统栏颜色改为动态适配(浅色模式白底深色图标,深色模式反之)。如果 App 有品牌色状态栏,这个视觉风格在 targetSdk 36 下只能放弃——或者用自定义 View 覆盖状态栏区域来模拟,代价是增加维护成本。
android:windowSoftInputMode="adjustResize" 在 edge-to-edge 模式下失效,需要改用 WindowInsetsAnimationCompat 或手动监听 ime insets。
Lint 规则 UnusedWindowInsets 很有用——它能检测出哪些 View 接收了 insets 但没有应用,辅助排查遗漏的适配点:
android {
lintOptions {
enable "UnusedWindowInsets"
}
}
实践建议
分层处理,明确消费边界。 View 树和 Compose 树都遵循同一原则:insets 在哪一层处理,就在哪一层消费,不要让同一个 insets 类型被多个节点重复应用。可以用 View.requestApplyInsets() 在 debug 包里验证 insets 值是否符合预期。
软键盘适配要单独测试。 IME insets 是最容易被 regression 的部分,建议在 CI 中加入 Espresso + WindowInsetsController 的自动化用例,覆盖键盘弹起/收起状态下的布局快照。踩过的坑是:每次动 ScrollView 或者 BottomSheet 的布局,几乎必然引入键盘遮挡问题,不做自动化覆盖很难在 code review 阶段发现。
Android 16 的这次强制变更短期内确实增加了适配工作量,但统一 insets 处理模型之后,UI 代码会更清晰——至少不用再维护那堆只有特定 API level 才生效的颜色设置了。