在商品详情页点“加入购物车”,顶部角标立即加一;切到结算页,那里也要看到同一数量。用 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 把哪几步接起来?
先看购物车计数的主线:
点击按钮 → 调用 CartController.addItem() → itemCount.value 变化 → 读取过 itemCount.value 的 Obx 重新构建CartController是状态持有者,.obs让普通值变成可观察值(Rx),Obx在构建回调中读取 Rx 并订阅变化。Get.put()注册 Controller,Get.find()从注册表取回它。响应式更新和依赖注册是两个不同责任;只会写.obs,还不足以保证多个页面拿到同一份状态。
下面的例子把 Controller 注册在应用启动处,因而它在示例中是应用级状态。MaterialApp已足够演示状态管理;只有使用 GetX 的命名路由等能力时,才需要按相应文档接入GetMaterialApp。
dependencies:get:4.7.3import'package:flutter/material.dart';import'package:get/get.dart';classCartControllerextendsGetxController{CartController(this._repository);finalPriceRepository_repository;finalitemCount=0.obs;finaltotalCents=RxnInt();finalpriceStatus=PriceStatus.idle.obs;finalpriceError=RxnString();int _requestId=0;bool _closed=false;voidaddItem(){itemCount.value++;_requestId++;// 数量变化后,旧报价失效。totalCents.value=null;priceStatus.value=PriceStatus.idle;priceError.value=null;}Future<void>loadPrice()async{finalrequestId=++_requestId;finalcount=itemCount.value;priceStatus.value=PriceStatus.loading;priceError.value=null;try{finalcents=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;}}@overridevoidonClose(){_closed=true;_requestId++;super.onClose();}}enumPriceStatus{idle,loading,success,failure}abstractclassPriceRepository{Future<int>fetchTotalCents(int count);}classFakePriceRepositoryimplementsPriceRepository{@overrideFuture<int>fetchTotalCents(int count)async{awaitFuture<void>.delayed(constDuration(milliseconds:300));returncount*1999;}}voidmain(){Get.put(CartController(FakePriceRepository()),permanent:true);runApp(constCartApp());}classCartAppextendsStatelessWidget{constCartApp({super.key});@overrideWidgetbuild(BuildContextcontext){returnconstMaterialApp(home:CartPage());}}classCartPageextendsStatelessWidget{constCartPage({super.key});@overrideWidgetbuild(BuildContextcontext){finalcart=Get.find<CartController>();returnScaffold(appBar:AppBar(title:constText('购物车')),body:Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[Obx(()=>Text('商品数:${cart.itemCount.value}')),Obx((){finalstatus=cart.priceStatus.value;if(status==PriceStatus.loading)returnconstText('正在报价…');if(status==PriceStatus.failure){returnText(cart.priceError.value??'报价失败');}if(status==PriceStatus.success){returnText('报价:${cart.totalCents.value}分');}returnconstText('价格待查询');}),ElevatedButton(onPressed:cart.loadPrice,child:constText('查询价格'),),],),),floatingActionButton:FloatingActionButton(onPressed:cart.addItem,child:constIcon(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 的优势是把响应式、依赖和路由放进同一套工具;选它时也要同时管理这三者的边界。
下一章会用同一购物车需求做横向实战,对照各方案的代码组织、测试与迁移成本。
参考资料
- GetX 包文档与版本
- GetX 项目仓库
- Flutter 官方:Introduction to state management