在商品详情页点"加入购物车",顶部角标立即加一;切到结算页,那里也要看到同一数量。用 GetX 写出这段联动很短:
0.obs保存值,Obx观察值,Get.find()找到同一个 Controller。短代码很有吸引力,但跨页面之后真正要管的是:这个 Controller 到底由谁创建,什么时候应该消失?
一句话结论:GetX 能把状态更新和共享对象写得很直接;只有把 Controller 的作用域、依赖入口和销毁时机一起约定好,简洁才不会变成隐式全局状态。
本文是系列第五章,示例按 get: 4.7.3 的常见 API 写,讨论 GetX 的响应式状态、依赖注册和生命周期。代码是一个可放进 Flutter 工程改造的最小示例:价格由内存里的假仓库返回,不包含真实接口、登录态和路由。本机未配置 Flutter SDK,因此示例未做运行验证;包版本来自 2026-09-29 的 pub.dev 包信息。
1. 从点击到重建,GetX 把哪几步接起来?
先看购物车计数的主线:
text
点击按钮 → 调用 CartController.addItem()
→ itemCount.value 变化
→ 读取过 itemCount.value 的 Obx 重新构建
CartController 是状态持有者,.obs 让普通值变成可观察值(Rx),Obx 在构建回调中读取 Rx 并订阅变化。Get.put() 注册 Controller,Get.find() 从注册表取回它。响应式更新和依赖注册是两个不同责任;只会写 .obs,还不足以保证多个页面拿到同一份状态。
下面的例子把 Controller 注册在应用启动处,因而它在示例中是应用级状态 。MaterialApp 已足够演示状态管理;只有使用 GetX 的命名路由等能力时,才需要按相应文档接入 GetMaterialApp。
yaml
dependencies:
get: 4.7.3
dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CartController extends GetxController {
CartController(this._repository);
final PriceRepository _repository;
final itemCount = 0.obs;
final totalCents = RxnInt();
final priceStatus = PriceStatus.idle.obs;
final priceError = RxnString();
int _requestId = 0;
bool _closed = false;
void addItem() {
itemCount.value++;
_requestId++; // 数量变化后,旧报价失效。
totalCents.value = null;
priceStatus.value = PriceStatus.idle;
priceError.value = null;
}
Future<void> loadPrice() async {
final requestId = ++_requestId;
final count = itemCount.value;
priceStatus.value = PriceStatus.loading;
priceError.value = null;
try {
final cents = await _repository.fetchTotalCents(count);
if (_closed || requestId != _requestId) return;
totalCents.value = cents;
priceStatus.value = PriceStatus.success;
} catch (_) {
if (_closed || requestId != _requestId) return;
totalCents.value = null;
priceError.value = '报价失败,请重试';
priceStatus.value = PriceStatus.failure;
}
}
@override
void onClose() {
_closed = true;
_requestId++;
super.onClose();
}
}
enum PriceStatus { idle, loading, success, failure }
abstract class PriceRepository {
Future<int> fetchTotalCents(int count);
}
class FakePriceRepository implements PriceRepository {
@override
Future<int> fetchTotalCents(int count) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return count * 1999;
}
}
void main() {
Get.put(CartController(FakePriceRepository()), permanent: true);
runApp(const CartApp());
}
class CartApp extends StatelessWidget {
const CartApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: CartPage());
}
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context) {
final cart = Get.find<CartController>();
return Scaffold(
appBar: AppBar(title: const Text('购物车')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Obx(() => Text('商品数:${cart.itemCount.value}')),
Obx(() {
final status = cart.priceStatus.value;
if (status == PriceStatus.loading) return const Text('正在报价...');
if (status == PriceStatus.failure) {
return Text(cart.priceError.value ?? '报价失败');
}
if (status == PriceStatus.success) {
return Text('报价:${cart.totalCents.value} 分');
}
return const Text('价格待查询');
}),
ElevatedButton(
onPressed: cart.loadPrice,
child: const Text('查询价格'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: cart.addItem,
child: const Icon(Icons.add),
),
);
}
}
按钮调用 addItem(),itemCount 改变,读取它的 Obx 重新构建文字。查询价格时,另一个 Obx 显示加载、报价或错误。列表页、详情页、结算页若都取同一个已注册的 CartController,就能观察同一购物车。假仓库只延迟返回"数量 × 1999 分",不会主动失败;要观察失败分支,可让 fetchTotalCents() 在演示时抛出异常。真实加入购物车和报价仍应由业务仓库及服务端决定。
2. Obx、GetBuilder 和 Get.find() 不做同一件事
GetX 常见的几个入口容易被一起叫成"状态管理",实际职责不同:
| 入口 | 做什么 | 何时考虑 |
|---|---|---|
.obs 与 Obx |
定义 Rx 值,界面读取并响应变化 | 一小块 UI 跟随具体值更新。 |
GetBuilder<T> 与 update() |
Controller 显式通知关联 Widget 重建 | 希望按操作时机手动触发刷新,不需要每个字段都是 Rx。 |
Get.put()、Get.lazyPut()、Get.find() |
注册、延迟创建和获取对象 | 多个页面需要共享同一 Controller 或服务。 |
Bindings |
把依赖注册与页面入口放在一起 | 需要清楚地限定路由级依赖及创建时机。 |
Obx 观察的是其构建过程中读到的 Rx。把整个大页面包在一个 Obx 里,任一被读取的 Rx 变化都可能使这块页面重新构建;把观察范围收在角标、价格或错误提示附近,更新原因更容易看清。
GetBuilder 走的是显式 update() 路径。它可以和响应式方式共存,但同一份状态同时靠 .obs 与 update() 维护,会让"是谁触发刷新"难以追踪。一个功能先选一种主要更新方式,再按需要做局部例外。
3. 作用域与销毁,才是跨页面共享的难点
上面的 permanent: true 是示例中的明确选择:购物车跟着整个应用存在,页面退出不会自动销毁它。这个选项适合解释"跨页面共享",不等于所有 Controller 都应永久注册。若购物车只属于登录会话,退出登录时要安排消费者先离开旧会话,再重建或删除相关状态;强制删除永久注册对象之前也要确认没有仍在使用它的界面。
页面专属 Controller 则应在页面作用域创建。若项目使用 GetX 路由,可以通过 Bindings 注册页面依赖,并按所选 SmartManagement 配置核对实际释放行为。GetxController 的 onInit() 与 onClose() 可放初始化和清理逻辑:如果 Controller 自己持有定时器、订阅、TextEditingController 或流,离开作用域时应取消或释放。不要因为页面看不见了,就假设全局注册的对象也自动消失。
依赖注册还有一条边界:业务层最好通过构造函数接收仓库接口。若每个方法内部都 Get.find<Repository>(),依赖从函数签名上消失,单元测试也要先准备全局注册表。GetX 可以负责装配对象,业务对象仍然可以保持显式依赖。
4. 异步价格不能只用一个 double 表达
结算页需要从接口获取价格时,只有 price.obs 不够。示例用 PriceStatus、totalCents 和 priceError 区分加载、成功与失败;生产环境还应在加载时限制重复提交,失败时给用户明确的重试路径。多个 Rx 字段连续变化时,界面可能看到中间状态;复杂流程可考虑把相关字段组合成一个不可变状态对象,再让 UI 观察它。
如果用户快速改数量并连续请求,两次结果可能乱序返回。示例用 _requestId 忽略旧报价;它不会取消底层请求。无论用 GetX、Riverpod 还是 Bloc,都需要决定是取消旧请求、只接收最后一次结果,还是在提交时重新向服务端确认。Obx 只负责让 UI 看见状态变化,不能自动保证异步结果仍属于当前购物车。
同理,Controller 中捕获异常后不能只把 loading 留在 true。用 try/finally 结束加载状态,给失败分支可见的错误,并在页面离开后避免把过期结果当成当前页面的价格。测试应覆盖成功、失败、连续点击、退出页面等路径,而不只测角标加一。
5. 什么时候用 GetX?
如果团队已统一采用 GetX 的路由、依赖注册和 Controller 作用域,它能让页面与状态的连接写得很集中。评估新项目时,我会先要求一份约定:应用级和页面级对象分别在哪里注册,谁负责释放,业务层能否直接访问全局注册表,异步结果怎样处理过期与失败。有了这些规则,代码短才是真的省事。
若只是一个页面内的展开状态,setState() 更直接;若团队更看重显式依赖图或事件流,也应对照 Riverpod、Bloc/Cubit 再决定。GetX 的优势是把响应式、依赖和路由放进同一套工具;选它时也要同时管理这三者的边界。
下一章会用同一购物车需求做横向实战,对照各方案的代码组织、测试与迁移成本。