Flutter 中我为什么把 GetX 定位为高效率方案,而不是默认架构方案

在 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 会影响哪些页面

这些信息很容易分散在:

  • Binding
  • Get.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 做页面型业务,我其实很建议你不要简单站队"只用某个状态管理方案"。

更现实的做法是先问自己两个问题:

  • 当前项目最核心的诉求,是开发效率,还是依赖治理,还是状态流转可控?
  • 团队有没有能力在工具之外,再补一层架构边界?

工具本身只决定上限的一部分,真正决定项目能不能长期稳定演进的,往往还是边界和约束。

相关推荐
传奇开心果编程3 小时前
组合优先开发范式:从汉字造字原理到声明式UI组件组合思想
android·flutter·ios·swiftui·移动开发·harmonyos·android jetpack
2501_9445215913 小时前
Ramda 跨端三方库仓颉适配全流程
开发语言·javascript·flutter·php
大龄秃头程序员19 小时前
Flutter 架构里总被混淆的 IoC、DI、Service Locator,到底是什么关系?
flutter
事圆则缓1 天前
Flutter 与原生交互的最后一层:RTC 视频帧怎么走
flutter·音视频·实时音视频
2601_949847751 天前
jose 跨端三方库仓颉适配全流程
前端·javascript·flutter
2601_949970691 天前
msgpackr 跨端三方库仓颉适配全流程
前端·javascript·flutter
2601_949970692 天前
HumanizeDuration 跨端三方库仓颉适配全流程
前端·javascript·flutter
传奇开心果编程2 天前
【Flutter踩坑排雷】第3课:const 误用坑|const 错误使用引发 UI 异常与性能损耗
flutter·移动开发·跨平台·dart·编程语言理论·编程框架·声明式ui
程序员老刘2 天前
你的Flutter项目要不要紧急升级3.47.8?
flutter·客户端