WindowInsets 的兼容处理,从 Edge-to-Edge 说起

WindowInsets 的兼容处理,从 Edge-to-Edge 说起

WindowInsets 的兼容处理,从 Edge-to-Edge 说起


「WindowInsets 的兼容处理,从 Edge-to-Edge 说起」


把项目 targetSdk 从 34 切到 35 之后,首页的底部导航栏直接沉到了屏幕最底下,被手势提示条盖住了大半。CoordinatorLayout 里的 FloatingActionButton 也没好到哪去,贴着屏幕底边,点按区域只剩一半。起初我以为是主题里的某个属性被 Android 15 忽略了,但查了半天发现,这次不是 bug,而是 Google 在 Android 15(API 35)上强制执行 Edge-to-Edge 之后,旧的那套 insets 适配逻辑集体失效了。更让人头疼的是,为了修这个问题,我把 WindowInsets 从 API 21 到 API 35 的文档翻了个遍,发现这玩意儿的历史包袱比想象中重得多。


fitsSystemWindows 为什么突然「失灵」了


很多老项目处理系统栏避让,第一步就是给根布局塞一个 android:fitsSystemWindows="true"。这个属性从 API 16 就存在,原理并不复杂:系统在分发 window insets 时,如果目标 View 的 fitsSystemWindows 为 true,就会自动把 insets 对应的 padding 打到这个 View 上。比如状态栏高 80px,就给这个 View 加 80px 的 top padding,内容自然往下挪。


但 fitsSystemWindows 本质上是一个「黑盒委托」。它把 insets 的处理权交给了父 View 的 dispatchApplyWindowInsets 实现,而不同容器和不同版本的实现差异巨大。Material 库里的 AppBarLayout、CollapsingToolbarLayout、DrawerLayout 都重写过这套逻辑,彼此之间还经常冲突。更关键的是,fitsSystemWindows 只认识传统的 System Window Insets,也就是大致对应今天的 WindowInsetsCompat.Type.systemBars()。它对键盘(IME)、手势区域(System Gestures)、刘海(Display Cutout)几乎是无感知的。


Android 15 强制 edge-to-edge 之后,系统默认允许你的内容绘制到状态栏和导航栏后方。这时候 fitsSystemWindows 的行为被进一步削弱:官方文档里明确提到,如果应用没有主动处理 insets,fitsSystemWindows 不会再像过去那样自动为你添加安全区域的 padding。因为系统假设你已经接受了「全屏绘制」的契约,理应自己计算避让区域。这导致大量依赖该属性的旧布局在 targetSdk 35 上直接翻车。


我在项目里遇到的具体场景是一个嵌套了 BottomNavigationView 的 ConstraintLayout。以前靠 fitsSystemWindows="true" 就能把 BottomNavigationView 托在导航栏上方,切到 35 之后它直接贴底。查看布局边界发现,BottomNavigationView 确实没有拿到任何 bottom padding。这说明系统栏的 insets 虽然还在,但 fitsSystemWindows 这条分发路径已经不再可靠。


从 setSystemUiVisibility 到 WindowInsetsCompat


要理解现在的困境,得先回顾一下 Google 是怎么一步步把 API 推成今天这个样子的。在 Android 10(API 29)之前,我们最常用的是 View.setSystemUiVisibility 那一套 flag,比如 SYSTEM_UI_FLAG_LAYOUT_STABLESYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION。这些 flag 组合起来可以告诉系统:「我要自己处理布局,别给我默认的安全区域偏移。」然后开发者再通过 View.OnApplyWindowInsetsListener 回调手动拿 insets 的边距。


API 30(Android 11)是一个关键分水岭。Google 废弃了 setSystemUiVisibility,转而推广 WindowInsetsController 和细粒度的 WindowInsets.Type。从这一刻开始,insets 不再是四个 int 值(left/top/right/bottom)的糊涂账,而是被拆分成了 statusBars()navigationBars()captionBar()ime()systemGestures() 等独立的类型。每个类型都可以单独查询、单独消费。


WindowInsetsCompat 作为 androidx.core 的缝合层,承担了在旧设备上模拟新 API 行为的任务。但_compat 的代价是抽象泄漏_。比如在 API 20-28 的设备上,WindowInsetsCompat.Type.ime() 的 bottom 值并不总是准确的,尤其是在键盘已经弹出的场景下,它依赖的是 View.getRootWindowInsets() 的缓存值,而某些 OEM 系统在键盘动画期间并不会及时更新这个值。再比如 WindowInsetsCompat.Type.navigationBars() 在 API 29 以下的设备上,有可能把手势导航的 mandatory gesture insets 也算进去,也可能不算,这取决于厂商有没有正确实现 WindowInsets 的派发。


代码层面,现在获取系统栏 insets 的标准写法大概是下面这样:


ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
    insets
}

这看起来干净,但放到真实项目里,你很快就会发现 setPadding 会覆盖掉 View 原本需要的内边距。而且如果这个 View 是 RecyclerView,你还必须区分 paddingclipToPadding,否则列表滚动体验会变得很奇怪。


Android 15 的强制 Edge-to-Edge 到底改了什么


Android 15 上最狠的一刀,是系统不再尊重应用之前用来「隐式退出 edge-to-edge」的那些配置。在 targetSdk < 35 的应用里,如果开发者没有在 Activity 里显式调用类似 enableEdgeToEdge() 的扩展,或者没有设置对应的 window flag,系统通常会保留传统的非全屏布局,自动在内容区域和系统栏之间留出安全距离。


但 targetSdk 35 的应用会被强制视为 edge-to-edge。这意味着即便你的 Activity 一片洁白,什么都没做,内容也会延伸到状态栏和导航栏后方。官方目前提供了一个临时逃生舱:在 theme 里设置 android:windowOptOutEdgeToEdgeEnforcement 为 true。但这被明确标记为仅用于过渡期的兼容手段,未来版本会移除。


这个变更的直接影响是,所有「假装系统栏不存在」的偷懒写法全部作废。过去很多开发者为了省事,直接把 Activity 的背景色设为白色,依赖系统默认的 windowTranslucentStatus 行为让状态栏文字反

NinePatch 的绘制原理,还能用多久 2026-09-11
Google Play 的政策收紧,开发者账号被封怎么办 2026-09-11

评论区