写在前面:这不是一篇中立的对比文章,这是一篇事后复盘。我在公司的多个生产项目里深度使用了 GetX,然后花了大量时间在填它挖的坑。如果你正在技术选型,希望这篇文章能帮你少走一些弯路。
一切的开始:GetX 真的很香
说实话,GetX 在我第一次接触 Flutter 时给我留下了极好的印象。
不需要 BuildContext,直接 Get.to() 跳页面;不用写 InheritedWidget,直接 GetxController 管状态;依赖注入?Get.put() 一行搞定。对于一个从其他生态转过来的开发者来说,GetX 简直像是 Flutter 世界里的"万能胶"——把所有烦人的东西都粘在一起,开箱即用,上手极快。
所以我在项目里大量使用了它。Controller 继承 GetxController,页面里 Get.find<XxxLogic>() 随处调用,路由用 Get.toNamed(),弹窗用 Get.dialog(),依赖用 Get.put() 注册……
然后,问题开始慢慢浮出水面。
问题一:Get.find 不是"依赖注入",它是"全局变量换了个马甲"
我项目里有大量这样的代码:
class SomeDetailPage extends StatelessWidget {
final SomeLogic logic = Get.find<SomeLogic>();
final SomeState state = Get.find<SomeLogic>().state;
SomeDetailPage({Key? key}) : super(key: key);
// ...
}
乍一看没问题,但你有没有想过——这个 Get.find<SomeLogic>() 是在 构造函数里 执行的?这意味着在这个 Widget 被实例化的那一刻,SomeLogic 必须已经在 GetX 的全局容器里注册好了。如果没有注册,直接崩溃。
更麻烦的是,你没办法通过构造函数传入一个 mock,这让单元测试和 Widget 测试变得极其痛苦。你没办法孤立地测试这个 Widget,因为它对全局容器有隐式依赖。
真正的依赖注入,是把依赖从外部传进来。而 Get.find 做的事情,本质上就是一个全局 Map 的查找,只是包了一层类型安全的外壳而已。
问题二:Controller 注册时机是一个隐形的定时炸弹
我在项目里实际遇到了这样的代码,最开始我以为是自己写的有问题,后来才意识到这是 GetX 设计本身带来的:
void someMethod() {
if (Get.isRegistered<AnotherLogic>()) {
try {
Get.find<AnotherLogic>().doSomething();
} catch (e) {
Future.delayed(const Duration(milliseconds: 100), () {
if (Get.isRegistered<AnotherLogic>()) {
Get.find<AnotherLogic>().doSomething();
}
});
}
}
}
注意看——这里有 isRegistered 检查,有 try-catch,还有 Future.delayed 兜底。为什么会写成这样?
因为 GetX 的 Controller 注册时机和 Widget 生命周期是分离且难以预测的。当 A 的 onInit 被调用时,B 可能还没注册进去。两个 Controller 之间相互依赖时,你没有一个可靠的方式来保证顺序,只能靠这种"等一会儿再试"的 hack。
这种代码一旦出现,就说明你的架构里有一个无法被类型系统或编译器检测到的隐患——一个随时可能因为时序问题而爆炸的地雷。
问题三:路由系统和 Flutter 原生 Navigator 的双轨并行
GetX 有自己的一套路由管理,Get.back()、Get.to()、Get.off(),这套 API 背后维护着 GetX 自己的导航栈。
问题在于,Flutter 本身也有一套 Navigator 栈。当你混用了 showDialog、showBottomSheet 这类原生方法,或者使用了某些第三方 UI 库,两套栈就会出现不同步的情况。
最典型的场景:底部弹出一个 BottomSheet,用户点击关闭,调用 Get.back()——结果关掉的不是 BottomSheet,而是后面的页面。因为 GetX 的栈以为当前最顶层是那个页面,而 Flutter 的 Navigator 知道顶层是 BottomSheet。
这类 bug 极难稳定复现,在测试阶段往往发现不了,偏偏在生产环境的某些特定操作路径下必现。而且一旦出现,表现就是页面凭空消失,用户一脸懵逼,你看日志也找不到任何异常。 吐槽: 我想你应该能体会到这个问题第一次出现的时候, 查遍了日志和测试人员一起反复的测试都无发复现, 但是生产人员却一直在提这个Bug的感受吗?
问题四:permanent: true 的幽灵
GetX 提供了 permanent 参数,让 Controller 在整个 App 生命周期内不被销毁:
Get.put(SomeService(), permanent: true);
这本来是用来处理全局单例服务的。但在实际开发中,这个参数很容易被滥用,或者说——在依赖关系复杂起来之后,你不得不把很多 Controller 标记为 permanent,因为你不知道它会在什么时候被 GetX 自动销毁。
结果就是:一堆"应该随页面销毁"的 Controller 变成了全局常驻对象,它们持有的资源(Stream 订阅、数据库连接、定时器……)永远不会被释放。Crashlytics 上的内存增长曲线会告诉你,你的 App 在连续操作几十分钟后内存占用会不断攀升。
GetX 的自动销毁机制听起来很美好,但它的触发条件是"当没有任何 Widget 依赖这个 Controller 时",这个判断本身在复杂页面嵌套下就很不可靠。
问题五:维护风险
这一点我觉得是最需要认真对待的。
GetX 把路由、状态管理、依赖注入、网络请求、国际化、主题、工具类……几乎所有东西都打包在一个包里。这种"大一统"的设计本身就是一种风险——你对一个生态如此深度绑定。
更重要的是,GetX 从始至终基本上是一个人在维护。不是 Google,不是 Flutter 团队,不是一个活跃的开源社区——是一个人。Issues 堆积,PR 几个月无回应,这在 GitHub 上都是公开可查的事实。
当你的项目依赖于一个可能随时停止维护的库来管理它的路由、状态和依赖注入,你承担的技术债务比你想象的要重得多。
为什么是 flutter_bloc + GetIt?
迁移之后,我选择了这个组合,说说我的理由。
flutter_bloc 的核心优势是可预测性。每一次状态变化都是显式的 Event → State 流转,你可以在任何时间点知道当前的状态是什么,是怎么来的。Bloc 天然适合单元测试,因为它就是一个接收输入、产生输出的函数,不依赖任何全局状态。bloc_test 提供的 DSL 让测试写起来非常顺手。
GetIt 是一个纯粹的服务定位器(Service Locator),它只做一件事:依赖注入。它不碰路由,不碰状态,就是一个类型安全的全局容器。与 injectable 搭配使用时,可以通过注解自动生成注册代码,极大减少样板代码。最重要的是,GetIt 是一个人们可以放心依赖的、久经考验的库,有大量大型项目在生产中使用。
路由方面我用回了原生 Navigator 2.0 或者 go_router——Flutter 官方出品,跟着 Flutter 一起更新,稳定性有保证。
一些真心话
我不是说 GetX 没有价值, 现在公司多数的APP项目还是在使用它。它降低了 Flutter 的入门门槛,让很多初学者能快速搭起一个能跑的应用,这是实实在在的贡献。
但有一句话我觉得挺有道理:GetX 给了你一把能很快建起房子的电动工具,但这把工具的设计,让你在建的过程中很难检查地基有没有问题。
当项目还小的时候,GetX 的问题都能被"快速开发"的效率掩盖住。等项目大了,屏数多了,逻辑复杂了,那些被掩盖的问题就会以各种奇怪的方式冒出来——路由乱跳、状态不同步、内存上涨、测试无法写……
迁移是痛苦的,但值得。 推荐一个网站: 里面的文章深受启发, 需要翻墙偶😯 medium