一个 Flutter Loading 关不掉的诡异 Bug:从 showDialog 的竞态说起

typescript 复制代码
## 写在前面

前段时间排查了一个非常隐蔽的线上问题:一个 Loading 弹窗在特定路径下**永远关不掉**,转圈一直挂在屏幕上,还和后续弹出的其他浮层叠在一起。更诡异的是,它只在一小部分用户身上复现,绝大多数人完全正常。

最终定位到,这不是业务逻辑的锅,而是 Flutter `showDialog` 一个容易被忽略的特性------**它的 `builder` 不是同步执行的**------在极端时序下引发的**竞态(race condition)**。

这篇文章完整复盘这个 Bug 的现象、根因和修复过程,并在最后封装一个不会再踩这个坑的通用 Loading 组件。文中代码均可直接复制运行。

---

## 一、现象

某个操作需要先弹一个「加载中,请稍候」的 Loading,等一个接口返回后再决定下一步。问题表现为:

- **Loading 不消失**:接口早已返回、业务逻辑也走完了,转圈却一直在,怎么都关不掉;
- **Loading 与后续浮层并存**:Loading 没关掉,后面的浮层照常弹出,两者叠在一起;
- **只有少数用户复现**:大多数用户完全正常。

打点后,日志里最关键的是这三行(时间戳保留,便于看时序):

```
20:11:49.332  dismiss 被调用,此时 route == null
20:11:49.332  回退到旧的 dismiss 逻辑,同样没有生效
20:11:51.558  Loading 的 route 才真正被捕获到
```

**关闭动作比 route 被捕获早了整整 2.2 秒。** 到这里方向就清楚了:这是一个 push(弹出)和 dismiss(关闭)之间的**竞态**------我们试图关闭一个「还没完全准备好」的弹窗。

---

## 二、根因:`showDialog` 的 builder 在「下一帧」才执行

### 2.1 先看 `showDialog` 到底做了什么

简化后,`showDialog` 的核心就是往 Navigator 里 push 一个 `DialogRoute`:

```dart
Future<T?> showDialog<T>({ required WidgetBuilder builder, ... }) {
  return Navigator.of(context).push(DialogRoute<T>(
    context: context,
    builder: builder,   // 注意:这个 builder 不是立刻执行的
    ...
  ));
}
```

这里有三个容易被忽略的事实:

1. **push 是同步的**:调用 `showDialog` 那一刻,`DialogRoute` 已经进入 Navigator 栈,并且成为栈顶。
2. **builder 是异步的**:route 的内容(真正的 widget 树)要等**下一帧**才会 build 出来。
3. **`showDialog` 不把 route 交给你**:它只返回一个 `Future`,而且这个 Future 要等**弹窗关闭**时才 complete------你无法通过它拿到 route 的引用。

第 2、3 点结合起来,就埋下了隐患。

### 2.2 一个典型的「靠 context 关闭」的封装

很多项目会这样封装一个 Loading:Widget 自己持有一个 `BuildContext`,关闭时用它来 pop。

```dart
class LoadingWidget {
  BuildContext? _context;
  set buildContext(BuildContext c) => _context = c;   // 在 builder 里被调用

  void dismiss() {
    final ctx = _context;
    if (ctx == null) return;                 // builder 没跑时 _context 为 null,直接返回
    if (!ctx.mounted) return;
    if (Navigator.canPop(ctx)) Navigator.of(ctx).pop();
  }
}

// 封装:用 showDialog 弹出
LoadingWidget showLoading(BuildContext context) {
  final loading = LoadingWidget();
  showDialog(
    context: context,
    builder: (ctx) {
      loading.buildContext = ctx;   // 只有 builder 执行了,_context 才有值
      return loading;
    },
  );
  return loading;                   // 返回时 builder 还没跑,_context 仍是 null
}
```

问题的种子就在这里:**`showLoading` 返回的那一刻,`_context` 还是 `null`**,因为 builder 要等下一帧。

### 2.3 竞态是如何发生的

来看调用方一段再普通不过的代码:

```dart
final loading = showLoading(context);        // push 了,但 builder 还没跑
final data = await fetchSomething();          // 如果这一步「瞬间」返回......
loading.dismiss();                            // 就会抢在 builder 首帧之前执行
```

把时间线摊开:

```
t0        showLoading 返回          _context == null
t0 + ε    fetchSomething 瞬间返回    _context == null(builder 仍未轮到)
t0 + ε    dismiss()                 _context == null → 直接 return,什么都没做  ✗
t1(下一帧)builder 执行             _context 终于被赋值 ------ 但已经没人来关它了
          → Loading 永久停留在屏幕上
```

