2026年7月15日 · 约 17 分钟读完
Flutter Android 底部手势导航区域出现黑条的一次排查
记录一个长期反复出现的 Flutter Android 底部黑条问题:为什么常见方案在不同项目里时灵时不灵,以及如何通过真机和单变量实验收敛到最小修复。
问题简介
Flutter Android 底部手势区域的黑条,并不是我最近才碰到的问题。它断断续续出现了很久,而且最麻烦的地方不是“没有方案”,而是网上能找到的方案太多,却总是时灵时不灵。
在一些新项目里,可能只改 Theme,或者把导航栏颜色设成透明,黑底就消失了。到了维护时间较长的项目,同样的写法却未必有效:Flutter 层、Theme、原生 Activity 和插件里各有一套系统栏配置,几个常见方案都试过,底部仍然可能是黑的。
以往遇到这种情况,我通常也是继续补配置。先把 Flutter 里常见的系统栏选项加上:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarContrastEnforced: false,
),
);
runApp(const MyApp());
}
Android Theme 里再补一份:
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:enforceNavigationBarContrast">false</item>
这些写法在部分项目里确实解决过问题,但换到另一个项目、另一套 Flutter 或 Android 配置,结果又可能完全不同。在这台 Android 14 的三星设备上,能想到的地方基本都设了,底部依旧是一整块黑色,只有中间的手势提示条是灰色。
反复几次以后,我不太相信问题只是“还少哪个透明属性”了。更可能的情况是:Flutter、Theme 和原生窗口都在碰同一个 Window,某个方案偶尔有效,只是它恰好成了最后一次写入;项目结构或初始化顺序一变,前面写进去的透明色转眼又被覆盖。
所以这次我没有继续在老项目里叠方案,而是重新建了一个尽量干净的 Flutter Android 项目,把每一层配置拆开验证。这篇记录想弄清的也不是“再提供一套可复制的配置”,而是这些配置分别解决什么问题、究竟哪一项生效,以及老项目应该怎样快速找到干扰来源。
排查中间还踩了一个很小、却很容易误导判断的坑:测试页最初用的是接近白色的 Material 默认背景。导航栏变成白色、浅色半透明或真正透明时,截图看起来都差不多,很难判断底部露出来的究竟是页面,还是系统自己画的一块相近颜色。
后来我把页面统一换成 #4C8D88 蓝绿色。背景既不黑也不白,三种状态一下就分开了:
| 状态 | 底部表现 | 是否真正显示页面背景 |
|---|---|---|
| 黑色不透明 | 底部是一整块纯黑区域 | 否 |
| 深色半透明 | 能看到蓝绿色,但明显覆盖了一层深色遮罩 | 部分显示 |
| 完全透明 | 底部与页面主体保持连续的同一蓝绿色 | 是 |
三张图来自同一台设备、同一个页面,只改了导航栏的处理方式。这里不要盯着手势提示条看,真正要比较的是它周围那层背景是否还盖在页面上。
先别急着改颜色:这里其实有三层问题
屏幕底部看起来只有一小块,背后却混着三件事:
- 应用窗口是否延伸到系统导航栏区域。
- 系统导航栏背景是否透明。
- 手势条或三键导航图标应该使用深色还是浅色。
这三个问题彼此相关,却不是同一个开关。
systemNavigationBarColor 设成透明,只回答了第二个问题。如果应用根本没有画到导航栏后面,透明之后也没有页面内容可露出来;看到的仍可能是窗口背景、系统保护色,或者厂商 SystemUI 额外绘制的黑层。
真正的 edge-to-edge 关系应该是:
Flutter 页面背景
-> 延伸到屏幕物理底部
-> 系统导航栏背景透明
-> 手势提示条绘制在页面背景上方
所以只改颜色并不够。绘制范围、背景透明度和图标明暗,必须分开判断。
排查过程
为了避开老项目里历史配置和插件的干扰,我从 Flutter 空项目开始,把测试条件固定在同一台 Android 14 三星设备上,并始终使用手势导航。这样每次只改一个变量,结果才有可比性。
先看 Flutter 空项目。屏幕物理尺寸是 1080 × 2340,Flutter 的 SurfaceView 却只到 1080 × 2301,余下的底部区域由 Samsung SystemUI 的 NavigationBar0 Surface 占着。
随后只在 Flutter 层启用:
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
再检查时,Flutter SurfaceView 已经扩展到完整的 1080 × 2340。也就是说,SystemUiMode.edgeToEdge 的确让页面画到了物理底部,但截图里的导航栏还是黑的。
这一步很关键:页面已经在黑层下面了,眼前那块黑色来自 Samsung SystemUI 的 NavigationBar Surface,而不是 Flutter 页面没有铺满。
确认责任层以后,我停下了继续“叠配置”的做法,改成把 Flutter、Theme 和原生层拆开,一个变量一个变量地试:
| 测试配置 | 结果 |
|---|---|
Flutter 只启用 SystemUiMode.edgeToEdge | Surface 全屏,但底部仍为黑色 |
再加入 windowTranslucentNavigation=true | 黑色变成灰色半透明,不是页面背景真正透出 |
| Flutter + Theme 声明透明导航栏,不加原生代码 | 仍然黑色 |
| 原生层完整设置透明色、对比度、InsetsController | 透明 |
| 移除对比度、InsetsController、Theme 和 OverlayStyle | 仍然透明 |
只在 onCreate 中设置透明色 | Flutter 初始化后被覆盖,黑色回来 |
在 onWindowFocusChanged 中设置透明色 | 稳定透明 |
一路删到最后,只剩两个真正影响结果的动作:Flutter 把 Surface 铺到屏幕底部;原生层等窗口获得焦点后,再把导航栏颜色写成透明。
黑色、半透明和透明不是同一种状态
这轮实验还有一个意外收获:结果并不是简单的“修好”或“没修好”,中间还有一种很容易被误判的半透明状态。
黑色不透明
即使 Flutter 已经进入 edge-to-edge,只要 Samsung SystemUI 的 NavigationBar Surface 最终还是黑色,页面画得再满,用户看到的也只会是一块纯黑底。
这时从 SurfaceFlinger 看,Flutter Surface 已经是完整屏幕尺寸,黑色来自覆盖在上方的系统导航栏层。
深色半透明
参考方案会在日间和夜间 Theme 中加入:
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
在这台三星 Android 14 上,这组配置并非毫无作用:纯黑底变成了一层深色半透明遮罩。蓝绿色能透出来,但底部明显比页面主体更暗。
如果测试页恰好是白色,这层浅灰或深灰很容易被当成“已经透明”。换成蓝绿色后,遮罩就藏不住了。
因此,windowTranslucentNavigation 更像是一种旧式的半透明导航栏能力。它能让纯黑底好看一些,却不等同于现代 edge-to-edge 里的完全透明。
完全透明
完全透明时,页面蓝绿色背景一直延伸到屏幕物理底部,手势提示条直接绘制在页面背景之上。底部不应该再出现独立的纯黑、灰色或深蓝绿色色带。
这也是我后来坚持用非黑非白背景验证的原因。纯色、渐变或图片都可以,只要能证明底部露出的确实是页面内容,而不是系统凑巧画了一个相近颜色。
为什么这两处修改缺一不可
两个层分别控制什么
回头看,黑条之所以绕,是因为绘制范围和系统栏颜色根本不在同一层控制。
Flutter 的 SystemUiMode.edgeToEdge 负责让 Flutter Surface 不再停在底部 inset 之前。没有它,即使系统导航栏变透明,下面也没有 Flutter 页面内容可以显示。
Android 的 Window.navigationBarColor 负责告诉 SystemUI 导航栏背景应该使用什么颜色。在这台三星设备上,Flutter 启动阶段写入的透明颜色没有成为最终状态,NavigationBar Surface 仍然使用黑色。
两者的分工可以简化成:
SystemUiMode.edgeToEdge
-> Flutter Surface 扩展到屏幕底部
window.navigationBarColor = Color.TRANSPARENT
-> Samsung SystemUI 的 NavigationBar Surface 变透明
前者决定“下面有没有内容”,后者决定“上面的系统层透不透”。少一个,结果都不对。
为什么必须在窗口获得焦点后设置
同一行代码放进 MainActivity.onCreate(),底部还是黑色;挪到 onWindowFocusChanged(),透明效果才稳定下来。关键并不是 Kotlin 和 Dart 谁的优先级更高,而是谁写得更晚。
Flutter engine 初始化、Activity 主题切换和系统 UI 同步都可能在 onCreate() 之后继续修改 Window。窗口获得焦点时再补一次,可以确保透明色成为当前页面的最终值:
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
window.navigationBarColor = Color.TRANSPARENT
}
}
这里要留一个边界:这个结论来自当前 Flutter 版本和这台 Android 14 三星设备。换到别的系统或厂商,仍应先遵循平台本身的 edge-to-edge 行为,再决定是否需要这段兼容代码。
不同 Android 版本,处理思路也不一样
Android 最近几个版本对 edge-to-edge 的态度变化很大。一段旧代码能在 Android 9 上解决问题,不代表它适合继续带到 Android 15、16。
| 系统版本 | 推荐处理 |
|---|---|
| Android 16 及以上,且 target SDK 36+ | 系统强制 edge-to-edge,不能退出;重点转为正确处理 WindowInsets |
| Android 15,且 target SDK 35+ | 默认启用 edge-to-edge,手势导航栏默认透明;需要避免底部交互内容被系统栏遮挡 |
| Android 10–14 | 主动启用 edge-to-edge;如果厂商 SystemUI 仍显示黑底,在窗口获得焦点后重新写入透明导航栏颜色 |
| Android 5–9 | 使用透明导航栏和窗口布局标志兼容;不同厂商、三键导航模式可能有差异 |
| Android 4.4 等遗留系统 | 只有项目仍支持这些版本时,才考虑 windowTranslucentNavigation 作为降级方案 |
Android 15 和 Android 16
应用 target SDK 达到 35、运行在 Android 15 时,系统默认启用 edge-to-edge,手势导航栏也默认透明,页面自然会绘制到系统栏后面。
到了 Android 16 和 target SDK 36,应用已经不能退出 edge-to-edge。与其想办法恢复过去那条底部安全区,不如把精力放在 Insets 上:背景可以铺到底,按钮、输入框等交互内容则要主动避开手势区。
Flutter 3.27 开始默认 target Android 15。即便项目里没有手动写 targetSdkVersion,也最好确认当前 Flutter SDK 最终生成的值,别只凭旧项目的经验猜。
Android 10 到 Android 14
Android 10–14 并不会在所有组合下自动替应用开启 edge-to-edge。先别碰导航栏颜色,第一步是确认 Flutter Surface 能不能铺到底:
Flutter 层:
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
如果 Surface 已经全屏,目标设备上仍留着黑色手势区,再在原生窗口获得焦点后补一次透明色:
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
window.navigationBarColor = Color.TRANSPARENT
}
}
三键导航要不要保留系统保护层,是另一道题。只有确认半透明对比度遮罩不符合设计时,才需要单独评估 isNavigationBarContrastEnforced,不要和手势导航黑底混在一起处理。
Android 9 及以下
一些旧方案会在 Theme 中加入:
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
这套写法在一些旧系统上确实有效,但属于较早的窗口透明方案。Android 10 以上的新项目,我会优先使用 edge-to-edge 和 WindowInsets,不再把 windowTranslucentNavigation 放在主方案里。
如果最低版本已经是 Android 10,这个属性可以不引入。项目还要照顾 Android 4.4、5.0 一类遗留设备时,再把它放进对应版本的资源目录做降级,并用真机确认三键导航和厂商系统的实际表现。
新 Flutter Android 应用的最小化解决方案
如果是刚创建的 Flutter 项目,而且已经在 Android 10–14 的目标设备上复现黑条,先只改两处就够了:一处负责把 Flutter 画面铺到底,另一处负责让系统导航栏真正透明。
先在 lib/main.dart 启动应用前开启 edge-to-edge:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
runApp(const MyApp());
}
再到 android/app/src/main/kotlin/.../MainActivity.kt,等窗口获得焦点后把导航栏颜色写成透明:
package com.example.app
import android.graphics.Color
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() {
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
window.navigationBarColor = Color.TRANSPARENT
}
}
}
改完后换一个明显的非黑非白背景,重新安装到真机验证。在这台复现问题的三星 Android 14 设备上,Theme、windowTranslucentNavigation、沉浸式 Flag、WindowInsetsController 和导航栏对比度开关都不是必要条件。
当然,这不意味着每个新 App 都要照抄原生兼容代码。当前 Flutter 和 target SDK 可能已经默认处理了 edge-to-edge;目标设备也未必存在 SystemUI 覆盖问题。只有 Android 10–14 上确实复现黑底时,才需要把这两步都补齐。
还有一点别漏掉:这里修的是背景,不是内容安全区。底部有按钮、输入框或自定义 Tab 栏时,仍要通过 MediaQuery.viewPaddingOf(context).bottom 或 SafeArea 留出可交互区域。
完整方案里,哪些配置该留
最小方案解决了黑底,但实际项目往往还要照顾启动过渡、图标明暗和底部控件位置。下面这些配置各有用途,只是不该混成一包,然后把“碰巧透明了”当成结论。
Flutter 层
Flutter 层保留前面最小方案中的 SystemUiMode.edgeToEdge。额外的 SystemUiOverlayStyle 可以控制状态栏和手势条的明暗,却不是消除黑底的关键。需要它时就按页面背景配置,不需要为了“保险”把所有字段都填一遍。
Android Theme
Theme 适合定义应用启动阶段的系统栏外观,例如:
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
<item name="android:enforceNavigationBarContrast">false</item>
</style>
不过,把日间和夜间 Theme 里的这些系统栏属性全部移除后,透明效果依旧成立。它们可以改善启动过渡和默认图标样式,却不是这次三星黑底的关键修复。
夜间 Theme 里的 windowLightStatusBar 和 windowLightNavigationBar 也应该跟着页面实际背景走。系统切到夜间模式,不代表当前页面一定是深色。
Android 原生兼容
原生层沿用最小方案里的 onWindowFocusChanged() 写法即可。navigationBarColor 在新版本 Android 上正逐步失去作用:Android 15 配合 target SDK 35 时默认使用透明手势导航栏,Android 16 配合 target SDK 36 时则强制 edge-to-edge。这里保留这行代码,只是为了兜住已经真机复现的 Android 14 厂商行为。
如果项目只面向已经强制 edge-to-edge 的新系统,或者目标设备根本没有出现覆盖问题,就没有必要增加这段原生代码。
不要忘记 Insets
背景可以延伸到手势区,按钮却不该跟着沉下去。
如果底部是自定义导航栏,可以让容器背景覆盖到底部,再把安全距离加在内容内部:
Widget buildBottomBar(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
return ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: const AppBottomNavigation(),
),
);
}
列表背景和装饰内容可以画在系统栏后面;底部按钮、输入框和浮动操作按钮则应该留出对应的安全距离。
存量项目的快速排障检查清单
存量项目里,Theme、插件、Activity 基类和旧的沉浸式代码都可能修改 Window,直接复制新项目的两处改动未必能解决问题。更省时间的做法,是按顺序把下面九项走一遍:
| 完成 | 检查动作 | 通过标准 | 未通过时先查什么 |
|---|---|---|---|
| □ 1 | 记录设备品牌、Android 版本、导航方式和实际 targetSdkVersion | 后续测试始终使用同一组条件 | 先固定设备和导航方式,不要混用手势与三键导航的结果 |
| □ 2 | 把测试页改成明显的非黑非白纯色 | 能清楚区分纯黑、深色半透明和页面原色 | 先建立视觉基准,不要使用白色背景判断透明度 |
| □ 3 | 完整结束并冷启动 App,连续复现两次 | 两次结果一致,并保存修复前截图 | 热重载无法反映启动 Theme 和 Window 的初始化过程 |
| □ 4 | 对比屏幕物理高度与 Flutter Surface 高度 | Flutter Surface 已经绘制到屏幕物理底部 | 检查 edge-to-edge、setDecorFitsSystemWindows、Activity 基类和旧布局 Flag,暂时不要调颜色 |
| □ 5 | 全局搜索所有系统栏配置入口 | 已列出 Dart、Theme、原生层和插件的全部写入位置 | 搜索 SystemChrome、navigationBarColor、windowTranslucentNavigation、WindowCompat、WindowInsetsController 和 systemUiVisibility |
| □ 6 | 保留 edge-to-edge,其余配置逐项做 A/B | 每轮只改变一个属性或调用时机,并完整重启 | 同时修改多层会掩盖真正生效的设置,先回到单变量实验 |
| □ 7 | 分别在 Theme、onCreate()、onWindowFocusChanged() 测试透明色 | 可以确定最后一次有效写入发生在哪个阶段 | 若 onCreate() 无效但获得焦点后有效,优先判断为后续初始化覆盖 |
| □ 8 | 切后台再返回,并开关权限弹窗和键盘 | 导航栏仍保持预期状态 | 检查生命周期回调、页面恢复逻辑和插件是否重复设置系统栏 |
| □ 9 | 在目标系统版本和导航方式上复测 | 手势和三键导航分别符合设计,底部控件未被遮挡 | Android 15/16 优先检查 Insets;Android 10–14 再处理厂商 SystemUI;旧系统使用版本限定资源 |
九项走完,问题通常会落到下面四类之一:
| 现象 | 优先排查方向 |
|---|---|
| 页面没有绘制到物理底部 | edge-to-edge、布局 Flag、setDecorFitsSystemWindows |
| 页面已全屏,但导航区域纯黑 | 导航栏最终颜色、厂商 SystemUI、写入时机 |
| 页面背景可见,但覆盖深色遮罩 | windowTranslucentNavigation、导航栏对比度保护层、三键导航策略 |
| 启动正常,切后台或弹窗后复现 | 生命周期回调、插件或页面恢复时重复写入系统栏样式 |
先确认归属,再引入最小修复。比如加入 windowTranslucentNavigation 后只得到深色遮罩,就应该记为“半透明”,而不是“修复成功”。如果一口气同时改 Dart、Theme 和原生层,即便最后变透明了,也很难知道究竟是哪一项起了作用。
这些尝试为什么没有解决问题
只设置透明颜色
systemNavigationBarColor: Colors.transparent
这行代码只表达“导航栏背景应该透明”,并不能保证 Flutter Surface 已经画进导航栏区域。更麻烦的是,如果它只在 Flutter 启动早期执行,还可能被后续的 Window 初始化覆盖。
把半透明当成完全透明
windowTranslucentNavigation 能改善部分旧设备的视觉效果,但替代不了 Android 10–16 的 edge-to-edge 和 WindowInsets 适配。在这台三星 Android 14 上,它只是把纯黑底变成深色半透明,页面虽然透出来了,上面仍盖着一层系统遮罩。
所以它不是没生效,只是实现的并非“完全透明”。
同时加入所有沉浸式 Flag
LAYOUT_FULLSCREEN、FULLSCREEN、HIDE_NAVIGATION、IMMERSIVE_STICKY 各自解决的问题不同。为了消掉底部黑条把它们全部打开,很可能顺手隐藏状态栏或系统导航,又制造出新的交互问题。
这轮单变量验证最后没有用到任何沉浸式 Flag,因此我没有保留这些旧标志。
忽略三键导航
手势导航和三键导航走的是两套视觉策略。Android 15 的手势导航栏默认透明,三键导航却常常带着一层半透明保护色。要不要关闭 isNavigationBarContrastEnforced,得看页面背景和按钮是否仍然清晰,不能只凭手势导航的截图决定。
修复后的回归验证清单
系统栏在一张静态截图里正常,不代表问题真的结束了。切一次前后台、弹一次键盘,甚至换成三键导航,黑条都可能回来。发布前我会至少跑过这些场景:
- 冷启动和热启动。
- App 切到后台再回来。
- 打开并关闭系统权限弹窗。
- 弹出和收起软键盘。
- 浅色页面和深色页面。
- 手势导航和三键导航。
- Android 14、Android 15,以及当前 target SDK 对应的最新系统。
- 至少一台 Pixel 或接近原生系统的设备,以及一台目标用户常见的厂商设备。
- 使用非黑、非白的明显背景色,确认底部显示的是页面背景,而不是系统生成的相近颜色。
想确认究竟是哪项配置生效,还是要回到 A/B:同一台设备、同一组条件,每次只改变一个属性或调用时机,再对比截图、Surface 尺寸和窗口状态。
总结
这条黑底看起来只是一个颜色问题,真正查下去却牵出了 Flutter Surface、Android Window 和厂商 SystemUI 三层关系。
对我最有用的结论有四条:
SystemUiMode.edgeToEdge只负责让 Flutter Surface 铺到底;在这台三星 Android 14 上,真正绘制黑底的是独立的NavigationBar0Surface。- 黑色、半透明和完全透明是三种不同状态。用白色页面凭肉眼判断,很容易把后两者混在一起。
- 单变量实验最终指向了写入时机:
navigationBarColor = Color.TRANSPARENT放在onCreate()会被覆盖,窗口获得焦点后再写才稳定。 - Android 15、16 的方向已经变成默认甚至强制 edge-to-edge。新系统更该关心 Insets;
windowTranslucentNavigation只适合作为旧系统的降级手段。
这次排查最大的提醒,不是又记住了一行原生代码,而是别掉进“再加一个透明属性试试”的循环。先看页面有没有铺满,再看是谁画出了眼前那层颜色,最后把属性和调用时机拆开验证,通常比堆配置快得多。