ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter状态管理:GetX Obx刷新机制与依赖收集原理深入解析

Flutter状态管理:GetX Obx刷新机制与依赖收集原理深入解析 Flutter 做状态管理GetX 的 Obx 是绝大多数人绕不开的一道坎。用它的时候确实爽给一个变量加.obs再包一个Obx(() ...)数据一变页面上那一小块内容就自动刷新了完全不用手动setState。但爽归爽很多人从来没想过它到底是怎么做到的——于是当你遇到为什么我的 Obx 不刷新、为什么我把整个页面包进 Obx 之后这么卡这类问题的时候就只能靠猜。这篇文章的目标就是花 15 分钟把 Obx 的刷新机制彻底拆开讲清楚。我假设你已经有 GetX 的基本使用经验哪怕只是照着教程跑过 demo读完这篇之后你会获得看懂源码级别的视角。如果你刚接触 Flutter我给的模型和代码也足够你把它当一本容易读的原理书来用。总之这篇覆盖三件事Obx 为什么能刷新、它的刷新机制到底长什么样、以及实战中怎么用才能避开最常见的坑。1. 从一个最普通的计数器说起Obx 到底替我们做了什么1.1 计数器代码的逐行拆解先看 GetX 教程里最经典的计数器代码几乎所有人都写过class HomeController extends GetxController { final count 0.obs; void increment() count; } class HomePage extends StatelessWidget { HomePage({super.key}); final controller Get.put(HomeController()); override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( children: [ Obx(() Text(${controller.count})), ElevatedButton( onPressed: controller.increment, child: const Text(1), ), ], ), ), ); } }这段代码里面有三处会让人产生疑惑的点0.obs明明把 int 变成了一个看起来像对象的东西为什么count这种写法还能用Obx(() Text(${controller.count}))里的 builder 只在 build 阶段执行了一次之后count变了Flutter 是怎么知道要重新执行这个 builder 的increment()里面没有调用任何update()或notifyListeners()也没有 setState但 UI 就是刷新了。如果你以前只是照着抄这三个问题可能从来没细想过。但它们恰恰是理解 Obx 的关键入口。先说结论0.obs产生的是一个RxInt对象count本质上是count.value count.value 1。而Obx会把自己注册到count这个RxInt的监听列表里。一旦count的 value 发生变化RxInt会主动通知所有监听它的人——其中就包括这个Obx——然后Obx内部调用setState触发重建。这样屏幕上那部分内容就跟着最新值变了。这个机制的关键词是四个字依赖收集。1.2 如果用 Flutter 原生实现代码会变成什么样我们先把视野拉回 Flutter 原生看看同样一个计数器如果不借助任何状态管理库代码会长什么样class HomePage extends StatefulWidget { override StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage { int _count 0; void _increment() { setState(() { _count; }); } override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ Text($_count), ElevatedButton( onPressed: _increment, child: const Text(1), ), ], ), ); } }原生方案里setState是你主动告诉 Flutter我这个 State 的 build 结果需要重新构建一次。这个方法会标记当前 State 为 dirty然后在下一帧执行build。但setState有个特点它作用于整个 State粒度很粗。只要你setState了整个build方法都会重跑一遍。如果页面结构复杂嵌套很深那么无关的区域也会跟着重建哪怕它们显示的数值根本没变。ChangeNotifier和ListenableBuilder在一定程度上解决了手动通知的问题因为你不需要每次都在事件回调里写setState只需要调用notifyListeners()class CounterModel extends ChangeNotifier { int _count 0; int get count _count; void increment() { _count; notifyListeners(); } }但ListenableBuilder监听的是整个 model 的通知它同样做不到精确到变量。只要notifyListeners()被调用所有监听这个 model 的ListenableBuilder都得重建。从这里你就能看出 Obx 的思路和原生方案的区别了Obx 不去监听一个整体controller / model而是监听你在 builder 里实际读取到的那几个变量。你在Obx(() Text(${controller.count}))里只碰了count那它就只订阅count哪怕你的 controller 里还有二十个其他 Rx 变量只要它们不变化这个 Obx 就不会被惊动。这个特性或者说这种读取即订阅的语义正是 Obx 区别于setState和ChangeNotifier的核心价值也是它性能好的原因之一。2. 拆开 Obx 的核心依赖收集与分发的完整链路2.1 响应式变量.obs到底是给变量加了什么先说0.obs。你见过很多次.obs它本质上是一个 Dart extensionextension IntExtension on int { RxInt get obs RxInt(this); }字符串、布尔值、列表也都有类似的扩展。随便点开RxInt你会发现它继承自一个通用的泛型类RxT。这个RxT内部持有两个关键的东西一个私有变量_value用来存放当前值一个通知中心在 GetX 源码里是GetStreamController可以理解为一个简化版的 StreamController用来管理所有关心这个变量的人。RxT对外暴露了value的 getter 和 setter。getter 返回_valuesetter 负责在赋值后向通知中心发送一个事件。把代码简化成你能一眼看懂的模型大概长这样class RxT { T _value; final _listeners void Function()[]; Rx(this._value); T get value { // 读取时尝试把当前的订阅者加入监听列表 RxNotifier.instance.addListener(this, () { // 实际注册逻辑这里只示意 }); return _value; } set value(T newValue) { if (newValue ! _value) { _value newValue; // 赋值时通知所有监听者 for (final listener in _listeners) { listener(); } } } }这个示意代码不是 GetX 的官方源码但机制是吻合的。你只需要抓住一句话Rx 变量的 getter 负责收集依赖setter 负责分发通知。这里还有个细节很多人不知道Rx在赋值时通常会对新旧值做相等判断。也就是说如果新值和旧值一样它可能不会触发通知。所以如果你写count.value count.value页面大概率不会刷新。这在某些版本里表现不完全一致但我的建议是不要依赖这个行为你需要触发刷新时就赋一个真正的新值。2.2 读取时注册、赋值时通知一条链路的两个环节现在到了最关键的问题Rx变量的 getter 是怎么知道当前应该把谁注册成监听者的你可能会想Obx(() Text(${controller.count}))这段代码里controller.count只是被读取了一下它怎么就能自动找到外层的 Obx这就是依赖收集比较巧妙的地方。GetX 内部维护着一个当前正处于构建状态的通知器引用。可以把它理解成一个全局指针记作_current。流程如下当Obx这个 widget 首次构建时它内部会创建一个属于自己的通知器你可以把它想成 Obx 的专属邮箱。在执行你传入的 builder 之前GetX 把这个_current指针指向刚才创建的通知器。然后 builder 开始执行。当代码执行到controller.count这一行时触发了count的 getter。getter 发现_current不为空就知道现在有个 Obx 正在构建而且读到了我于是把_current这个通知器加进自己的监听列表里。builder 执行完后GetX 会把_current恢复成 null防止后续无关代码误注册。等赋值发生的时候过程反过来count.value 1会遍历自己的监听列表找到那个 Obx 的通知器给它发一个我这里变了的信号。Obx 收到信号后在内部调用setState触发 rebuildbuilder 重新执行屏幕上就显示出最新值。你可以用微博的关注逻辑来理解这件事Obx 在读取变量的一瞬间相当于点了关注变量在赋值的时候相当于发了一条微博Obx 因为关注了这个变量所以收到了动态推送于是就跑去更新自己的内容。**关注的不是你要刷新的 UI而是你读取的那个变量。**这是 Obx 最核心的认知掌握了它后面所有问题都能顺着推导。2.3 从count到屏幕上数字变化完整走一遍把上面的机制串起来我们完整走一遍count到屏幕刷新的链路。为了讲清楚我把count拆开它实际执行的是count.value count.value 1;完整流程如下先读count.value的旧值比如 0。这一步的读取会让当前正在构建的 Obx 完成依赖注册如果这次读取发生在用户点击按钮的事件回调里_current已经是 null所以不会发生注册但这一步不重要。执行0 1得到 1。调用 setter把count.value设为 1。setter 内部遍历监听列表找到之前注册的那个 Obx 通知器。Obx 的通知器收到事件在内部执行setState把自己标记为需要重建。Flutter 在下一帧执行 Obx 的build重新运行 builder。builder 里再次读取count.value拿到最新的 1。Text组件使用新值UI 刷新完成。这套链路里有个值得注意的点整个通知过程是同步的。也就是说从你执行count到setState被调用中间没有经过微任务、没有经过 Stream 的异步派发就是一次普通的方法调用链。这也解释了为什么 GetX 的响应式更新在大多数场景下非常快——它没有额外的事件循环开销。同步更新还带来一个特性如果你在一个循环里连续给同一个Rx变量赋十次值理论上 Obx 会被通知十次但 Flutter 的setState本身有合并机制一帧内多次setState只会导致一次 rebuild。不过对 Obx 来说十次赋值肯定比一次赋值多做了无意义的通知遍历所以高频更新时最好自己做好节流这个我们后面讲性能的时候再展开。3. 一句话分清 Obx / GetBuilder / GetX 三兄弟很多刚用 GetX 的人会在Obx、GetBuilder、GetXT之间纠结感觉它们都能刷新 UI但不知道差别在哪。我把三者的定位捋了一遍你先记住一句话Obx 订阅变量GetBuilder 订阅控制器GetX 两者都要。3.1 GetBuilder粗粒度可控的控制器级刷新GetBuilder的工作方式和 Obx 截然不同。它不关心你读了哪些变量它只关心一件事你有没有调用controller.update()。GetBuilderHomeController( init: HomeController(), builder: (controller) { return Text(${controller.count}); }, );当GetBuilder被构建时它会把自己注册到 controller 上。你在 controller 里任意地方调用update()所有正在显示这个 controller 的GetBuilder都会收到通知然后重建自己。这个机制有两个特点粒度粗。不管 controller 里哪个数据变了只要调用了update()所有监听它的GetBuilder全部重建。就算你只改了一个无关紧要的标志位页面也照刷不误。需要手动调用。如果你的代码写完了却忘了update()那 UI 永远不动。这也是新手最容易栽跟头的地方。那为什么还要用GetBuilder因为它简单、直白、可控性强。你明确知道我调用了 update它就会刷新没有任何黑魔法。而且由于它不好用反而逼着你把 controller 拆得小一点让状态管理更清晰。有一个常见的误区GetBuilder只能配合GetxController来用不能直接监听一个Rx变量。如果你想在GetBuilder的 builder 里读controller.countRxInt那读到的只是当前值但count变化时GetBuilder并不会自动刷新——因为GetBuilder的刷新依赖的是update()不是依赖收集。这一点和 Obx 有本质区别。3.2 GetX 想同时要控制器生命周期和响应式的场合GetXT在命名上很容易让人混淆因为整个库也叫 GetX。作为组件时它的写法长这样GetXHomeController( init: HomeController(), builder: (controller) Text(${controller.count}), );从外部看它和GetBuilder有点像都能直接拿到 controller 实例。但GetXT内部的机制更接近 Obx它既会监听 controller 的update()调用也会通过依赖收集监听你实际读取的 Rx 变量。换句话说如果你在GetXT的 builder 里只用了controller.count那count变化时它会刷新同时如果你在别处调用了controller.update()它也会刷新。这种双通道监听让它比GetBuilder更灵活又比单独用Obx多了一层 controller 生命周期管理。实际场景中当你需要一个页面同时展示多个来自同一个 controller 的数据、并且希望 controller 的创建和销毁由 GetX 自动管理时GetXT是最省心的选择。但它的缺点是双通道监听让你难以精确判断这次刷新到底是谁触发的在复杂页面里反而可能引入性能隐患。3.3 一张表快速选型直接上结论我做了个对照表你可以存下来当笔记对比项ObxGetBuilderGetXT监听粒度精确到变量精确到控制器两者都监听是否自动刷新是需要手动 update是双通道能否拿 controller 实例不能直接拿能能生命周期托管不托管可配合 init自动管理适用场景局部 UI 依赖少量变量简单页面、少量 update较复杂页面需要劳逸结合心智负担低极低中个人经验是能只用一个Obx解决的就不要上GetXT能用一个GetBuilder解决的就不要叠加多个刷新通道。状态管理最怕的是刷新链路过多出了 bug 不知道怪谁。4. 动手实践一个带异步加载的列表页看看 Obx 的真实使用姿势原理讲完我们来做一个实际项目里非常典型的场景用户列表页。这个页面包含加载状态、数据列表、下拉刷新、失败重试。我用它来演示 Obx 到底该怎么布局以及数据在什么时机用.value赋值、什么时候用update()。4.1 控制器设计把页面状态变成 Rx先看 controller 代码class UserController extends GetxController { final isLoading true.obs; final hasError false.obs; final users String[].obs; override void onInit() { super.onInit(); fetchUsers(); } Futurevoid fetchUsers() async { isLoading.value true; hasError.value false; try { await Future.delayed(const Duration(seconds: 1)); users.value [Alice, Bob, Charlie]; } catch (_) { hasError.value true; } finally { isLoading.value false; } } Futurevoid refresh() fetchUsers(); }注意到我用了三个 Rx 变量来分别管理加载状态、错误状态和列表数据。为什么要拆这么细因为页面上这三块内容的渲染范围不同加载中显示转圈加载完成显示列表出错显示重试按钮。它们不应该因为其中一个状态变化而全部重建。users.value [...]这种赋值方式会替换整个列表如果你是往列表里加一项users.add(new user)也会自动触发通知因为RxList重写了add方法在修改集合的同时会主动通知监听者。同样还有remove、clear、insert等操作都会触发刷新。这一块是很多人会忽略的总以为一定要整体赋值其实 GetX 已经帮你封装好了。4.2 页面结构Obx 围着数据边界展开页面这边我的建议是不要让一个巨大的 Obx 包裹整棵组件树而是让每个 Obx 只包住真正依赖数据的那一小块。class UserPage extends StatelessWidget { UserPage({super.key}); final controller Get.put(UserController()); override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(用户列表)), body: RefreshIndicator( onRefresh: controller.refresh, child: Obx(() { if (controller.isLoading.value) { return const Center(child: CircularProgressIndicator()); } if (controller.hasError.value) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text(加载失败请重试), ElevatedButton( onPressed: controller.fetchUsers, child: const Text(重试), ), ], ), ); } return ListView.builder( itemCount: controller.users.length, itemBuilder: (context, index) { return ListTile(title: Text(controller.users[index])); }, ); }), ), ); } }这里有个细节值得展开我把加载中、错误、列表三个分支放在同一个 Obx 里是因为它们之间存在互斥关系一次只会有一个分支显示出来。这样写无论是isLoading变化、hasError变化还是users变化这个 Obx 都会重建然后根据当前状态渲染出正确的分支。但注意itemBuilder里我直接读取了controller.users[index]。由于这个读操作发生在Obx的 builder 执行期间整个ListView.builder的构造是在Obx的 builder 里完成的所以每个 item 在构建时也会触发依赖收集。不过这只是读取了 users 这个 Rx 变量并不会让每个 item 单独订阅——如果你删掉了某个用户整个ListView对应的 Obx 都会重建这谈不上细粒度到单行。真正要做到单行级刷新需要在 item 内部再包一个 Obx每个 item 只监听自己的数据。但这种优化在大多数真实项目里收益有限反而会让代码变复杂。我的建议是先保证 Obx 包的边界正确再谈优化。4.3 加载、刷新、失败重试的完整状态切换跑起来之后页面会经历这样几次状态切换进入页面onInit调用fetchUsersisLoading变 true页面显示加载中。一秒钟后users.value [...]赋值isLoading变 false页面从转圈切换到列表。用户下拉刷新isLoading变 true但因为RefreshIndicator自带下拉动画这里其实会出现一个加载中覆盖列表的奇怪状态我在实际项目中通常会把下拉刷新场景单独处理比如不用全局 loading只靠RefreshIndicator自己的转圈。请求失败时hasError变 true页面显示重试按钮。点重试再次调fetchUsers。这里有一个我踩过的坑第一次写的时候我在fetchUsers里用了users.clear()再慢慢填充数据。但RxList的clear()会立刻通知监听者导致页面先闪一下空列表再加载出新数据。UI 上表现为列表抖动了一下。后来我改成在数据完全准备好之后整体赋值就再也没有这种闪动了。这个经验供你参考。5. 页面不刷新我踩过的坑和一套排查清单凡是用过 Obx 的人十有八九都遇到过数据变了但 UI 纹丝不动的诡异问题。这类问题排查起来之所以痛苦就是因为大部分人对底层机制理解不深只能在代码里瞎试。我梳理了几个高频原因按概率从高到低排给各位。5.1 变量根本不是 Rx 类型这是最没技术含量但发生频率最高的问题。你在 controller 里写int count 0;然后在页面上写Obx(() Text($count));完了发现点按钮没反应。你甚至可能会怀疑 Obx 是不是坏了。其实 Obx 是巧妇难为无米之炊——count是普通 int它没有通知机制Obx 虽然试着去监听它但它压根不具备被监听的能力。只要把int count 0改成final count 0.obs一切立刻正常。排查方法很简单看你的变量声明上有没有.obs或者类型是不是RxInt、RxString、RxListT这一系的。还有一种隐蔽情况有些人在 controller 里写了final RxInt count 0.obs;看着没问题但后来又写了count 1;而不是count.value 1;前者把整个 RxInt 对象替换掉了引用丢了当然也不会通知。赋值之前看清楚你是想改对象内部的值还是想改引用。5.2 builder 里根本没读到 .value第二个高频坑是依赖收集条件不满足。前面我们说过Obx 只监听它在 builder 里实际读取过的变量。如果你在 builder 里读的是普通字段或者读了一个之后才生成的值那依赖收集就无从谈起。举个例子我见过这样的代码Obx(() Text(controller.userName));这里的userName是个普通的 String getterString get userName user.name;user是一个 Rx 对象user.name是普通字符串。Obx 在 builder 里确实访问了user通过 getter 链但如果 getter 里没有直接读.value依赖收集可能收集不到user这个 Rx 变量。具体表现就是user更新了但页面不刷新。这类问题的排查方式比较直接把 builder 里所有与响应式相关的读取全部改成直接读.value的形式。比如把上面的写法改成Obx(() Text(controller.user.value.name));如果还是不行那就是这个字段根本不是 Rx 类型回到 5.1 的问题了。5.3 异步回调在控制器销毁后才赋值这种情况通常发生在列表页跳转到详情页后列表页的请求才返回然后回调里执行了users.value ...。如果列表页的 controller 已经被Get.delete销毁那 Rx 变量也跟着释放了再去给它赋值轻则毫无效果重则报错。GetX 的GetxController有一个isClosed字段专门用来判别这种情况。我的习惯是在fetchUsers的异步回调里做一层保护if (!isClosed) { users.value result; }如果你用的是Obx而不是GetXT或GetBuilder那么 controller 的销毁不会自动解除 Obx 的订阅因为 Obx 本身不感知 controller 生命周期。它只盯着那个 Rx 变量。变量如果被销毁了Obx 还傻傻地等着那自然什么都不发生。这种问题尤其容易出现在使用了Get.put创建 controller、却忘记在页面销毁时Get.delete的场景里。5.4 列表刷新的性能坑全量重建 vs 区域重建还有一个不是不刷新、而是刷新得太猛的问题。新手很容易写出这种代码Obx(() ListView.builder( itemCount: controller.users.length, itemBuilder: (context, index) { return Obx(() ListTile( title: Text(controller.users[index]), )); }, ));这个写法有个隐患controller.users[index]这个读操作虽然发生在 item 的 Obx 里但ListView.builder本身在滚动时会不断创建和销毁 item导致新的 Obx 反复执行依赖收集。如果你的列表很长、每帧滚动都有好几条 item 进出性能就会明显下降。我实际项目里的选择是如果列表项之间有频繁的单独更新需求就考虑在 item 内部拆出更细粒度的 Obx只监听单项数据如果整个列表的数据变更频率不高那就用一个 Obx 包住整个ListView反而更省心。至于混合方案需要依靠你对数据更新频率的判断没有银弹。这里我再补一个和性能相关的经验不要在Obx的 builder 里去Get.put创建 controller。因为 builder 会被反复执行Get.put虽然内部有去重但这种写法模糊了 controller 的生命周期边界很容易在后续维护时埋坑。controller 统一在页面入口或路由层创建不要在渲染层顺手创建。6. 走出舒适区Obx 的高级玩法与性能边界6.1 一个 Obx 监听多个变量联动搜索与过滤既然 Obx 是读取即注册那单个 Obx 同时读取多个 Rx 变量当然没问题。这让它很适合做联动场景。比如搜索框加过滤列表class SearchController extends GetxController { final keyword .obs; final allItems String[].obs; ListString get filteredItems { final kw keyword.value; if (kw.isEmpty) return allItems.value; return allItems.value.where((item) item.contains(kw)).toList(); } }页面上你可以写Obx(() { final results controller.filteredItems; return ListView.builder( itemCount: results.length, itemBuilder: (context, index) { return ListTile(title: Text(results[index])); }, ); });这里有个微妙的地方filteredItems是普通的 getter但它内部读了keyword.value和allItems.value所以依赖收集会命中这两个 Rx 变量。也就是说无论你在搜索框输入文字keyword 变化还是增删列表数据allItems 变化这个 Obx 都会重建。巧妙的是它不会因为别的不相关变量变化就重建一定程度上实现了精度和灵活性的平衡。这种写法让我觉得 GetX 的响应式模型其实挺像 Vue 2 的依赖收集思路读取时收集变更时通知只是 GetX 把它用到了 Flutter 的 widget 上。6.2 别把 Obx 当万能药性能边界与注意事项相信你通过前面的原理已经意识到Obx 的刷新成本取决于两点通知链路长度和 builder 重建的复杂度。虽然单次通知很快但它仍然是同步遍历监听列表再走一遍完整的 build。所以下面几个边界你要心里有数第一不要用 Obx 包裹高频变化的全局数据源。比如你在 build 里启动了动画或者周期性地更新一个时间戳听起来很酷但 Obx 每次都会重建 UI如果这个 UI 很大必然掉帧。这种场景应该用 AnimationController 或 Transform 这类原生方案。第二不要因为省事把所有状态都塞进一个 controller。一个 controller 里有上百个 Rx 变量任何一个变化都可能导致某个大 Obx 重建。状态拆分得越细Obx 的粒度优势越能发挥出来。第三注意 list 的长度变化。RxList的add会通知但如果 item 的内容变化了add不会触发。你要更新列表里的某个元素时要么整体赋值一个新的列表要么用可识别的精确操作比如list[index] newItem是否能触发取决于版本实现。我建议在团队里约定修改列表项时直接对list.value做整体替换行为最可预测。6.3 能不能自己造一个Obx最后留一个有意思的问题如果你理解了 Obx 的原理能不能自己用 Flutter 原生的Listenable和ValueListenableBuilder搭一个简化版其实是可以的。你需要一个能够感知当前构建上下文的类加上读取时注册、赋值时通知的机制再配合ValueListenableBuilder就能复刻出 80% 的效果。GetX 只是把这个模式打磨得更完善加了缓存、生命周期、依赖管理等等。我有时候看到网上有人争论GetX 的 Obx 是不是黑魔法、是不是用了 Stream 所以很重其实这些说法都有失偏颇。它的核心思想并不复杂和 Vue、SwiftUI、Jetpack Compose 里的很多响应式机制都很像。当你真正吃透了这个思想再去看别的框架会发现到处是熟人——你阅读源码的能力从此会上一大截。回到开头那句话Obx 的刷新原理读到这里你应该已经门儿清了。我个人实际操作中的最大体会是以后遇到不刷新先别慌按我前面列的单子查一遍变量是不是 Rx读取有没有走.valuecontroller 还活着吗三个问题排查完90% 的坑都能就地解决。剩下的要么是版本差异要么是别人写的迷惑代码了。如果你还想继续往深了走我建议你直接去读 GetX 源码里的rx_notifier.dart和obx_widget.dart这两个文件。它们不算长看代码时对照今天我讲的读取即订阅、赋值即通知这条主线你会发现整个 GetX 的响应式世界其实就是围绕这两句话转的。
返回列表