关键在于 `fetchSomething()` **瞬间返回**。正常情况下它是一次真实网络请求,耗时几百毫秒,这段时间足够 builder 跑完、`_context` 早就绪,`dismiss` 自然生效------**坑被真实耗时掩盖了**。

而在出问题的路径里,这个请求**命中了缓存**(或因某种重放逻辑几乎立即返回),把 `show` 和 `dismiss` 压进了同一个微任务窗口,`dismiss` 抢跑,竞态暴露。

### 2.4 为什么只有少数用户复现

这也解释了「为什么大多数人正常」:**能否复现,取决于 `show` 与 `dismiss` 之间那次异步操作的真实耗时**。

- 走正常网络请求的用户:有几百毫秒间隔,builder 早跑完 → 正常关闭;
- 命中缓存 / 走了某条「瞬时返回」快路径的用户:间隔趋近于 0 → 撞上竞态 → Loading 卡死。

它跟用户身份、权限本身无关,纯粹是**时序**问题。这类 Bug 的典型特征就是「难复现、看似玄学」,而根子往往是一个被忽略的异步边界。

---

## 三、修复:手动创建 DialogRoute + 同步 push

既然坑在于「拿不到 route,只能靠 builder 异步 set 的 context」,那修复思路就很直接:

> **别依赖 builder 那一帧------自己把 route `new` 出来,push 的同时就攥在手里。**

`showDialog` 内部无非也是 new 一个 `DialogRoute` 再 push,我们把这个过程「摊开」自己做,就能在函数返回时**同步**拿到 route 引用:

```dart
LoadingWidget showLoading(
  BuildContext context, {
  String title = '加载中,请稍候...',
  bool barrierDismissible = false,
  bool useRootNavigator = true,
  Color barrierColor = Colors.black54,
  ValueChanged<ModalRoute<dynamic>>? onRouteCaptured,   // 同步回传 route
}) {
  final loading = LoadingWidget(title: title);

  final navigator = Navigator.of(context, rootNavigator: useRootNavigator);
  // showDialog 内部会做 InheritedTheme.capture,手动 push 必须自己补上,
  // 否则弹窗会丢失主题(字体、颜色等)。
  final capturedThemes =
      InheritedTheme.capture(from: context, to: navigator.context);

  final route = DialogRoute<dynamic>(
    context: context,
    barrierDismissible: barrierDismissible,
    barrierColor: barrierColor,
    themes: capturedThemes,
    builder: (ctx) {
      loading.buildContext = ctx;
      return loading;
    },
  );

  // route 已经 new 出来,此刻同步回传给调用方------不必等 builder
  onRouteCaptured?.call(route);

  navigator.push(route);   // 同步入栈
  return loading;
}
```

调用方拿到 route 后,用 `removeRoute` 精准关闭:

```dart
ModalRoute<dynamic>? loadingRoute;
final loading = showLoading(
  context,
  onRouteCaptured: (route) => loadingRoute = route,   // 同步就拿到了
);

void dismissLoading() {
  final route = loadingRoute;
  if (route != null && route.isActive && route.navigator != null) {
    route.navigator!.removeRoute(route);   // 精准移除指定 route,无关它在栈第几层
    loadingRoute = null;
    return;
  }
  loading.dismiss();   // 兜底
}
```

无论 `dismissLoading` 多快被调用,`loadingRoute` 都**已经是非空**的,竞态窗口彻底消失。

---

## 四、关闭 Loading 的三种方式对比

修复过程中,其实有三种关闭手段可选,各有优劣,值得单独拉出来讲清楚。

| 方案 | 消除竞态 | 误关业务页风险 | 说明 |
|---|:---:|:---:|---|
| `loading.dismiss()`(靠 `_context`) | ✗ | 低 | builder 未执行时 `_context == null`,直接 no-op,Loading 卡死 |
| `Navigator.of(context).pop()` | ✓ | ⚠️ 有 | pop 弹的是**栈顶**,不区分是不是自己 |
| `navigator.removeRoute(route)` | ✓ | 无 | 精准移除**指定 route 对象**,无关它在栈第几层 |

这里要特别说一下 `Navigator.of(context).pop()`。

它其实**也能解决竞态**:因为 `showDialog` 的 push 是同步的,route 一调用就成了栈顶,所以紧接着 `pop()` 就能把它弹掉,绕过了「等 builder 捕获 route」的异步链。这也是为什么有人会发现「showDialog 之后直接 pop 就好了」。

