乐于分享
好东西不私藏

我从一个拥有 35 个页面的 App 中移除了 GetX.以下是它所掩盖的 4 个 Bug.

我从一个拥有 35 个页面的 App 中移除了 GetX.以下是它所掩盖的 4 个 Bug.

我在一个拥有 35 个页面的生产环境 App 中使用了 GetX。经过 8 个月后,导航栈开始出现崩溃,Crashlytics 中出现了内存泄漏,而单元测试在功能上变得完全无法实现。 迁移到 BLoC + GetIt 耗时 3 周。 以下是我追溯到的每一个由 GetX 导致的失败案例的“法医鉴定级”分析——这个框架替换了 Flutter 的导航器、状态管理、依赖注入和覆盖层(Overlay)系统。而这一切,都由一名开发者维护。不是 Google,不是 Flutter 团队,只有一个人。

1. 导航栈崩溃

承诺: “不需要 Context!随处调用 Get.back() 即可。”

现实: 当 BottomSheet(底部工作表)或异步覆盖层处于打开状态时,Get.back() 根本不知道“返回”意味着什么。 GetX 维护着一套完全独立于 Flutter 内部 Navigator 的导航栈。当你触发标准的系统对话框时,这两个栈就会出现不同步(Desync) 。当用户试图关闭一个 BottomSheet 时,Get.back() 经常会指向错误的路由。它要么静默失败,导致 Sheet 卡在屏幕上;要么直接退出了底层的路由页面,导致屏幕变黑。

这种静默失败模式在 Issue #1523 和 Issue #514 中被持续跟踪,开发者们被迫手动覆盖 preventDuplicates 参数或传递自定义标识符,仅仅为了强迫框架识别出正确的路由。

// ❌ 陷阱:GetX 导航栈不同步Get.bottomSheet(MyCustomSheet());  // 用户点击关闭。这经常会失败,或者弹出错误的屏幕。Get.back();
// ✅ 修复:使用标准 Navigator。路由行为可预测。showModalBottomSheet(  context: context,  builder: (context) => PopScope(    canPop: true,    child: MyCustomSheet(),  ),);// 确保弹出本地栈顶部的路由。Navigator.of(context).pop();

Get.back() 弹出的不是你认为的那个页面,而是 GetX 认为的那个页面。这两者根本不是一回事。

2. 全局状态陷阱

承诺: “只需一行 .obs,即可实现响应式状态!”

现实: 全局可变状态,且没有任何审计追踪(Audit Trail)。 Flutter 中规范的状态管理依赖于单向数据流(Unidirectional Data Flow) 。而 GetX 摧毁了这一点。通过允许你使用 Get.put() 和 Get.find() 在应用中的任何地方实例化和修改状态,它创建了一个全局可变的“状态图”。

任何 Widget、服务或后台 Isolate 都可以秘密地修改一个完全不相关的 UI 组件的状态——而无需通过共享的 Context(上下文)。当 Bug 出现时,没有状态变更的堆栈追踪(Stack Trace)可供参考。你就像在盲飞。

// ❌ 陷阱:全局状态变更,且完全无法追踪class PaymentController extends GetxController {  var isProcessing = false.obs;}// 在某个随机的 UI 文件深处,完全脱离了支付流程:Get.find<PaymentController>().isProcessing.value = true;// 状态在全局被修改。没有事件,没有日志,没有追踪。
// ✅ 修复:单向数据流。每一次状态变更都有据可查。class PaymentBloc extends Bloc<PaymentEvent, PaymentState> {  PaymentBloc() : super(PaymentInitial()) {    on<ProcessPayment>((event, emit) {      emit(PaymentProcessing());      // 这是修改状态的唯一途径。      // 所有的变更都流经同一个入口点。    });  }}

如果你想要确定性的资源回收(Cleanup)隔离的测试环境,你需要严格的分层。

如果任何 Widget 都能在任何地方修改任何状态,那你拥有的不是“状态管理”,而是一个拥有 35 个编辑者且没有版本记录的共享 Google 文档。

3. 隐藏依赖陷阱

承诺: “没有样板代码!随时随地使用 Get.find()。”

现实: 隐藏的依赖关系让单元测试变得根本无法实现。 依赖注入(DI)存在的意义,是为了让类能够显式声明它们运行所需的条件。GetX 完全绕过了这一点,充当了一个全局的服务定位器(Service Locator)。当一个类使用 Get.find() 时,它将依赖项隐藏在方法内部,而不是在构造函数中暴露出来。

