在 Flutter 状态管理方案里,GetX 一直是一个很特别的存在。
它的上手成本低,API 很直接,状态管理 + 依赖注入 + 路由跳转 基本都能一把梭。对于 Demo、原型、页面型需求,它常常能用很少的代码快速把功能跑起来。
但我对 GetX 的定位一直比较明确:
它是一个高效率方案,不应该被默认等同于完整工程化架构方案。
这并不是说 GetX 不适合工程化,而是如果你直接把 Demo 阶段那套写法原封不动带进中大型项目,后面大概率会遇到两个问题:
Controller膨胀- 隐式依赖扩散
这篇文章就基于我在 flutter_demo 里做的一个最小 GetX 实践,聊聊我为什么会这么看待 GetX,以及如果要把它真正落地到工程里,应该怎么补上边界治理。
对应示例代码在这几个文件里:
一、为什么 GetX 会让人觉得"真香"
如果你第一次接触 Flutter 状态管理,很容易被 GetX 吸引。
原因很简单:它真的很快。
比如一个最简单的计数器页面,用 GetX 你很快就能把这几个核心点串起来:
Rx:响应式变量Controller:状态与交互逻辑收口Bindings:依赖注入GetMaterialApp / Get.to:导航能力Obx / GetBuilder:UI 刷新
这套最小闭环大概是这样:
text
按钮点击
→ Controller 方法
→ 修改 Rx / 普通字段
→ Obx / GetBuilder 刷新
→ Get.to 跳转页面
→ 新页面继续复用同一个 Controller
对于快速验证需求来说,这种开发体验非常顺。
你不需要先设计很多事件,也不需要写很重的 Provider 组合,更不需要为一个简单页面搭很多样板代码。很多时候,一个 Controller 加两个页面,功能就通了。
所以如果从"最少代码实现功能"这个维度看,GetX 的优势非常明确:
- API 短
- 心智链路短
- 页面开发效率高
- 很适合做 MVP、原型、教学 Demo、简单业务页
这也是为什么我会把它定位成一个高效率方案。
二、最小 Demo 阶段,GetX 的核心能力是怎么体现的
先看我这个最小示例里的 Controller:
dart
import 'package:get/get.dart';
class GetxCounterController extends GetxController {
final RxInt count = 0.obs;
int manualCount = 0;
void increment() {
count.value++;
manualCount++;
update();
}
void decrement() {
count.value--;
manualCount--;
update();
}
}
这段代码不长,但已经把 GetX 两种很典型的 UI 刷新模式都演示出来了。
1. Rx + Obx:响应式自动刷新
dart
final RxInt count = 0.obs;
这意味着 count 变成了一个响应式变量,只要 count.value 变化,依赖它的 Obx 就会自动刷新。
在页面里对应的是:
dart
Obx(
() => Text('${controller.count.value}')
)
这套写法非常适合那种"状态变化就自动更新 UI"的场景,心智模型很简单:
- 改数据
- 框架自动帮你刷新依赖视图
2. GetBuilder + update():手动控制刷新
同一个 Controller 里我还保留了一个普通字段:
dart
int manualCount = 0;
它本身不具备响应式能力,所以要靠:
dart
update();
去触发 GetBuilder 刷新。
页面里对应的是:
dart
GetBuilder<GetxCounterController>(
builder: (controller) => Text('${controller.manualCount}')
)
这两种刷新模式对比起来就很清楚:
Obx偏响应式自动更新GetBuilder偏手动控制刷新
对于初学者来说,这种差异很好理解,也很适合拿来做概念验证。
三、GetX 为什么在 Demo 阶段特别顺手
再看页面层代码:
dart
class GetxDemoPage extends GetView<GetxCounterController> {
const GetxDemoPage({super.key});
static const String routeName = '/getx-demo';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Get.to 跳转页')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('GetX 核心:Rx、Controller、Bindings、GetMaterialApp / Get.to'),
const SizedBox(height: 16),
Card(
child: Column(
children: [
const Text('Obx:响应式自动刷新'),
const SizedBox(height: 8),
Obx(
() => Text(
'${controller.count.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('GetBuilder:手动调用 update() 刷新'),
const SizedBox(height: 8),
GetBuilder<GetxCounterController>(
builder: (controller) => Text(
'${controller.manualCount}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => Get.to<void>(() => const GetxDetailPage()),
child: const Text('Get.to 跳转详情页'),
),
],
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: () => controller.increment(),
child: const Text('+'),
),
FilledButton(
onPressed: () => controller.decrement(),
child: const Text('-'),
),
],
),
);
}
}
这段代码能明显看出 GetX 的"顺手感"来自哪里。
1. GetView<T> 直接拿到控制器
页面继承:
dart
GetView<GetxCounterController>
之后就能直接使用:
dart
controller
这比显式传参或者手动查找都更省代码。
2. 页面跳转非常短
dart
Get.to<void>(() => const GetxDetailPage())
你不用先拿 Navigator,也不用关注 context 的层级问题,直接跳。
3. 依赖注入也很轻
Binding 里只要这样写:
dart
class GetxDemoBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<GetxCounterController>(() => GetxCounterController());
}
}
基本就把注入这件事完成了。
所以你会发现,GetX 把很多原本分散的工作做了统一收口:
- 状态更新
- 控制器获取
- 页面跳转
- 依赖注册
也正因为这样,它在 Demo 阶段几乎是"写哪哪顺"。
四、问题也恰恰从"太顺手"开始
如果项目一直很小,这套方式没什么问题。
但只要业务复杂一点,GetX 常见的问题就会逐渐暴露出来。核心是两个:Controller 膨胀,以及隐式依赖扩散。
1. Controller 膨胀
在最初阶段,Controller 只是一个计数器控制器,看起来非常干净:
- 有状态
- 有两个增减方法
- 有一个
update()
但真实业务里,Controller 往往会慢慢被塞入这些职责:
- 网络请求
- 参数组装
- 表单校验
- 缓存读写
- 埋点
- Toast / Dialog
- 导航跳转
- 生命周期处理
- 多页面共享状态
最后就会变成一个典型的"超级控制器"。
也就是说,GetX 的问题不是你一开始就写错了,而是它太容易让你一路往 Controller 里加东西。
这点和某些 MVC / MVVM 项目里 ViewModel 或 Presenter 失控的路径非常像。
2. 隐式依赖扩散
另一个问题是依赖变得不够显式。
在 GetX 里,页面可以通过 GetView<T> 直接拿控制器,也可以通过 Get.find() 很轻松地拿任意依赖。短期看这很方便,但长期会带来阅读和治理成本:
- 这个页面到底依赖哪些能力
- 这些依赖在哪里注册
- 生命周期谁负责
- 是局部依赖还是共享依赖
- 修改一个 Binding 会影响哪些页面
这些信息很容易分散在:
BindingGet.find()- 页面代码
- 控制器代码
- 全局注入代码
所以当项目变大后,问题不再是"能不能跑",而是"能不能稳"。
五、我为什么不把 GetX 直接当默认架构方案
到这里,我的观点就比较明确了:
GetX 非常适合高效率开发,但它本身并不会自动给你带来工程边界。
如果团队没有额外的治理约束,GetX 往往会自然滑向下面这种状态:
Controller既管状态又管业务- 页面层直接获取各种依赖
- 导航逻辑到处都能发起
- 依赖关系藏在注入细节里
- 公共状态和页面局部状态混在一起
换句话说,GetX 的易用性是一把双刃剑:
- 小项目里,这是效率
- 大项目里,如果边界不严,这就是风险
所以我不会把 GetX 直接等同于"默认架构方案"。
更准确的说法应该是:
它是一个非常高效的 UI 层方案,但要想工程化落地,必须主动补上边界治理。
六、工程化改造的核心思路:Controller 只做页面编排
既然问题是 Controller 容易膨胀,那优化思路也就很明确:
限制 Controller 的职责。
我做的这版改造,核心原则就是这四条:
Controller只负责页面状态编排- 复杂业务逻辑下沉到
Service - 数据获取和持久化抽象到
Repository - 依赖注入统一收口到
Binding
这其实是在保留 GetX 效率优势的同时,把职责边界补齐。
优化后的目录结构是这样的:
text
lib/getx_demo
├── application
│ └── services
│ └── counter_service.dart
├── domain
│ └── repositories
│ └── counter_repository.dart
├── infrastructure
│ └── repositories
│ └── in_memory_counter_repository.dart
├── getx_counter_controller.dart
├── getx_demo_binding.dart
├── getx_demo_page.dart
└── getx_detail_page.dart
这套结构虽然简单,但职责已经开始清晰了。
七、Repository:先把数据访问抽象出来
先看仓储协议:
dart
abstract interface class CounterRepository {
int readCount();
void saveCount(int count);
}
这个接口的意义在于:
- 上层不关心数据怎么存
- 可以先用内存实现
- 未来可以切到本地存储、数据库、远端接口
- 测试时也可以轻松替换
对应的最小实现是:
dart
class InMemoryCounterRepository implements CounterRepository {
int _count = 0;
@override
int readCount() => _count;
@override
void saveCount(int count) {
_count = count;
}
}
这是一个典型的"最少可运行基础设施实现"。
它没有引入额外复杂度,但已经把"抽象"和"实现"分开了。
八、Service:把业务规则从 Controller 剥离出来
接下来是应用服务层:
dart
import '../../domain/repositories/counter_repository.dart';
class CounterService {
const CounterService({required CounterRepository repository})
: _repository = repository;
final CounterRepository _repository;
int get currentCount => _repository.readCount();
int increment() {
final int nextCount = currentCount + 1;
_repository.saveCount(nextCount);
return nextCount;
}
int decrement() {
final int nextCount = currentCount - 1;
_repository.saveCount(nextCount);
return nextCount;
}
}
这里的关键点是:
计数规则不再由 Controller 直接维护,而是交给 CounterService。
你可能会说,一个计数器而已,为什么还要单独抽服务层?
原因不在于这段逻辑有多复杂,而在于要把职责边界立起来。
一旦未来这里不是简单的 +1 / -1,而是:
- 加减前要做校验
- 要限制最小值 / 最大值
- 要打埋点
- 要做权限判断
- 要同步远端
- 要做失败回滚
那么这些逻辑显然不应该继续往页面控制器里堆。
所以 Service 的意义,不是为了"看起来更高级",而是为了给后续扩展留出稳定落点。
九、Controller:从"业务承载者"收缩成"页面编排者"
优化后的 Controller 变成这样:
dart
import 'package:get/get.dart';
import 'application/services/counter_service.dart';
class GetxCounterController extends GetxController {
GetxCounterController({required CounterService counterService})
: _counterService = counterService;
final CounterService _counterService;
final RxInt count = 0.obs;
int manualCount = 0;
@override
void onInit() {
super.onInit();
_applyCount(_counterService.currentCount);
}
void increment() {
_applyCount(_counterService.increment());
}
void decrement() {
_applyCount(_counterService.decrement());
}
void _applyCount(int nextCount) {
count.value = nextCount;
manualCount = nextCount;
update();
}
}
这版 Controller 和最开始相比,最大的变化是:
- 不再自己写业务规则
- 不再直接决定"怎么计算"
- 只负责:
- 调用服务层
- 接收结果
- 同步到页面状态
- 触发 UI 刷新
也就是说,它的职责被收敛成了:
页面状态编排层。
这一步非常关键,因为一旦 Controller 的边界清楚了,后面的演化路线就会稳定很多。
十、Binding:把依赖注入统一收口
接着看优化后的 Binding:
dart
import 'package:get/get.dart';
import 'application/services/counter_service.dart';
import 'domain/repositories/counter_repository.dart';
import 'getx_counter_controller.dart';
import 'infrastructure/repositories/in_memory_counter_repository.dart';
class GetxDemoBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<CounterRepository>(InMemoryCounterRepository.new);
Get.lazyPut<CounterService>(
() => CounterService(repository: Get.find<CounterRepository>()),
);
Get.lazyPut<GetxCounterController>(
() => GetxCounterController(counterService: Get.find<CounterService>()),
);
}
}
这里我依然使用了 Get.find(),但只允许它出现在一个地方:Binding 内部。
这是一个很重要的治理原则:
- 页面层不随意
Get.find() - Controller 内部不随意
Get.find() - 依赖注入的入口统一收口在 Binding
这样做的收益是:
- 依赖关系集中
- 替换实现更容易
- 阅读路径更清晰
- 可以避免"页面和控制器到处找依赖"的扩散问题
这也就是我常说的:
不要让 Get.find() 变成整个项目的公共逃生通道。
十一、这样改造后,GetX 还是高效率的吗
答案是:是,而且更稳了。
因为你会发现,这种改造并没有破坏 GetX 原本那些高效体验:
- 页面仍然可以用
GetView - UI 仍然可以用
Obx - 局部刷新仍然可以用
GetBuilder - 跳转仍然可以用
Get.to - 依赖注入仍然由
Binding完成
你保留下来的,是 GetX 的高开发效率。
你额外补上的,是这些工程化能力:
- 职责边界
- 依赖可替换性
- 逻辑下沉能力
- 实现解耦
- 更清晰的扩展路径
所以这不是"为了规范而牺牲效率",而是:
在不明显损伤效率的前提下,把未来的维护成本先控住。
十二、和 Bloc、Riverpod 对比时,我会怎么定位 GetX
如果把三种方案放在一起看,我自己的定位大概是这样的。
1. Bloc
Bloc 更强调的是:
- 事件驱动
- 状态流转可预测
- 团队协作规范性强
- 过程可控
它更适合那种对状态变化路径要求很严格的场景。
2. Riverpod
Riverpod 更强在:
- 依赖治理
- 显式编排
- 可替换性
- Provider 链式依赖关系清晰
如果项目很强调依赖管理和组合式架构,Riverpod 的优势会非常明显。
3. GetX
GetX 的核心优势仍然是:
- API 轻
- 页面开发快
- 状态、注入、路由一体化
- 做最小功能验证非常高效
但它的前提是:
你得自己给它补边界。
所以我不会简单说:
GetX不适合大项目
更准确的表达应该是:
GetX可以做大项目,但不能只停留在 Demo 写法
十三、我的最终结论
经过这次最小 Demo 到工程化收敛的实践,我对 GetX 的看法可以总结成一句话:
GetX 适合做高效率方案,但工程化落地时一定要加强边界治理。
具体来说,我会坚持这几条原则:
- 限制
Controller只负责页面状态编排 - 不让
Controller直接承载复杂业务 - 把数据获取和领域逻辑下沉到独立的
Service / Repository - 把依赖注入统一收口到
Binding - 避免页面和控制器随意
Get.find()扩散 - 让页面层继续保持轻,但不让轻量写法演化成架构失控
所以,GetX 的问题往往不是库本身,而是团队是否愿意为它建立边界。
在 Demo 阶段,它帮你提速。
到了工程阶段,你要反过来帮它收口。
这才是我理解里,GetX 更合理的使用方式。
结尾
如果你现在也在用 Flutter 做页面型业务,我其实很建议你不要简单站队"只用某个状态管理方案"。
更现实的做法是先问自己两个问题:
- 当前项目最核心的诉求,是开发效率,还是依赖治理,还是状态流转可控?
- 团队有没有能力在工具之外,再补一层架构边界?
工具本身只决定上限的一部分,真正决定项目能不能长期稳定演进的,往往还是边界和约束。