**但 `pop()` 有个致命前提:关闭那一刻,Loading 必须仍是栈顶。** 它弹的是当前栈顶,并不检查这是不是「你的」Loading。一旦在你调用 `pop()` 之前,Loading 已经被别的路径关掉了(比如用户手势返回、或另一处 dismiss),栈顶就变成了**业务页面**,这一 `pop()` 会把业务页面误关掉。

我们的项目里就真实发生过这种「误退出业务页」的事故,所以最终选择了 `removeRoute(route)`------它移除的是**你手里那个具体的 route 对象**,不管它此刻在栈的哪一层、是不是栈顶,都只精准抽掉它,绝不误伤。

**结论**:能拿到 route 引用时,优先用 `removeRoute(route)`;只有在你能 100% 保证「关闭时 Loading 一定是栈顶」的简单场景,才可以图省事用 `pop()`。

---

## 五、封装一个不会再踩坑的 Loading 组件

把上面的经验固化成一个通用组件 `SafeLoading`,核心特性:

1. **同步持有 route**:show 后可以立即 dismiss,不依赖 builder 那一帧;
2. **`removeRoute` 精准关闭**:不会误关业务页;
3. **dismiss 幂等**:多次调用安全;
4. **句柄式 API**:`show()` 返回一个句柄,`handle.dismiss()` 关闭,直观好用。

### 5.1 完整代码

```dart
import 'package:flutter/material.dart';

/// 一个不会踩「showDialog builder 异步」竞态坑的 Loading。
///
/// 用法:
/// ```dart
/// final loading = SafeLoading.show(context, text: '加载中...');
/// try {
///   await doSomething();
/// } finally {
///   loading.dismiss();   // 幂等,随时可调;即便瞬间 dismiss 也不会卡死
/// }
/// ```
class SafeLoading {
  SafeLoading._(this._route);

  final ModalRoute<void> _route;
  bool _dismissed = false;

  /// 弹出 loading,同步返回可控句柄。
  ///
  /// [text] 文案。
  /// [barrierDismissible] 点击遮罩是否关闭,默认 false。
  /// [barrierColor] 遮罩颜色。
  /// [useRootNavigator] 是否用根 Navigator(默认 true,覆盖在所有页面之上)。
  /// [builder] 自定义 loading 内容;不传则使用默认转圈 + 文案。
  static SafeLoading show(
    BuildContext context, {
    String text = '加载中,请稍候...',
    bool barrierDismissible = false,
    Color barrierColor = Colors.black54,
    bool useRootNavigator = true,
    WidgetBuilder? builder,
  }) {
    final navigator = Navigator.of(context, rootNavigator: useRootNavigator);
    // 手动 push 必须自己补 InheritedTheme,否则弹窗拿不到主题。
    final capturedThemes =
        InheritedTheme.capture(from: context, to: navigator.context);

    final route = DialogRoute<void>(
      context: context,
      barrierDismissible: barrierDismissible,
      barrierColor: barrierColor,
      themes: capturedThemes,
      builder: builder ?? (ctx) => _DefaultLoading(text: text),
    );

    // 关键:route 在此同步 new 出来,push 也是同步入栈,
    // 句柄立即持有 route,不依赖 builder 首帧。
    navigator.push(route);
    return SafeLoading._(route);
  }

  /// 关闭 loading。幂等:多次调用安全。
  ///
  /// 用 removeRoute 精准移除本 route,无关它当前在栈第几层,
  /// 绝不会误关业务页面。
  void dismiss() {
    if (_dismissed) return;
    _dismissed = true;
    if (_route.isActive && _route.navigator != null) {
      _route.navigator!.removeRoute(_route);
    }
  }

  /// 是否仍在显示。
  bool get isShowing => !_dismissed && _route.isActive;
}