当你尝试为单个类编写单元测试时,编译器会报错,因为那个隐藏的 Get.find() 调用期望整个 GetX 全局上下文都在运行。你无法在隔离环境下测试你的业务逻辑。

// ❌ 陷阱:隐藏的依赖class CheckoutService {  void processOrder() {    // 依赖关系被隐藏在方法内部。    // 测试这段代码需要模拟整个 GetX 定位器图表。    final api = Get.find<ApiService>();    api.submit();  }}
// ✅ 修复:构造函数注入。显式且易于测试。class CheckoutService {  final ApiService api;// 依赖关系是显式的。  // 在单元测试中传递 MockApiService 非常简单。  CheckoutService(this.api);  void processOrder() {    api.submit();  }}

如果你的类使用了 Get.find(),你拥有的就不是依赖注入,而是只有在你尝试编写测试时才会现形的“隐藏依赖”。

4. 内存泄漏引擎

承诺: “智能生命周期管理——自动销毁!”

现实: 控制器(Controller)在页面关闭后依然存活。流(Streams)保持开启。内存持续流失。

当你全局初始化一个控制器时,如果它被多个 Widget 引用,它往往会在路由关闭(Pop)后依然存活。如果该控制器包含一个活动的数据库流、一个监听器(Listener)或者缓存了一个 BuildContext,该对象就会一直留在堆(Heap)内存中。Dart 垃圾回收器(GC)无法回收它,因为 GetX 持有一个全局引用。

你的应用现在正处于静默内存流失状态,直到操作系统因内存溢出(OOM)而强行终止它。

这并非理论推测。如果你分析框架内部的 RouterReportManager,你会发现 GetPageRoute 在每次导航事件时都会创建一个新实例,导致内部 Map 的键值对不断增长。垃圾回收器根本无法清理它们。(参见官方仓库中关于 Obx 视图残留的 Issue #1854 和 Issue #3345。)

打开 DevTools(开发者工具),亲自做一下这个测试:

// ❌ 陷阱:静默内存泄漏class UserProfileController extends GetxController {  // 如果该控制器因全局引用而在路由关闭后依然存活,  // 这个 Firestore 流将永远保持开启状态。  final stream = FirebaseFirestore.instance      .collection('users')      .snapshots()      .listen((data) {});}
// ✅ 修复:与 Widget 生命周期绑定的确定性资源清理class UserProfileBloc extends Bloc<UserEvent, UserState> {  late final StreamSubscription _subscription;UserProfileBloc(UserRepository repo) : super(UserInitial()) {    _subscription = repo.userStream().listen((user) {      add(UserUpdated(user));    });  }  // 显式清理。无需猜测,没有全局引用。  @override  Future<void> close() {    _subscription.cancel();    return super.close();  }}

当你将 Get.put() 与 Get.lazyPut() 混合使用时,你的数据库流(Streams)会无限期地保持开启状态。

GetX 并不是真的自动销毁,它只是“自动假装”在销毁。你的 Crashlytics(崩溃日志分析工具)很清楚这两者的区别。

迁移路径

我在 3 周内将一个包含 35 个页面的应用从 GetX 迁移到了 BLoC + GetIt。以下是对应的替换方案映射表:

BLoC 的迁移工作量是最重的,因为每一个 .obs 变量都要转化为驱动状态的事件类(Event-driven state class)。但一旦大功告成,每一次状态变更都有事件日志可查,每一项测试都能在隔离环境中运行,而且每一个控制器都会在 Widget 树发出指令时销毁——而不是在 GetX “觉得”该销毁的时候。

停止在随机的文件里调用 Get.find()。停止迷信 Get.back() 会知道“返回”的真正含义。停止把你的导航栈交给一个维护着“平行宇宙”的框架。

如果你已经发布了基于 GetX 的应用,你的迁移路径就是 BLoC + GetIt。我花了 3 周时间处理了 35 个页面。我调试出的最后一个 Bug,就是 GetX 最后一次在“我到底在哪个页面”这件事上对我撒谎。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 我从一个拥有 35 个页面的 App 中移除了 GetX.以下是它所掩盖的 4 个 Bug.

猜你喜欢

  • 暂无文章