Flutter GetX 核心坑及架构选型与可替换性方案

5 阅读15分钟

本文首发于公众号:移动开发那些事Flutter GetX 核心坑及架构选型与可替换性方案

适用版本:本文代码与行为描述基于 GetX 4.6 / 5.0 分支验证,不同小版本的 BindingsfenixWorkers 行为略有差异,实际接入前建议以所在项目的版本为准。

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 ObxGetBuilder 混用

一句话症状: 要么 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 变量」来建立依赖。如果第一次构建走的分支没有读到任何 .obsGetX 会认为这个 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 Workersever / 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 控制器生命周期坑

一句话症状:

  • onInitGet.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 里访问 contextonInitwidget 还没挂载,Get.context 可能为 null

    • 方案:需要 context 的逻辑放到 onReady,它在首帧之后触发。

3. 路由导航

3.1 路由栈管理混乱

一句话症状: 混用原生 NavigatorGet 路由,或者频繁 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 / popGet.arguments 错乱、previousRoute 拿到的不是期望值。

核心坑点: Get.arguments 是基于当前 Route 的,路由切换瞬间可能已经是下一个 RouteargumentsWebhash / history 模式下,命名路由的深链接表现又和原生不完全一致。

实操建议:

  • 避免在 ControlleronInit 里直接读 Get.arguments,改成通过 Binding 注入时传递构造参数;

  • Web 项目优先用 Get.parametersURL query)而不是 Get.arguments(内存参数),刷新之后 arguments 会丢;

  • 如果路由参数复杂到要封装,干脆自定义一个 RouteArgs 模型 + NavigatorUtil.push<T extends RouteArgs>(...),把 GetX API 藏在工具类里(见 5.3)。


4. 架构选型建议

4.1 按项目规模选型

核心原则: 结合项目规模、团队熟悉度和长期维护预期来选,不要盲目全量用 GetX

  • 小型项目 / MVP / 个人项目:直接用 GetX 全量(状态管理 + 依赖注入 + 路由),开发速度拉满;

  • 中大型项目 / 团队项目:建议拆分使用:

    • 路由导航:可以继续用 GetX 路由(不依赖 contextAPI 简洁);

    • 轻量状态:页面内局部状态(计数器、开关、下拉展开)用 Obx / GetBuilder

    • 核心业务状态:用 Riverpod / Bloc,生命周期和测试都更可控;

    • 依赖注入:复杂场景推荐 get_it,单一职责、和状态管理解耦。

4.2 选型核心考量

几个维度的大致排序(仅作参考,具体以团队情况为准):

维度排序
开发效率GetX > Riverpod > Bloc
可维护性Bloc > Riverpod > GetX
可测试性BlocRiverpod > GetX
学习曲线GetX < Riverpod < Bloc
团队适配小团队优先 GetX,大团队优先 Bloc / Riverpod

5. 保留框架可替换性的实现方案

很多同学会担心:现在用了 GetX,以后想换 Riverpod / Bloc,改动太大怎么办?其实只要做好抽象封装(这也是做为应用的架构师需要在选型时就考虑到的点),就能把替换成本压到很低,不用重构整个项目。

5.1 核心原则:依赖抽象,不依赖具体框架

这也是软件架构里最核心的原则之一(依赖倒置原则 DIP)。

核心思路: 把需要用的几个核心能力(状态管理、路由、依赖注入)做一层抽象封装,项目里只调用抽象接口,不直接用 GetXAPI;后续替换框架时,只改封装层,不动业务代码。

接口设计的一个重要原则

  • 只暴露业务概念(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_itRiverpodProvider,只改 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.obsRx<T>WorkerGetX 专有符号。

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 抽象层的代价

抽象不是免费的,要承认它的代价:

  1. 心智成本上升:团队成员要多记一层 APICode Review 里要额外检查;
  2. 功能裁剪:抽象接口通常只能覆盖 GetX 能力的子集,特殊能力(比如 everAllinterval)要专门设计扩展口;
  3. 封装漏洞:总有人图省事直接 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 团队编码规范

抽象设计做得再好,也需要规范来兜底:

  1. 业务代码禁止直接使用 GetX 的静态方法Get.to / Get.find / .obs 等),全部通过 NavigatorUtil / Injector / IStateProvider 调用;
  2. Controller / Service 基类不直接继承 GetxController,封一个自己的 BaseController,未来换框架只改基类;
  3. Worker / 监听订阅一律要在 onClose / dispose 里释放,在 Code Review 里作为硬性检查项;
  4. 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'