class _DefaultLoading extends StatelessWidget {
  const _DefaultLoading({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    // MaterialType.transparency:避免透明弹窗下方的独立合成层(如内嵌 WebView)透出。
    return Material(
      type: MaterialType.transparency,
      // 拦截返回键:loading 期间默认不允许物理返回关闭。
      child: PopScope(
        canPop: false,
        child: Center(
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const SizedBox(
                  width: 32,
                  height: 32,
                  child: CircularProgressIndicator(strokeWidth: 3),
                ),
                const SizedBox(height: 12),
                Text(
                  text,
                  style: const TextStyle(
                    color: Color(0xFF75778E),
                    fontSize: 14,
                    fontWeight: FontWeight.w400,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

### 5.2 用法示例

**基础用法:**

```dart
final loading = SafeLoading.show(context, text: '加载中...');
try {
  final result = await api.fetch();
  // ... 处理 result
} finally {
  loading.dismiss();   // 无论成功失败都关;即便接口瞬间返回也不会卡死
}
```

**瞬间 dismiss(正是当初踩坑的场景,现在安全):**

```dart
final loading = SafeLoading.show(context);
loading.dismiss();   // 紧接着就关 ------ route 已同步持有,removeRoute 必生效
```

**自定义内容:**

```dart
final loading = SafeLoading.show(
  context,
  builder: (ctx) => const Center(
    child: Card(
      child: Padding(
        padding: EdgeInsets.all(24),
        child: Text('自定义 loading 内容'),
      ),
    ),
  ),
);
```

**异步回调中关闭(即使原 context 已失效):**

```dart
// dismiss 走的是 route.navigator.removeRoute(route),不依赖 context.mounted,
// 即使原页面 context 已重建/失效,照样能关。
final loading = SafeLoading.show(context);
someListener.onEvent = () {
  Future.delayed(const Duration(seconds: 1), () {
    // ... 后续逻辑
    loading.dismiss();
  });
};
```

### 5.3 与「靠 context 关闭」的旧实现对比

| 对比项 | 旧的 showDialog 封装 | SafeLoading |
|---|---|---|
| route 获取 | 靠 builder 异步 set context | 构造时同步 new,当场持有 |
| dismiss 依赖 | `_context != null && ctx.mounted` | `_route`(永远非空) |
| 瞬间 dismiss | no-op → 卡死 | `removeRoute` 生效 |
| 误关业务页 | 用 pop 有风险 | `removeRoute` 精准,无风险 |
| 多次 dismiss | 需自行加标志位 | 内置 `_dismissed` 幂等 |
| 主题传递 | showDialog 内部处理 | 手动 `InheritedTheme.capture` 补上 |

---

## 六、总结

`showDialog` 本身没有 Bug,但它有两个**隐含前提**:

1. 你不需要**同步**拿到 route 引用;
2. `show` 和 `dismiss` 之间有足够时间让 builder 跑完(哪怕只是一帧)。

绝大多数场景这两个前提都成立,所以它一直好用。但一旦你的代码里出现「**show 之后可能瞬间就 dismiss**」的路径(缓存命中、逻辑重放、同步回调等),第二个前提就被打破,竞态随之而来。

应对方式也很清晰:

- **需要同步拿到 route,或可能瞬间关闭** → 绕开 `showDialog`,手动 `new DialogRoute` + 同步 `push`,把 route 攥在自己手里;
- **关闭时** → 优先用 `removeRoute(route)` 而非 `pop()`,既消除竞态,又不会误关业务页;
- **别忘了** 手动 push 时补一句 `InheritedTheme.capture`,否则弹窗会丢主题。

把这些固化进一个 `SafeLoading` 组件,就能一劳永逸地绕开这个坑。希望这篇复盘能帮你在遇到「Loading 关不掉」这类玄学问题时,第一时间想到去查一查异步边界与时序。
```
相关推荐
一枚菜鸟_1 小时前
在 Flutter 项目里装 Skills:从 0 搭建 AI 辅助开发体系
flutter·ai编程
qq_246839758 小时前
记一次 Flutter 预热帧引起的初始化问题
android·flutter
心中有国也有家9 小时前
AtomGit Flutter 鸿蒙客户端:MoodStorage 的 CRUD 集成验证
android·服务器·flutter·华为·harmonyos
心中有国也有家9 小时前
AtomGit Flutter 鸿蒙客户端:错误处理与优雅降级策略
android·javascript·flutter·华为·harmonyos
西西学代码1 天前
Flutter---Text
flutter
左有道1 天前
用 Flutter 做一个更顺手的日历组件:calendarview_flutter
flutter
一枚菜鸟_1 天前
Flutter + Cursor 一天出 App?这 3 个坑让开发者多花了 3 小时
flutter·cursor
心中有国也有家1 天前
AtomGit Flutter 鸿蒙客户端:零外部资源的应用打包策略
android·javascript·flutter·华为·harmonyos
心中有国也有家1 天前
AtomGit Flutter 鸿蒙客户端:为 E-Brufen 注入情感节律的文案设计哲学
javascript·学习·flutter·华为·harmonyos