返回列表

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 蓝绿色。背景既不黑也不白,三种状态一下就分开了:

状态底部表现是否真正显示页面背景
黑色不透明底部是一整块纯黑区域
深色半透明能看到蓝绿色,但明显覆盖了一层深色遮罩部分显示
完全透明底部与页面主体保持连续的同一蓝绿色

三张图来自同一台设备、同一个页面,只改了导航栏的处理方式。这里不要盯着手势提示条看,真正要比较的是它周围那层背景是否还盖在页面上。

先别急着改颜色:这里其实有三层问题

屏幕底部看起来只有一小块,背后却混着三件事:

  1. 应用窗口是否延伸到系统导航栏区域。
  2. 系统导航栏背景是否透明。
  3. 手势条或三键导航图标应该使用深色还是浅色。

这三个问题彼此相关,却不是同一个开关。

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.edgeToEdgeSurface 全屏,但底部仍为黑色
再加入 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 edge-to-edge 与 Android 系统导航栏覆盖层的两层控制关系
Flutter 负责把页面铺到底,Android 负责让系统导航栏层透明。

两个层分别控制什么

回头看,黑条之所以绕,是因为绘制范围和系统栏颜色根本不在同一层控制。

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).bottomSafeArea 留出可交互区域。

完整方案里,哪些配置该留

最小方案解决了黑底,但实际项目往往还要照顾启动过渡、图标明暗和底部控件位置。下面这些配置各有用途,只是不该混成一包,然后把“碰巧透明了”当成结论。

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 里的 windowLightStatusBarwindowLightNavigationBar 也应该跟着页面实际背景走。系统切到夜间模式,不代表当前页面一定是深色。

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、原生层和插件的全部写入位置搜索 SystemChromenavigationBarColorwindowTranslucentNavigationWindowCompatWindowInsetsControllersystemUiVisibility
□ 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_FULLSCREENFULLSCREENHIDE_NAVIGATIONIMMERSIVE_STICKY 各自解决的问题不同。为了消掉底部黑条把它们全部打开,很可能顺手隐藏状态栏或系统导航,又制造出新的交互问题。

这轮单变量验证最后没有用到任何沉浸式 Flag,因此我没有保留这些旧标志。

忽略三键导航

手势导航和三键导航走的是两套视觉策略。Android 15 的手势导航栏默认透明,三键导航却常常带着一层半透明保护色。要不要关闭 isNavigationBarContrastEnforced,得看页面背景和按钮是否仍然清晰,不能只凭手势导航的截图决定。

修复后的回归验证清单

系统栏在一张静态截图里正常,不代表问题真的结束了。切一次前后台、弹一次键盘,甚至换成三键导航,黑条都可能回来。发布前我会至少跑过这些场景:

  1. 冷启动和热启动。
  2. App 切到后台再回来。
  3. 打开并关闭系统权限弹窗。
  4. 弹出和收起软键盘。
  5. 浅色页面和深色页面。
  6. 手势导航和三键导航。
  7. Android 14、Android 15,以及当前 target SDK 对应的最新系统。
  8. 至少一台 Pixel 或接近原生系统的设备,以及一台目标用户常见的厂商设备。
  9. 使用非黑、非白的明显背景色,确认底部显示的是页面背景,而不是系统生成的相近颜色。

想确认究竟是哪项配置生效,还是要回到 A/B:同一台设备、同一组条件,每次只改变一个属性或调用时机,再对比截图、Surface 尺寸和窗口状态。

总结

这条黑底看起来只是一个颜色问题,真正查下去却牵出了 Flutter Surface、Android Window 和厂商 SystemUI 三层关系。

对我最有用的结论有四条:

  1. SystemUiMode.edgeToEdge 只负责让 Flutter Surface 铺到底;在这台三星 Android 14 上,真正绘制黑底的是独立的 NavigationBar0 Surface。
  2. 黑色、半透明和完全透明是三种不同状态。用白色页面凭肉眼判断,很容易把后两者混在一起。
  3. 单变量实验最终指向了写入时机:navigationBarColor = Color.TRANSPARENT 放在 onCreate() 会被覆盖,窗口获得焦点后再写才稳定。
  4. Android 15、16 的方向已经变成默认甚至强制 edge-to-edge。新系统更该关心 Insets;windowTranslucentNavigation 只适合作为旧系统的降级手段。

这次排查最大的提醒,不是又记住了一行原生代码,而是别掉进“再加一个透明属性试试”的循环。先看页面有没有铺满,再看是谁画出了眼前那层颜色,最后把属性和调用时机拆开验证,通常比堆配置快得多。

延伸阅读