本文首发于公众号:移动开发那些事Flutter GetX 核心坑及架构选型与可替换性方案
适用版本:本文代码与行为描述基于
GetX4.6 / 5.0 分支验证,不同小版本的Bindings、fenix、Workers行为略有差异,实际接入前建议以所在项目的版本为准。
0 背景
GetX 作为 Flutter 的轻重量级框架,提供了状态管理、路由管理、依赖注入、国际化、主题切换、网络请求等一整套能力。说它 轻,是因为上手快、开发效率高;说它重,是因为功能太多、模块之间耦合也重。
很多做创新或创业的公司(包括笔者现在所在的公司),为了追求快,直接把
GetX当成万能开发框架来用——确实能快速把应用搭起来,但到了中后期,框架的弊端就开始暴露:不少模块存在「这种那种」的坑,一不小心就掉进去,后期定位和修复的成本都不小。
本文主要做两件事:
-
讲坑:从状态管理、依赖注入、路由导航这三大模块,聊一聊在开发阶段如何规避踩坑;
-
讲架构:从架构选型和框架可替换性设计的角度,探讨如何在不牺牲开发速度的前提下,给后续的维护留一条退路。
1. 状态管理
1.1 响应式变量(.obs)误用
一句话症状: 改了 RxList 里对象的字段、或者 Rx<自定义对象> 的内部属性,UI 纹丝不动;或者反过来,所有变量无脑 .obs,白白增加监听开销。
核心坑点: GetX 的响应式是基于引用变更触发的,改内部字段并不会通知外界。
举个反面例子:
class User {
String name;
User(this.name);
}
final RxList<User> userList = <User>[User("张三")].obs;
final Rx<User> user = User("李四").obs;
// [反例] 修改内部属性,UI 不更新
userList[0].name = "张三改名字了";
user.value.name = "李四也改名字了";
// [反例] 所有变量都加 .obs,没必要
final count = 0.obs; // 用于 UI 显示,合理
final internalNum = 0.obs; // 仅内部计算,不合理
解决方案:
-
内部属性修改后,要么手动调用
refresh(),要么替换整个对象 / 列表(推荐后者,贴近不可变数据思想,更稳); -
只给需要驱动
UI更新的变量加.obs,内部逻辑变量用普通Dart类型。
// [推荐1] 手动 refresh()
userList[0].name = "张三改名字了";
userList.refresh();
// [推荐2] 替换整个对象 / 列表(首选)
userList[0] = User("张三改名字了");
user.value = User("李四改名字了");
// [推荐3] 合理使用 .obs
final count = 0.obs; // UI 显示
final internalNum = 0; // 仅内部计算
其他补充:Rx<List<T>> vs RxList<T>
这俩看着像,行为差得很远:
// RxList:add / remove 会触发更新
final RxList<int> a = <int>[].obs;
// Rx<List>:add 不触发,必须 b.value = [...]
final Rx<List<int>> b = Rx<List<int>>([]);
真出现「列表 add 没反应」的时候,先检查是哪种类型。
1.2 Obx 与 GetBuilder 混用
一句话症状: 要么 UI 不更新,要么 update() 调了也没反应。
核心坑点: 两者的触发机制完全不同:
-
Obx:依赖.obs变量的「读」来建立监听,不需要update(); -
GetBuilder:依赖手动update()触发,不会监听.obs。
// [反例1] Obx 里调用 update(),多余且无效
Obx(() {
controller.update();
return Text("${controller.count.value}");
});
// [反例2] GetBuilder 依赖 .obs,指望它自动更新 —— 没反应
GetBuilder<CounterController>(
builder: (controller) {
return Text("${controller.count.value}");
},
);
解决方案(记一句就够):
-
高频实时更新(计数器、流式数据)用
Obx; -
受控更新(按钮点击 / 表单提交)用
GetBuilder。
// [推荐1] Obx
Obx(() => Text("计数器:${controller.count.value}"));
// [推荐2] GetBuilder,需要手动调用update方法更新
GetBuilder<CounterController>(
builder: (controller) => Text("计数器:${controller.count}"),
);
class CounterController extends GetxController {
int count = 0;
void increment() {
count++;
update(); // 手动触发 GetBuilder
}
}
1.3 Obx 的「短路」陷阱
一句话症状: 条件表达式里有 .obs 变量,但被前面的条件短路掉,运行时报 [Obx] improperly used 一类的错。
核心坑点: Obx 在首次构建时通过「实际读过哪些 Rx 变量」来建立依赖。如果第一次构建走的分支没有读到任何 .obs,GetX 会认为这个 Obx 是错误使用,直接抛异常。
RxBool isUpdate = false.obs;
bool isBeta = false;
Obx(() {
// 当 isBeta=false 时,isUpdate.value 根本没被读
// GetX 会认为这个 Obx 没有依赖任何 Rx 变量 —— 报错
if (isBeta && isUpdate.value) {
return Container();
}
return const Text('kk');
});
解决方案:
- 把
.obs的读取放在条件的左侧,或在进入条件前主动读一次:
// [推荐1] 调整顺序,保证 .obs 被读到
Obx(() {
if (isUpdate.value && isBeta) {
return Container();
}
return const Text('kk');
});
// [推荐2] 先显式读一次,建立依赖
Obx(() {
final updating = isUpdate.value; // 建立依赖
if (isBeta && updating) {
return Container();
}
return const Text('kk');
});
1.4 Obx 粒度与嵌套
一句话症状: 一个页面只有一个 Obx 包整屏,改一个小字段整棵 widget 树重建,卡顿 + 性能浪费。
核心坑点: Obx 的重建范围就是它包裹的 widget 子树。粒度越粗,重建成本越高;而外层 Obx 里嵌套内层 Obx,外层更新时内层也会跟着重建,反而起不到隔离效果。
// [反例] 一个 Obx 包整个页面
Obx(() {
return Column(children: [
Text("名字:${controller.name.value}"),
Text("年龄:${controller.age.value}"),
ExpensiveChart(data: controller.chartData.value), // name 变了图也重建
]);
});
解决方案: 把 Obx 下沉到**真正依赖响应式变量的最小 widget**上:
Column(children: [
Obx(() => Text("名字:${controller.name.value}")),
Obx(() => Text("年龄:${controller.age.value}")),
Obx(() => ExpensiveChart(data: controller.chartData.value)),
]);
原则:能在叶子节点做的 Obx,绝不放到根节点。
1.5 Workers(ever / debounce / interval …)未释放
一句话症状: 页面退出后,控制台还在打印日志;或者两次进入后触发两次回调,叠加放大。
核心坑点: ever / everAll / once / debounce / interval 会返回一个 Worker 对象,必须在 onClose 里手动 dispose,否则就是一个永不过期的监听器。
// [反例]
class HomeController extends GetxController {
final count = 0.obs;
@override
void onInit() {
super.onInit();
// 没持有 Worker,释放不掉
ever(count, (v) => print("count=$v"));
}
}
// [推荐]
class HomeController extends GetxController {
final count = 0.obs;
late final Worker _countWorker;
@override
void onInit() {
super.onInit();
_countWorker = ever(count, (v) => print("count=$v"));
}
@override
void onClose() {
_countWorker.dispose();
super.onClose();
}
}
这条和后面 5.2 的 IStateProvider.listen 也有关系——抽象层一定要暴露取消订阅的能力,不然这个坑会一路被带到封装层里。
2. 依赖注入
2.1 Get.lazyPut 使用不当
一句话症状: 注入之后 Get.find() 找不到;或者页面出入一次之后再进来,Controller 被销毁了直接报错。
问题表现: 用 Get.lazyPut 注入 Controller,首次进入能用,返回再进入就 controller not found。
// [反例] 未加 fenix,路由返回后实例被销毁
Get.lazyPut(() => HomeController());
final controller = Get.find<HomeController>(); // 第二次进入时报错
初步方案: 加 fenix: true,让实例销毁后能按需重建。
Get.lazyPut(() => HomeController(), fenix: true);
final controller = Get.find<HomeController>();
但这个方案并不总是够用,特别是当你快速进入 / 退出同一个页面、且参数不同时,很有可能拿到的是上一次参数初始化的那个实例。
进阶方案: 用 tag 区分实例,同一个类、不同 tag 就是不同实例。
Get.lazyPut(() => HomeController(), tag: 'A');
Get.lazyPut(() => HomeController(), tag: 'B');
final controllerA = Get.find<HomeController>(tag: 'A');
注意 tag 需要业务层自己管,通常会挂到路由参数或页面 key 上,并保证「注入 / 查找 / 销毁」三处使用同一个 tag。
2.2 全局 Get.put() 滥用
一句话症状: Controller 到处 Get.put,最后分不清谁是单例、谁该销毁、谁被谁覆盖。
// [反例]
Get.put(HomeController()); // 页面 1
Get.put(UserController()); // 页面 2
Get.put(HomeController()); // 页面 3,覆盖了之前的实例
初步方案: 用 Bindings 把依赖和路由生命周期绑到一起,只在当前路由生效,路由销毁实例也销毁。
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => HomeController(), fenix: true);
}
}
GetPage(
name: '/home',
page: () => HomePage(),
binding: HomeBinding(),
);
但为什么笔者最后放弃了 Bindings?
-
异步初始化对不齐:
dependencies()是同步执行的,Controller内部异步加载数据还没回来,UI已经开始渲染; -
快速
push同名页面tag冲突:连点按钮两次,两个实例抢同一个key,经常出现状态错乱; -
销毁时机难预测:
SmartManagement不同模式下,onClose被触发的时机差别很大,做资源释放时容易漏; -
嵌套路由 / 底部导航场景混乱:在
BottomNavigationBar+IndexedStack之类结构里,Binding不一定按直觉生效; -
测试不友好:
Binding把注入和路由耦在一起,单测很难单独构造依赖。
坑太多了,实在填不完,后来就改成在项目入口集中注入 + 抽象一层 DI 工具来管(见 5.3)。
2.3 依赖注入方法混淆
核心坑点: 分不清不同注入方法的生命周期差异,实例要么没如预期重建,要么没如预期复用。
关键区分:
-
Get.put():立即实例化,默认不重建,相当于全局单例; -
Get.putAsync():异步实例化,适合需要await的初始化; -
Get.lazyPut():首次Get.find()时才实例化,加fenix: true可重建; -
Get.create():每次find都创建一个新实例(适合多实例场景)。
项目层面的建议:统一用 Get.lazyPut(..., fenix: true) + 自定义 Injector 工具类,减少选择心智(见 5.3)。
Get.put(UserController()); // 立即创建 + 全局单例
Get.lazyPut(() => UserController(), fenix: true); // 懒加载 + 可重建
Get.create(() => UserController()); // 每次都新建
2.4 控制器生命周期坑
一句话症状:
onInit里Get.find拿不到另一个Controller;onClose不触发;BuildContext拿不到。
三个高频场景:
-
onInit里依赖别的Controller:如果两个Controller在同一个Binding里用lazyPut,它们都是首次find时才实例化,onInit里去Get.find<Other>很可能它还没被创建。- 方案:改成
Get.put明确先后顺序,或者把依赖挪到onReady。
- 方案:改成
-
onClose没触发:通常是被Get.put(permanent: true)或者被其他页面持有导致GetX不敢释放。- 方案:检查是不是有
permanent: true、是不是有地方把Controller引用挂到了外部长生命周期对象上。
- 方案:检查是不是有
-
onInit里访问context:onInit时widget还没挂载,Get.context可能为null。- 方案:需要
context的逻辑放到onReady,它在首帧之后触发。
- 方案:需要
3. 路由导航
3.1 路由栈管理混乱
一句话症状: 混用原生 Navigator 和 Get 路由,或者频繁 Get.offAll(),结果退不出、重复跳转、回退到奇怪的页面。
// [反例] 原生路由 + GetX 路由混用
Navigator.push(context, MaterialPageRoute(builder: (ctx) => DetailPage()));
Get.to(() => SettingPage());
// [反例] 频繁 offAll
Get.offAll(() => HomePage());
Get.offAll(() => DetailPage()); // 栈被清两次,无法回退
解决方案: 项目里统一用 GetX 路由 API,并明确语义:
Get.to/Get.toNamed:新增页面(可后退);Get.off/Get.offNamed:替换当前页面(不可回退到当前页);Get.offAll/Get.offAllNamed:清栈后跳转(登录 / 退登 / 切主账号)。
Get.to(() => DetailPage());
Get.off(() => SettingPage());
Get.offAll(() => LoginPage());
3.2 命名路由参数传递坑
一句话症状: 快速 push / pop 时 Get.arguments 错乱、previousRoute 拿到的不是期望值。
核心坑点: Get.arguments 是基于当前 Route 的,路由切换瞬间可能已经是下一个 Route 的 arguments;Web 端 hash / history 模式下,命名路由的深链接表现又和原生不完全一致。
实操建议:
-
避免在
Controller的onInit里直接读Get.arguments,改成通过Binding注入时传递构造参数; -
Web项目优先用Get.parameters(URL query)而不是Get.arguments(内存参数),刷新之后arguments会丢; -
如果路由参数复杂到要封装,干脆自定义一个
RouteArgs模型 +NavigatorUtil.push<T extends RouteArgs>(...),把GetXAPI藏在工具类里(见 5.3)。
4. 架构选型建议
4.1 按项目规模选型
核心原则: 结合项目规模、团队熟悉度和长期维护预期来选,不要盲目全量用 GetX。
-
小型项目 /
MVP/ 个人项目:直接用GetX全量(状态管理 + 依赖注入 + 路由),开发速度拉满; -
中大型项目 / 团队项目:建议拆分使用:
-
路由导航:可以继续用
GetX路由(不依赖context,API简洁); -
轻量状态:页面内局部状态(计数器、开关、下拉展开)用
Obx/GetBuilder; -
核心业务状态:用
Riverpod/Bloc,生命周期和测试都更可控; -
依赖注入:复杂场景推荐
get_it,单一职责、和状态管理解耦。
-
4.2 选型核心考量
几个维度的大致排序(仅作参考,具体以团队情况为准):
| 维度 | 排序 |
|---|---|
| 开发效率 | GetX > Riverpod > Bloc |
| 可维护性 | Bloc > Riverpod > GetX |
| 可测试性 | Bloc ≈ Riverpod > GetX |
| 学习曲线 | GetX < Riverpod < Bloc |
| 团队适配 | 小团队优先 GetX,大团队优先 Bloc / Riverpod |
5. 保留框架可替换性的实现方案
很多同学会担心:现在用了
GetX,以后想换Riverpod/Bloc,改动太大怎么办?其实只要做好抽象封装(这也是做为应用的架构师需要在选型时就考虑到的点),就能把替换成本压到很低,不用重构整个项目。
5.1 核心原则:依赖抽象,不依赖具体框架
这也是软件架构里最核心的原则之一(依赖倒置原则 DIP)。
核心思路: 把需要用的几个核心能力(状态管理、路由、依赖注入)做一层抽象封装,项目里只调用抽象接口,不直接用 GetX 的 API;后续替换框架时,只改封装层,不动业务代码。
接口设计的一个重要原则
- :只暴露业务概念(
state/update/listen/push/inject) - 不暴露框架概念(
Rx/Notifier/Cubit/Get.xxx)。 - 一旦框架专有词汇漏到接口上,抽象层就废了一半。
5.2 状态管理:抽象类 + 实现 + 工厂
// 步骤 1. 抽象接口(不依赖任何框架)
abstract class IStateProvider<T> {
T get state;
void updateState(T newState);
// 注意:listen 返回一个取消句柄,避免监听泄漏
VoidCallback listen(void Function(T) listener);
void dispose();
}
// 步骤 2. GetX 实现
class GetxStateProvider<T> implements IStateProvider<T> {
GetxStateProvider(T initialState) : _state = Rx<T>(initialState);
final Rx<T> _state;
final List<Worker> _workers = [];
@override
T get state => _state.value;
@override
void updateState(T newState) => _state.value = newState;
@override
VoidCallback listen(void Function(T) listener) {
final worker = ever(_state, listener);
_workers.add(worker);
return () {
worker.dispose();
_workers.remove(worker);
};
}
@override
void dispose() {
for (final w in _workers) {
w.dispose();
}
_workers.clear();
}
}
// 步骤 3. 工厂抽象 + GetX 工厂实现
abstract class StateProviderFactory {
IStateProvider<T> create<T>(T initialState);
}
class GetxStateProviderFactory implements StateProviderFactory {
@override
IStateProvider<T> create<T>(T initialState) =>
GetxStateProvider<T>(initialState);
}
// 步骤 4. 入口处只选一次具体工厂(可以再套个 Injector.put 变成全局单例)
StateProviderFactory stateProviderFactory = GetxStateProviderFactory();
// 步骤 5. 业务代码:只依赖抽象
final counter = stateProviderFactory.create<int>(0);
counter.updateState(1);
final cancel = counter.listen((v) => print("count=$v"));
// 页面销毁时:
cancel();
counter.dispose();
后续替换为 Riverpod:只需新增一个 RiverpodStateProvider + RiverpodStateProviderFactory,把入口那一行 stateProviderFactory = ... 换掉即可,业务侧 create / listen / updateState 都不用动。
5.3 路由 / DI 的同款套路
路由工具类:
class NavigatorUtil {
static Future<T?> push<T>(String route, {Object? arguments}) =>
Get.toNamed<T>(route, arguments: arguments);
static void pop<T>([T? result]) => Get.back(result: result);
static Future<T?> replace<T>(String route, {Object? arguments}) =>
Get.offNamed<T>(route, arguments: arguments);
}
// 业务侧
NavigatorUtil.push('/detail', arguments: DetailArgs(id: 123));
NavigatorUtil.pop();
依赖注入工具类:
class Injector {
static void put<T>(T instance, {String? tag}) =>
Get.put<T>(instance, tag: tag);
static void lazyPut<T>(T Function() factory, {String? tag, bool fenix = true}) =>
Get.lazyPut<T>(factory, tag: tag, fenix: fenix);
static T get<T>({String? tag}) => Get.find<T>(tag: tag);
static void delete<T>({String? tag}) => Get.delete<T>(tag: tag);
}
Injector.lazyPut(() => HomeController());
final controller = Injector.get<HomeController>();
后续要换成 get_it 或 Riverpod 的 Provider,只改 NavigatorUtil / Injector 内部,业务代码不用动。
5.4 GetX API → 封装 API 对照表
给团队沉淀一张对照表,比任何文档都好用:
| 业务场景 | 直接用 GetX | 封装后(业务层调用) |
|---|---|---|
| 创建响应式状态 | final c = 0.obs; | stateProviderFactory.create(0) |
| 更新状态 | c.value = 1; | provider.updateState(1) |
| 监听状态 | ever(c, fn) | provider.listen(fn) |
| 取消监听 | worker.dispose() | cancel()(listen 返回值) |
| 跳转页面 | Get.toNamed('/x') | NavigatorUtil.push('/x') |
| 返回 | Get.back() | NavigatorUtil.pop() |
| 替换当前页 | Get.offNamed('/x') | NavigatorUtil.replace('/x') |
| 注入实例 | Get.lazyPut(...) | Injector.lazyPut(...) |
| 获取实例 | Get.find<T>() | Injector.get<T>() |
铁律:业务层不允许直接出现 Get.xxx、.obs、Rx<T>、Worker 等 GetX 专有符号。
5.5 抽象的副产品:可测试性
这一层抽象真正的高价值不是以后能换框架,而是立刻就能给业务逻辑写单元测试。
// 测试用的 Mock 实现
class MockStateProvider<T> implements IStateProvider<T> {
MockStateProvider(this._state);
T _state;
final List<void Function(T)> _listeners = [];
@override
T get state => _state;
@override
void updateState(T newState) {
_state = newState;
for (final l in _listeners) l(newState);
}
@override
VoidCallback listen(void Function(T) listener) {
_listeners.add(listener);
return () => _listeners.remove(listener);
}
@override
void dispose() => _listeners.clear();
}
// 业务类只依赖 IStateProvider
class CartLogic {
CartLogic(this.count);
final IStateProvider<int> count;
void addOne() => count.updateState(count.state + 1);
}
// 单测里用 Mock 实现,零 GetX 依赖
test('addOne 会把数量 +1', () {
final cart = CartLogic(MockStateProvider(0));
cart.addOne();
expect(cart.count.state, 1);
});
相比直接用 GetX 在测试里 mock Rx<T> 或 Controller,这种方式几乎没有心智负担。
5.6 抽象层的代价
抽象不是免费的,要承认它的代价:
- 心智成本上升:团队成员要多记一层
API,Code Review里要额外检查; - 功能裁剪:抽象接口通常只能覆盖
GetX能力的子集,特殊能力(比如everAll、interval)要专门设计扩展口; - 封装漏洞:总有人图省事直接
import 'package:get/get.dart';,封装就破掉了。
最后一点最常见,用工程手段兜底,而不是靠自觉:
# analysis_options.yaml
analyzer:
errors:
# 禁止业务目录直接依赖 get 包
depend_on_referenced_packages: error
# 推荐配合 custom_lint / import_lint 之类的包
# 在 lib/features/** 下禁止 import 'package:get/get.dart';
# 只允许 lib/core/getx_impl/** 使用
这样即使有人偷懒,CI 也会直接挡下来。
5.7 团队编码规范
抽象设计做得再好,也需要规范来兜底:
- 业务代码禁止直接使用
GetX的静态方法(Get.to/Get.find/.obs等),全部通过NavigatorUtil/Injector/IStateProvider调用; Controller/Service基类不直接继承GetxController,封一个自己的BaseController,未来换框架只改基类;Worker/ 监听订阅一律要在onClose/dispose里释放,在Code Review里作为硬性检查项;- 用
lint规则把第 1 条固化进CI,不要指望自觉。
6 总结
GetX 不是一个「不好」的框架,但它的定位决定了:越往后期、越大型,它就越需要被「约束着用」。
- 三大模块的坑,本质都是两个根因:
-
静态全局
API让生命周期不可控、 -
过度封装的魔法让问题难以排查。
-
规避方法就是分清场景、规范用法、该释放的一定释放。
-
架构选型按项目规模和团队情况选就好:小项目全量用
GetX、中大型项目拆开用、预期长期维护的项目一定要在业务层和框架层之间留一道抽象。 -
可替换性设计的最大收益并不是「以后真的换框架」,而是让业务逻辑可测试、让团队对框架有掌控感——这两点,才是让项目走得远的底子。
7 参考
GetX官方仓库issue区:很多坑(包括本文提到的Bindings/Obx/Worker)都能搜到对应讨论:github.com/jonataslaw/…Riverpod官方文档 & 从其他框架迁移指南:riverpod.dev/flutter_bloc官方文档:bloclibrary.dev/get_it(轻量DI):pub.dev/packages/ge…custom_lint/import_lint:用来在CI中约束业务层不得直接import 'package:get/get.dart'。