最近在做一个新版本应用的适配时,被一个看起来很简单的小需求绊住了:让页面内容真正全屏铺满,同时让状态栏和导航栏保持透明。一开始我想着这不就是老一套 Window.setStatusBarColor(Color.TRANSPARENT) 就能解决的吗?结果跑到 Android 16 的机型上一调试,发现传统写法根本不起作用,状态栏和导航栏要么依然是白底黑字,要么就是内容区域没有正确延伸,底部生生留出一条黑边。查了一圈源码和官方文档才发现,从 Android 15 开始系统就在推进 edge-to-edge(边到边)的强制适配,到了 Android 16(API 36)直接用 targetSdk 36 编译时,系统栏透明和内容全屏已经变成了默认规则。这篇文章就把我这次的完整适配过程、核心原理和踩过的坑都整理出来,希望对正在做 Android 16 适配的朋友有帮助。
1. 全屏与透明系统栏,到底要实现什么效果
先理清一下需求。很多产品经理口中的“内容全屏,状态栏和导航栏透明”,其实包含着完全不同的两种状态,也是最容易让开发产生误解的地方:
- 边到边(Edge-to-Edge)但系统栏可见:内容区域扩展到状态栏和导航栏后面,系统栏本身保留但背景是透明的,用户能看到内容从屏幕顶部延伸到最底下,但状态图标和导航条仍然可见,不影响操作。这是现代 Android 应用推荐的默认形态。
- 沉浸式全屏(Immersive Mode)隐藏系统栏:状态栏和导航栏临时隐藏或变为半透明悬浮状态,比如看视频、打游戏时想要完全无干扰的体验。这种情况下系统栏其实是“不显示”的,和“透明可见”是两码事。
我在文中主要讨论的是第一种:系统栏透明但可见,页面内容全屏延伸到系统栏区域,这也是 Android 16 强制要求应用开发者适配的模式。第二种需要额外调用 API 隐藏系统栏,后面我会单独讲。
还有个很容易混淆的点:透明不代表不可见。透明是指系统栏背景没有颜色,状态栏里的时间、信号、电池图标,以及底部的手势条仍然会覆盖在你的应用内容之上,所以必须给这些区域腾出空间,否则 UI 就会被遮挡。
1.1 需求拆解:谁需要这种全屏效果
可能对全屏和透明系统栏有需求的应用大体分两类:
第一类是以内容为核心的应用,比如小说阅读器、浏览器、图片预览、Dashboard 类页面。这类页面希望内容从上到下连贯展示,不希望顶部有个突兀的纯色状态栏打断视觉。比如背景是深蓝色,顶部状态栏如果还是黑色或者白色,就会显得很割裂,内容全屏后让背景色直接透过透明系统栏延伸到最顶端,视觉上会自然得多。
第二类是沉浸式需求强烈的应用,比如视频播放器、游戏、地图导航。它们更希望隐藏状态栏和导航栏,让用户聚焦在内容上。不过游戏或视频全屏时往往会选择隐藏系统栏,而地图导航通常会在状态栏下显示简易信息,所以系统栏透明且可见的模式正好适合做“悬浮半透明导航”。
如果你的应用有列表、表单、聊天界面,同样要处理系统栏透明,因为默认内容会延伸到顶部和底部,如果不给根布局加上安全区 padding,第一个条目会被状态栏遮住,底部输入框会被手势导航条挡住。
1.2 Android 16 为什么把透明全屏变成“默认规则”
老开发者都知道,以前 Android 的默认形态其实和 iOS 不太一样:应用内容默认只在 system windows 内的可用区域绘制,状态栏和导航栏通常是不透明的黑色或品牌色,应用如果要实现“沉浸式”,需要自己写大量兼容代码,比如 FLAG_TRANSLUCENT_STATUS、SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 等等。这套机制理解成本低,但问题也很多:各家 ROM 自行魔改 SystemUI,导致同一套代码在不同厂商手机上表现完全不同,很多开发者干脆只适配了某几个机型。
随着屏幕比例变长和全面屏手势普及,Android 团队决定彻底终结这种混乱状态。从 Android 15 开始,强制要求以 targetSdk 35 及以上编译的应用默认启用 edge-to-edge;Android 16 延续并强化了这一策略。也就是说,当你的应用指定 targetSdk 36 时,系统会忽略你在全局主题里设置的 statusBarColor 和 navigationBarColor,默认帮你把系统栏颜色变成透明,同时把内容区域扩展到系统栏后面,根本不需要你再去做那些“透明”的 hack。
换句话说,Android 16 的开发者不再需要主动“申请”全屏和透明,而是默认就是全屏和透明。你需要做的是反过来——学会在新的默认模式下正确布局,让内容不会撞到系统图标上。所以说,标题里的“Android16 系统内容全屏,状态栏和导航栏透明”并不是一种需要刻意实现的黑科技,而是每位 targetSdk 36 开发者都要面对的新基线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统级解决方案选型:老 API 为什么会失效
这部分是重点。既然 Android 16 默认强制 edge-to-edge,那我们把所有旧的实现方式都扔掉吗?也不是,关键是理解哪些 API 被废弃、哪些还能用、新 API 解决了什么问题。
2.1 旧方案为什么失效
在 Android 10 以前,很多应用实现“透明状态栏”的代码是这样写的:
java复制getWindow().setStatusBarColor(Color.TRANSPARENT);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
Android 10 以后,还开始流行调用 WindowInsetsController 等新 API,但大多数人依然会习惯性在 Activity 的 onCreate 里先手动把状态栏颜色设成透明作为“保险”,像这样:
kotlin复制window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
到了 Android 15 / 16 + targetSdk 36,情况就变了。系统强制执行 edge-to-edge,代码里再调用上述旧属性时,很多会被忽略,甚至有的系统栏颜色会出现不预期的黑色闪烁。比如 Window#setStatusBarColor 在 targetSdk 35+ 时基本只会应用在 3-button navigation 模式下,对手机默认的手势导航条不再生效。导航栏同理,系统现在只支持用透明背景上的对比度来区分图标颜色,而不是用纯色背景。
另外,SYSTEM_UI_FLAG_LAYOUT_* 这一整套 flags 在 API 30 就已经被标记为 deprecated,不过当时清理得不够彻底。Android 13 后很多 flags 实际上已经由系统策略覆盖,到了 API 36 再用就会收到警告甚至直接无效。强行设置还可能让布局在运行不同系统版本的设备上表现不同,形成新的兼容性问题。
所以我的建议是:只要开发的 targetSdk 已经 >= 35,就没必要再写那一套老代码。把控制权交给系统,用官方的 enableEdgeToEdge() 作为入口,然后自己处理 Insets 更省心。
2.2 官方推荐的统一入口:enableEdgeToEdge
Jetpack 的 androidx.activity 库从 Activity 1.8.0 开始提供了 enableEdgeToEdge() 扩展函数,这是目前最推荐的适配入口。它可以看作官方团队帮你把系统栏透明、图标明暗、以及必要的布局 flag 都设置好。我是在 targetSdk 升级到 35 之后开始使用的,迁移成本非常低。
简单说,enableEdgeToEdge() 实现了以下效果:
- 内容扩展到状态栏和导航栏后面;
- 将系统栏背景清为透明;
- 根据系统深色模式,自动设置浅色状态栏图标(深色图标,对应浅色栏背景)还是亮色状态栏图标;
- 适配三键导航和手势导航两种模式下的导航栏。
在 Activity 中调用时,需要放在 setContentView 之前:
kotlin复制class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
}
}
如果项目还在使用 Fragment,或者多种入口 Activity,每个 Activity 都要在 setContentView 前调用一次。因为它本质是修改当前 window 的属性,不能通过一个 Application 全局设置。
也有一部分情况不适合直接用 enableEdgeToEdge(),比如需要在自定义主题里指定状态栏为固定品牌色,或者某个页面希望沿用沉浸式隐藏。这种时候更推荐使用 WindowCompat.setDecorFitsSystemWindows(window, false) 配合 WindowInsetsControllerCompat,我通常会把这种写成一个基础 BaseActivity 的模板方法,方便不同页面自由控制。
2.3 实现路径对比:不是所有“透明”都叫 Edge-to-Edge
我看过不少历史项目把沉浸式全屏和 edge-to-edge 当成一回事,结果迁移时闹出笑话。简单总结几种常见实现目标及方案差异:
| 目标 | 传统方案 | Android 15/16 + targetSdk 36 推荐方案 |
|---|---|---|
| 内容延伸到状态栏 / 导航栏,但栏背景透明且图标可见 | setStatusBarColor(TRANSPARENT)、FLAG_TRANSLUCENT_STATUS 等 | 默认强制,通过 enableEdgeToEdge + WindowInsets 处理安全区域 |
| 界面内容故意延伸到“被挖孔/刘海”区域 | 同上 + 保守 padding | 使用 WindowInsets.displayCutout,通常配合 edge-to-edge 自动生效 |
| 游戏/视频临时隐藏系统栏,实现完全沉浸 | SYSTEM_UI_FLAG_HIDE_NAVIGATION + IMMERSIVE_STICKY | WindowInsetsControllerCompat.hide(Type.systemBars()) |
| 系统栏有品牌色或固定背景 | statusBarColor 指定颜色 | API 35+ 基本失效,需自己绘制背景到 system bar 后面并加 padding,或在 decor 上画一个 view 作为栏背景 |
| 保持内容不遮挡,并让系统栏透明 | 布局设置固定 padding | 监听 WindowInsets,动态增加 content padding |
通过表格可以直观看到,在 Android 16 的强制模式里,“透明”已经由系统完成,剩下的核心工作是“不遮挡”,也就是第二部分要讲的 Insets 处理。
3. 核心实操:三步搞定内容全屏 + 系统栏透明可见
接下来进入可以直接抄作业的部分。我会用一个最简单但完整的页面来演示:页面顶部有一张带动图的头图,下面是一个滚动列表。期望效果是头图从屏幕最顶端开始显示,状态栏叠在半透明的图片上,系统时间显示清晰;底部内容延伸到导航栏后面,列表最后一项不会被遮挡。
3.1 第一步:切换为 edge-to-edge 模式
如果你的 targetSdk 已经是 36,其实系统会自动切到 edge-to-edge,但你仍然需要调用一次 enableEdgeToEdge() 来确保系统栏图标对比度正确,特别是在深色模式下。如果你的 targetSdk 仍低于 35,可又想模拟 Android 16 的效果,那调用 enableEdgeToEdge() 也可以让老版本系统快速切到 edge-to-edge。
我的做法是在 BaseActivity 基类统一调用:
kotlin复制abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkMode -> darkMode },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkMode -> darkMode }
)
}
}
如果只想让某个页面使用,也可以在具体 Activity 的 setContentView 前调用。这里有一个需要注意的点:SystemBarStyle.auto 的参数是 light 模式和 dark mode 下的系统栏背景,也可以传入带 scrim 的 SystemBarStyle.dark 或 SystemBarStyle.light。我一般直接给透明,因为颜色差异靠图标明暗识别已经足够。
3.2 第二步:让布局内容延伸到系统栏区域
仅仅调用 enableEdgeToEdge() 后,如果直接运行,你会惊讶地发现界面的根布局已经“顶到”最上边和下边,状态栏区域的背景变成了你的内容颜色。这正是我们期望的。但如果页面主体是一个 LinearLayout 或 ScrollView,内容会被状态栏遮住,所以需要处理安全 inset。
这里必须提到一个老伙伴:fitsSystemWindows。在很多老项目里,根布局会设置 android:fitsSystemWindows="true",它的宿主会根据系统窗口自动为根 View 增加 padding,从而避开状态栏。但这个机制和 edge-to-edge 是天然矛盾的,如果设置了 fitsSystemWindows=true,它会把状态栏区域挡在外面,内容根本无法延伸到后面,那“全屏”也就无从谈起。
所以在迁移时第一步是:把根布局上的 android:fitsSystemWindows="true" 去掉,或者改为 false。如果遇到由于具体业务没法去掉,那就要用等价的 padding 调整绕过去。我自己更倾向于完全移除 fitsSystemWindows,改用代码统一处理,可预测性更强。
对于最普通的 XML 布局,设置完 enableEdgeToEdge 且不设置 fitsSystemWindows 后,内容已经能全屏延展,但为了后续处理不被遮挡,我们一般在根 View 上设置 OnApplyWindowInsetsListener。
3.3 第三步:用 WindowInsets 调整安全区域,实现真正“看起来全屏,内容又不遮挡”
写完前两步,你在视觉上其实已经实现了“内容全屏,系统栏透明”,但是我建议一定补上这一步,否则当列表滚到顶或输入框弹出时,页面会出现难看的遮挡。
推荐 99% 场景使用 ViewCompat.setOnApplyWindowInsetsListener 来处理安全区 padding。先看一个最常用的实现:
kotlin复制// 在 setContentView 之后,找到根布局
val rootView = findViewById<View>(R.id.root)
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
// 给根 View 设置 padding,避开状态栏和导航栏
view.setPadding(
view.paddingLeft,
insets.top,
view.paddingRight,
insets.bottom
)
windowInsets
}
这段代码的作用是:每当系统 insets 发生变化(比如状态栏出现/隐藏、横竖屏切换、手势条调整),都会回调,并把 insets 的 top 和 bottom 作为 padding 设置到根 View。这样内容在普通屏幕上会保留出状态栏和导航栏的高度,不会和系统图标重叠;但根 View 的内部背景已经延伸到了系统栏后面,实现“系统栏透明”效果。
不过有个细节:如果你直接给根 LinearLayout 设置 padding,会导致它的背景色只能绘制在 padding 内部,而 padding 区域透出的其实是 window 默认背景。这样会造成“顶部有一段背景色和页面背景不同”的 bug。换句话说,给根 View 统一 padding 并不完美:我们通常希望“外部区域(系统栏背后的区域)”使用页面的背景,而“页面内容”避开系统栏。
更精细的做法是区分两种情况:
- 如果页面根布局有透明背景,且整体页面背景是一张图片或颜色,那我们应该给根内容区域的子 View(比如列表)加 padding,而不是给根 View 加。例如
androidx.coordinatorlayout.widget.CoordinatorLayout配合AppBarLayout时,通常只需要给内容区的RecyclerView加 bottom padding,顶部会被AppBarLayout吸收。 - 如果根布局只是包裹内容且背景色本身已经延伸到根之外(比如布局根背景是蓝色,没有分层),那么使用上述简单 padding 是没问题的。
我通常写一个工具类,动态处理 insets 并决定是给根 View 设置 padding,还是给特定列表设置 padding:
kotlin复制object EdgeToEdgeHelper {
fun applySystemBarInsets(view: View, topAdjust: View? = null, bottomAdjust: View? = null) {
ViewCompat.setOnApplyWindowInsetsListener(view) { v, systemInsets ->
val insets = systemInsets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(top = insets.top, bottom = insets.bottom)
systemInsets
}
}
}
然后在布局里按需调用。还有一个更现代、声明式的方式是使用 Android 12 新增的 windowOptOutEdgeToEdgeEnforcement 来关闭强制,但这不是我们想要的。
为了不掩盖整页背景,我补充一个经典方案:给根布局设置 android:clipToPadding="false",让列表滚动内容可以延伸到系统栏后面。也就是说,根布局不设 padding,而让内部可滚动列表设置 padding top/bottom 并开启 clipToPadding=false,这样当内容滚动到顶时,第一个条目的背景会滑到状态栏后面,视觉上是一种真正“全屏”的效果,但首屏内容依然停留在安全区内。这在实际项目里非常常用。
3.4 完整代码示例:一个带透明系统栏的全屏详情页
下面以 ConstraintLayout + RecyclerView 为例,展示一个安全的实现模板。假设有一个详情页,背景色是浅灰色,头图延伸到状态栏后。
Activity:
kotlin复制class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_detail)
val rootView = findViewById<ConstraintLayout>(R.id.root)
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, systemBars ->
val insets = systemBars.getInsets(WindowInsetsCompat.Type.systemBars())
// 动态设置内容区域 Insets
findViewById<View>(R.id.content).updatePadding(top = insets.top, bottom = insets.bottom)
systemBars
}
}
}
布局文件:
xml复制<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page_bg">
<ImageView
android:id="@+id/hero"
android:layout_width="0dp"
android:layout_height="260dp"
android:scaleType="centerCrop"
android:src="@drawable/hero"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="16dp"
android:paddingBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/hero" />
</androidx.constraintlayout.widget.ConstraintLayout>
这里我们假设顶部 hero 图片本来就应该延伸到状态栏后面,所以我们不给 RecyclerView 加 top inset,还是加了一个 paddingTop,是为了避免内容撞到图片。如果你希望列表内容从图下方才开始滚动,那么只需要在 RecyclerView 上设置 paddingTop 即可,不需要动态设置 Insets。如果页面顶栏是一条固定 Toolbar,需要避开状态栏,则将 Toolbar 的高度加 android:fitsSystemWindows 是不行的,正确的做法是给 Toolbar 根节点加上 Insets padding 或使用 ViewCompat.setOnApplyWindowInsetsListener。
再看一下不需要 Toolbar 的简单列表页面。假设根布局是 LinearLayout,背景色就是页面背景,我们给根布局设置系统栏 padding:
kotlin复制rootView.setOnApplyWindowInsetsListener { v, insets ->
val systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemInsets.left, systemInsets.top, systemInsets.right, systemInsets.bottom)
insets
}
这个写法适合内部没有需要视觉全屏元素的场景。实际上我们经常面临混合情况:页面底部是空白背景延伸到导航栏,顶部是自定义标题栏要避开状态栏。这时候更推荐用“Handle Window Insets on the View”的方式:
- Header 顶部标题栏:设置
fitsSystemWindows=false,但代码中给标题栏布局添加paddingTop = systemBars.top。 - 底部容器:给列表添加
paddingBottom = systemBars.bottom,clipToPadding=false。
一定要记住:只在需要避让系统栏的 View 上消耗 Insets,其他区域不要统一加 padding,否则全屏效果就消失了。
4. 如果你要的是“彻底隐藏系统栏”,那又是另一回事
有不少视频类应用、游戏页面,最终想要的效果是让状态栏和导航栏完全消失,只在用户点击屏幕或滑动时重新出现。这就是真正的 “Immersive Mode”。它和“透明可见”的系统栏是两个路线,所以务必确认需求再动手。
4.1 使用 WindowInsetsController 隐藏系统栏
在 Android 16 的 API 里,隐藏系统栏的推荐方式是利用 WindowInsetsControllerCompat:
kotlin复制class PlayerActivity : AppCompatActivity() {
private val insetsController by lazy {
WindowCompat.getInsetsController(window, window.decorView)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_player)
// 隐藏系统栏
insetsController.hide(WindowInsetsCompat.Type.systemBars())
// 让隐藏后仍能在屏幕边缘滑动唤出系统栏
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
hide(Type.systemBars()) 会把状态栏和导航栏同时隐藏。如果你只想隐藏状态栏,可以传 Type.statusBars();只想隐藏导航栏,传 Type.navigationBars()。
这里有一个和全屏需求非常相关的坑:调用 hide 后,WindowInsetsCompat.Type.systemBars() 的 insets 会变成 0,那么你在第 3.3 节里设置的根 View padding 会自动变为 0,内容会顺势铺满整个屏幕,符合预期。但是当用户下拉呼出系统栏时,insets.top 又会被回调回来,你的内容会突然被顶下去,产生跳变。为了避免这种跳变,通常需要监听系统栏 visible 状态,在 onApplyWindowInsets 里根据 systemBars.isVisible 决定是否在 padding 中保留一部分“半透明遮罩层”的空间,而不是直接使用 0。不过多数沉浸式设计并不需要顶栏避让,所以大部分开发者不会管这个。
另一个常见坑:如果调用 hide 之后,再调用 show,系统栏恢复可见。但系统栏图标颜色可能没有跟随应用当前的深色/浅色主题自动更新,导致在浅色内容上显示出浅色图标,看不清楚。因此建议在 show 之后重新调用一下 isAppearanceLightStatusBars 设置。
kotlin复制fun showSystemBars() {
insetsController.show(WindowInsetsCompat.Type.systemBars())
// 恢复图标风格,跟随当前系统亮暗模式
insetsController.isAppearanceLightStatusBars = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_NO
}
4.2 沉浸模式下的“Sticky”行为还重要吗?
Android 16 全面使用 WindowInsetsController 后,过去那套 IMMERSIVE_STICKY 系统 flag 已经不存在了。现在要在隐藏系统栏后继续支持“边缘滑出”且自动淡出,需要单独设置 systemBarsBehavior 为 BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE。
kotlin复制insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
这个 behavior 的含义是:用户滑动屏幕边缘时,系统栏会以半透明浮层的形式临时出现,几秒后自动消失。这种形式比较适合视频播放器,因为内容全屏、系统栏透明而且临时出现不会永久阻挡画面。
如果用 old API 的 SYSTEM_UI_FLAG_IMMERSIVE_STICKY,它在 API 36 的源码里实际上已经被剥离到很边缘的位置,某些系统版本甚至不再生效。所以新的适配工作,统一用 WindowInsetsControllerCompat 即可。
4.3 结合全屏透明,如何同时支持手势导航
很多人在 Android 10 全面屏手势之后遇到了一个奇怪问题:应用虽然隐藏了导航栏,但底部还是会显示一根细长的手势条(导航条),怎么也去不掉。其实这是系统为了保持手势可发现性强制保留的。要接受这个现实:当设备处于手势导航模式时,导航栏区域的系统栏 insets 中,底部会有一部分是“手势导航栏”占用的空间。如果你的应用目标是隐藏导航栏获得完全全屏,这部分手势条依然会保留。我测试下来,Android 16 上只有带实体 Home 键的老式设备能把导航栏隐藏得很干净,主流的全面屏手势设备都至少会保留一条半透明导航指示。
对于“内容全屏但导航栏透明”的页面,我们通常不希望这块区域一直占据可点击空间,所以处理 Insets 时,手势导航条的 bottom inset 本身很小(通常 24dp 左右),一般把 RecyclerView 的 bottom padding 设置为 insets.bottom + 16dp 即可,让最后一个条目标注有充足空间,不会被 Android 系统手势条遮挡。
5. 适配过程常见问题与排查实录
在我针对 Android 16 做全屏透明适配时,遇到了一批很有代表性的问题。下面整理成几个小标题,每个都是实际调试现场,希望能帮你直接跳过。
5.1 状态栏图标“消失”了,深色背景下看不清
场景:我把页面背景设计成深蓝色,调用了 enableEdgeToEdge() 后,状态栏透明,但时间、信号图标变成了深灰色,几乎和深色背景融为一体,看不清。原因是系统默认按“当前系统亮色模式”来决定状态栏图标的颜色。我的应用是深色页面,但系统设置处于浅色模式,所以系统就把图标设置成了深色图标,期望配合浅色状态栏背景;但我的应用背景实际是深色的,自然看不清。
解决办法是通过 WindowInsetsControllerCompat 手动设置图标明暗:
kotlin复制window.statusBarColor = Color.TRANSPARENT
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = false // false 表示状态栏图标为浅色
如果系统处于深色模式,默认会使用浅色图标,在深色背景上是没问题的;但浅色系统 + 深色应用页面,就需要显式设置为 false。同理,如果你页面顶部是白色的,则需要 isAppearanceLightStatusBars = true。这个逻辑最好和 Configuration.uiMode 联动判断。
5.2 底部导航栏区域出现一条黑色或白色背景条
升级 targetSdk 36 后我遇到一个奇怪的视觉 bug:页面背景是浅色,底部导航栏位却始终是纯黑色。检查布局确实延伸了,底部也没有多余 padding,最后发现是“老代码残留”——父类在某处调用了 window.navigationBarColor 且传了 Color.BLACK。在 Android 16 上,这个 API 虽已失效,但部分厂商 ROM 仍然尊重它,于是出现了黑色横条。排查方法是全局搜索 setNavigationBarColor、setStatusBarColor,把它们都改成透明或删掉。不要把 navigation bar 颜色写成不透明。
另外如果项目里引用了较老版本的 Android 库,比如 com.google.android.material:material 1.6.0 以下,可能会在主题里偷偷设置 android:navigationBarColor,也需要覆盖掉。
5.3 布局顶部出现一大片空白,内容没有全屏
如果布局顶部出现和页面背景不协调的空白,大概率是某个 View 还开着 fitsSystemWindows="true"。常见于老项目中的根布局,或者大量第三方控件内部的根 View。需要重点检查:
android:fitsSystemWindows="true"在 XML 中的痕迹;- 使用了
CoordinatorLayout时,AppBarLayout默认会消费系统 insets; - 如果自定义了
DrawerLayout,它内部也常常对状态栏做了特殊处理。
最快速定位方式:把 Android Studio 的 Layout Inspector 打开,查看界面中哪个 View 带有 fitsSystemWindows=true,然后按需修改。
如果项目是 Compose 而不是 View 系统,逻辑类似,在 Compose 里默认 edge-to-edge 以及 systemBarsPadding 由 WindowInsets 控制,本质上是一样的,只是不再有 View.OnApplyWindowInsetsListener,而是用 Modifier.systemBarsPadding()。
5.4 软键盘弹出后,输入框被键盘遮住或页面整体错位
问题复现:升级到 Android 16 + targetSdk 36 后,登录页输入框被软键盘顶上去后,整个页面整体下移了一截,或者输入框被底部导航栏挡住。归根结底是 edge-to-edge 模式下软键盘 insets 和系统栏 insets 混在了一起。
Android 官方建议,在 edge-to-edge 的 16 环境里,为了让软键盘能自动调整布局,通常使用 window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)。但必须在清单或代码中确保 Activity 的 windowSoftInputMode 是 adjustResize,而不是 adjustNothing。
然后,在 OnApplyWindowInsetsListener 里要分别监听 Type.systemBars() 和 Type.ime()。不要简单地把 ime 高度作为底部 padding 加到根 View,否则会导致键盘弹出时内容被顶得很高,甚至跳来跳去。一般我会做一层逻辑:
kotlin复制ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
val bottomInset = if (ime.bottom > 0) ime.bottom else systemBars.bottom
root.updatePadding(bottom = bottomInset)
insets
}
这时键盘弹出时代替系统导航栏 area,底部 padding 变成键盘高度,页面不会被键盘覆盖;键盘隐藏时又回到原来的导航栏 padding。如果你不做 ime 监听,沿用老的 systemBars bottom padding,键盘会把你的布局盖住。
5.5 Dialog、PopupWindow 和 Fragment 页面的隔离处理
全屏适配不是只在 Activity 层生效,Dialog 和 PopupWindow 也都有自己的 Window,这些 Window 默认不会自动跟随 Activity 的 edge-to-edge 行为,需要单独处理。
如果你在 Activity 里弹出的 Dialog 还设置了圆角背景,很可能会发现弹窗被状态栏或导航栏遮挡。这时候可以在 Dialog 的 onCreate 里调用:
kotlin复制dialog.window?.let { window ->
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = false
}
同时记得给 Dialog 内容根布局设置系统栏 padding,不然文字会压在状态栏上。PopupWindow 类似,不过它的内容通常不会顶到状态栏那么高,一般只需要设置底部点击区域避开系统栏即可。
5.6 第三方页面库(如 WebView、地图、视频播放器)全屏时的独立行为
WebView 和视频播放器在进入全屏时,往往会自行控制系统 UI。如果你在自己的页面已经实现了内容透明全屏,再让 WebView 全屏播放视频时,可能会看到状态栏又从透明变回了不透明底色。这是因为 WebView 内部使用了不同的 SystemUiVisibility 控制逻辑,甚至直接调用了老 API。
处理方案是:给 WebView 设置一个 WebChromeClient,在里面响应 onShowCustomView / onHideCustomView,在这两个回调中手动同步全局 SystemUI 状态,保证在状态栏隐藏(沉浸)和恢复之间和你的透明全屏状态一致。地图应用如果使用官方 SDK,它们通常自己会处理全屏地图,但如果你要在地图上层悬浮 UI,同样要留意 Insets 更新。
5.7 半透明状态栏和遮挡处理
Android 16 强制 edge-to-edge 时,系统的状态栏背景默认透明,但你依然可以通过 enableEdgeToEdge 传入带透明度的颜色值,比如半透明红色背景 + 浅色图标,做一种介于透明和完全不透明之间的效果。不过要记住,这种半透明背景同样属于“系统栏背景”,仍然会和页面内容叠在一起,所以布局的 padding 处理逻辑一样。
如果应用页面是横向滑动的图片浏览,透明系统栏能减少割裂感,但如果横屏游戏想完全看不到任何系统元素,还是得走沉浸模式隐藏,而不是仅仅透明。
5.8 状态栏透明后截图或录屏出现“多余”的状态栏图标
有测试反馈:页面沉浸式全屏状态下,录屏视频里仍然能看到状态栏上的时间信号图标,或者按钮呼出系统栏时 icon 消失了但留下了透明背景。这是 Android 10 后一个比较“迷”的地方:在某些情况下 Type.systemBars() 并不会完全清除内容可见范围,状态栏区域的绘制被系统单独缓存。我遇到的大多是测试使用了三键导航而不是手势导航,系统将状态栏认为是“永久可见”的。如果用户想要彻底隐藏,需要额外隐藏父级区域。
针对这个问题,我通常建议:只要不是游戏或视频播放器,不必追求 100% 隐藏状态栏,现代 Android 的交互中,时间、网络状态保留在顶部反而能让使用者感到稳定。如果产品执意要求隐藏,尽量引导用户开启全屏模式(隐藏所有系统栏),同时处理预加载阶段被遮挡的内容。
5.9 状态栏 / 导航栏透明后,页面背景滚动的过渡
透明系统栏最常见的交互问题是:当页面上下滚动时,背景颜色、图片内容在系统栏区域发生变化,但你给页面内容设定的 padding 是固定的,导致内容边缘在越过状态栏时看起来像“突然穿帮”。比如你有一个浅色列表,顶部固定是白色 Toolbar,状态栏是透明,滚动时列表内容从 Toolbar 上滑到状态栏后面,可能颜色不协调。
完美方案是让背景真正延伸到系统栏后面,并且让离屏内容通过 clipToPadding=false 平滑滑动。比如 RecyclerView 的顶部 padding 不是硬生生抠开的间距,而是让列表内容能够滑到状态栏后面去。这样滚动过程中,内容条目的背景色会自然填充系统栏区域,不会露出页面根背景。这类需求适合首页信息流,可以把根布局的 padding 设成 0,只给 RecyclerView 内部的 item 顶部加一个 paddingTop,或者使用 header 方式。
5.10 在 Android 16 上模拟老版本看效果
如果你本地开发机还没有 Android 16,想提前验证行为,可以将 targetSdk 临时升到 35(Android 15),使用 Android 15 系统的模拟器。从 15 开始系统便在强制 edge-to-edge 了,所以 Android 16 上的效果在 Android 15 模拟器上一样能复现。如果你目标只是让状态栏/导航栏透明但内容不全屏,还可以通过 Android 12 的“遵循系统导航条”设置进行模拟。我觉得最稳的方法还是开 Android 16 官方模拟器真实切换手势导航和三键导航各测一遍。
6. 总结实操后我最终沉淀的一套适配规则
经过这次 Android 16 的适配,我整理出老项目转型时能落地的几条规则,也在新项目里当成了团队规范:
- 所有用 targetSdk 36 编译的新页面,不再写
setStatusBarColor、setNavigationBarColor这类 API,统一依赖系统透明实现。 - 所有 Activity 基类在最前面调用
enableEdgeToEdge(),不要在个别页面自行用老 API 覆盖。 - 移除 XML 根布局的
fitsSystemWindows,改用ViewCompat.setOnApplyWindowInsetsListener或子 View padding 按需处理安全区域。 - 列表页面的滚动内容需要视觉上全屏延伸时,给 RecyclerView 设置
android:paddingTop/Bottom并配合android:clipToPadding=false。 - 页面有输入框时,必须显式处理 IME insets,避免软键盘压迫内容。
- 如果项目必须兼容 Android 8/9 等旧版本,仍然可以通过
if判断 API level,在老版本上继续使用旧 API,新版本走新流程,不要用一套老代码硬吃全部版本。
最后再分享一个调色经验:完成透明栏适配后,系统栏透明并非是把颜色都设成 #00000000 就一劳永逸。如果你的页面有亮色与暗色两种主题,一定要把“系统栏图标颜色”和“系统栏背景”一起切换。我一般把这些都封装进一个 SystemBarDelegate 工具里,传入 Activity 和页面当前的主题背景色、图标明暗,在进入页面时调用一次,省得每个页面重复处理。工具类还可以统一注册 onApplyWindowInsets,保证在页面尺寸变化和键盘弹出时始终正确。代码我贴在了自己的项目文档里,核心逻辑完全靠上面的 WindowInsets 和 enableEdgeToEdge() 支撑,并不复杂,后面如果有空,我再单独开一篇讲如何把这些能力封装成一行调用的库。
