深入 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.safeDrawingWindowInsets.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 才生效的颜色设置了。