ARTICLE DETAIL

资讯详情

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

Flutter状态管理:Riverpod vs GetX,对象监听与系统依赖的深度对比

Flutter状态管理:Riverpod vs GetX,对象监听与系统依赖的深度对比 最近半年一直在帮团队做 Flutter 中大型项目的架构重构几乎每一轮技术方案评审都会被问到同一个问题新模块到底用 Riverpod 还是 GetX这个问题看起来是在选状态管理库但实际拆开之后会发现它背后是对象监听 vs 系统依赖这两种完全不同的设计哲学的碰撞。今天我不打算站在任何一方阵营去吹某个库而是把这两个方案放在真实业务场景里从原理到代码、从迁移成本到排查体验完整地讲一讲我的看法。如果你正处在选型阶段或者准备把老项目从 GetX 迁到 Riverpod亦或是新团队想绕过网上那些互相攻击的帖子做出自己的判断这篇文章应该能给你提供一个相对完整的视角。1. 先给两个库划清坐标系很多争议的产生是因为说话人讨论的根本不是同一个层面。Riverpod 和 GetX 虽然都出现在 Flutter 状态管理的搜索词前几位但它们在项目里的定位差异非常大不把这个差异先说清楚后面所有的对比都是鸡同鸭讲。1.1 Riverpod 本质上是依赖注入 状态订阅Riverpod 官方页面很少直接喊自己叫状态管理框架它的描述更接近一个用于 Dart 和 Flutter 的响应式缓存和数据绑定框架。这句话里隐藏了它的核心Provider 本身是一个可缓存的、可组合的数据对象Widget 通过继承或监听这个对象来获取最新值一旦值变化只有真正订阅了这个对象的 UI 会被重建。我理解它最关键的词是对象级监听。Riverpod 里没有全局刷新这种概念也没有一棵挂满依赖的全局对象树。它更像是在 Dart 侧维护了一张 provider 依赖图每一个 provider 就是一个监听节点。Widget 用 ref.watch 挂到某个节点上就只对该节点的变化负责。这和我早期用 Provider 包 InheritedWidget 的思路很像但 Riverpod 把这一层抽象做得更彻底连 widget 树层级都不需要关心了。1.2 GetX 本质上是带依赖容器的轻量级框架GetX 的定位比 Riverpod 重得多。它不只是状态管理还包括路由、依赖注入、国际化、主题切换甚至对话框和底部弹窗都帮你封装好了。很多团队把 GetX 当作 Flutter 开发的全家桶因为只要在入口处配置一次 GetMaterialApp后续再写任何页面几乎没有额外的框架代码。与之配套的是一套全局依赖容器也就是 Get.put、Get.lazyPut、Get.find 这套 API。Controller 被放进一个全局容器里后任何页面、任何组件只要调用 Get.find () 就能拿到同一个实例。这种模式我习惯称之为系统依赖依赖不通过组件树逐层传递而是直接从系统容器中索取。数据挂在系统层视图只需要消费。1.3 两者不是纯替代关系你把 Riverpod 和 GetX 丢到同一个 Slot A/B 测试里会得到一个结论它们 80% 的日常用途是重叠的都可以拆分页面状态、都可以做跨页共享、都有依赖注入能力。但剩下 20% 决定了项目的长期走向。Riverpod 适合把状态来源和页面渲染之间的关系写得特别明确的项目尤其是复杂业务、多人协作、需要单测的场景。GetX 适合小团队快速起步、不太想写模板代码、希望少一些概念负担的场景。这两者有交集但不会完全趋同。2. 对象监听Riverpod 如何保证 UI 只重建该重建的标题里的对象监听四个字是我在迁入 Riverpod 之后体会最深的。这也是它和 GetX 最根本的一个区别Riverpod 的通知机制不是一个全局广播而是围绕数据对象的精确订阅。2.1 监听对象而不是监听整棵树可以先回想一下最原始的 setState 方案。调用一次 setState整个 Widget build 方法重新执行涉及到的所有子 widget 全部跟着 rebuild。这不是 bug但它是粗粒度的。GetX 的 Obx 会把这个粒度缩小到一个 widget 级别只有被 Obx 包裹的部分才会在依赖的 Rx 变量变化时重建。这已经比 setState 精细很多。但问题在于GetX 中 Rx 对象属于哪个 Controller、Controller 被谁持有、它的生命周期由谁管理很多情况下都是隐式的。开发者可以写出不调用 Get.put 也能运行的页面但一旦涉及到跨页共享就必须去系统容器里找依赖。Riverpod 的做法不太一样。我写任何状态之前会先把数据声明成一个 provider 对象final cartProvider StateProviderListCartItem((ref) []);然后在组件里通过 WidgetRef 去监听这个对象class CartBadge extends ConsumerWidget { override Widget build(BuildContext context, WidgetRef ref) { final cart ref.watch(cartProvider); return Badge( label: Text(${cart.length}), child: Icon(Icons.shopping_cart), ); } }这里最值得注意的一点是依赖关系是显式写在 build 方法里的。读一遍代码就能知道 CartBadge 依赖了 cartProvider不依赖任何全局容器也不依赖页面之外的环境。这就是对象监听的直观体现我的 UI 不是被系统通知的而是主动订阅了一个数据对象。2.2 ref.watch、ref.read、ref.listen 谁该用在哪儿Riverpod 提供了三个非常容易混淆的 API用错一个就可能埋下性能或响应 bug。我按自己的使用习惯做个梳理新人可以照着套API使用场景后果ref.watch在 build 方法里订阅数据数据变化后组件重建组件会持续依赖该 providerref.read只想拿一次值不关心后续变化事件回调里常用不会触发重建也不建立依赖ref.listen想监听变化但不想因为变化而重建当前组件比如埋点、弹 toast每次变化触发回调当前组件不重建看一个真实场景。购物车页面我想在商品数量变化时弹个轻提示但又不希望整个页面为了这一个小提示重建。用 ref.watch 会把页面重 build 一次用 ref.listen 合适ref.listen(cartProvider, (previous, next) { if (next.length previous.length) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(已加入 ${next.length} 件商品)), ); } });这类细粒度控制在 GetX 里也能通过 GetX 系列组件实现但 Riverpod 把监听对象这一动作拆成了两个独立维度一个是值的获取一个是变化事件的处理。刚接触时多花一点时间理解这三者能少踩不少页面疯狂重建的坑。2.3 数据粒度决定组件重建范围Riverpod 里没有全局状态和局部状态的硬性边界只有 provider 声明位置和是否覆盖的差别。你可以把 provider 写在一个文件里也可以写在某个页面私有目录里两者在 API 使用上几乎没有区分。这种统一性很容易让人忽略一个问题provider 的粒度设计直接决定了 UI 重建的粒度。我接手过一个模块把一整个表单几十个字段全部放进一个 StateProvider 里然后每个输入框都 watch 同一个 provider。结果用户每敲一个字整个表单都重建。哪怕只是让一个 TextField 获得焦点也会牵连其他字段重新 build。后来我把这个大 state 拆成了多个独立的 provider例如 userNameProvider、userAgeProvider、userAddressProvider每个输入框只 watch 自己关心的字段。重建范围一下子就缩小到了单个输入框。这个体验在大型、可编辑页面里非常明显。如果类比生活可以想象成一套公寓的独立电表。Riverpod 里每个 provider 就是一个小电表哪户用电多、哪户用电少都能单独看到而系统依赖式的方案更像整栋楼的公摊电表状态变化后会整体通知到所有住户是否重建由住户自己决定。2.4 一个购物车模块的 Riverpod 写法随手写一版购物车模块能更直观地看 Riverpod 如何把对象监听贯穿到底。首先定义业务状态class CartItem { final String id; final String name; final int price; CartItem({required this.id, required this.name, required this.price}); } final cartItemsProvider StateProviderListCartItem((ref) []); final cartTotalProvider Providerint((ref) { final items ref.watch(cartItemsProvider); return items.fold(0, (sum, item) sum item.price); });注意 cartTotalProvider 是通过 watch 依赖另一个 provider 实现的。购物车数据变化总价会自动变不需要手动 setState 去更新合计。页面组件也只在各自关心的 provider 上做订阅class CartPage extends ConsumerWidget { override Widget build(BuildContext context, WidgetRef ref) { final items ref.watch(cartItemsProvider); return ListView.builder( itemCount: items.length, itemBuilder: (context, index) CartItemTile(item: items[index]), ); } } class CartTotalBar extends ConsumerWidget { override Widget build(BuildContext context, WidgetRef ref) { final total ref.watch(cartTotalProvider); return Text(总价$total 元); } }这里 CartPage 和 CartTotalBar 是两个独立组件它们各自监听自己的数据对象。往购物车里加商品时列表区和总价区会各自重建互不干扰。这种数据变化自动传递、UI 按需刷新的链路非常符合函数式直觉。3. 系统依赖GetX 把依赖放在了全局容器里GetX 能长期保持较高社区热度绝非没有理由。它最大的优势就是系统级整合小团队或者个人独立开发时你几乎不需要思考状态管理怎么分层照着 GetX 的模式写就行。这一节重点拆解它背后的依赖管理逻辑。3.1 Get.put / Get.find 背后是哪套逻辑如果只看 API 表面GetX 的依赖注入很简单。在启动的地方把 Controller 注册进容器void main() { runApp(GetMaterialApp(home: HomePage())); }某个页面里需要时再初始化和挂载class HomeController extends GetxController { final count 0.obs; void increment() count; } class HomePage extends StatelessWidget { final HomeController controller Get.put(HomeController()); ... }后续任何地方想拿到同一个实例只需调用 Get.find ()。这种模式的工程含义是全局容器持有所有需要共享的对象对象之间可以互相感知应用不再依赖 Widget Tree 的结构来传递依赖。它确实很省代码。没有 ProviderScope没有 ConsumerWidget甚至可以在普通 StatelessWidget 里无脑取用 controller。但这也是我最担心的一点当你看到 Get.find 被撒得到处都是时代码的依赖方向已经失控了。我曾经在一个老项目里搜索 Get.find结果 200 多个地方直接从容器里拿 Service。某个 Service 的构造参数一改编译期没有强制报错只有运行时调用到那个分支才会炸。这种问题的根源不在于 Get.put/Get.find 本身而在于系统依赖给了开发者很大的自由度。自由度意味着你可以绕过显式依赖关系直接取得任何资源但相应的代码的可读性和可控性就完全依赖团队纪律了。3.2 Obx 与 GetBuilder 两种更新方式的取舍GetX 提供了两种常见响应式更新组件Obx 和 GetBuilder。它们的设计思路不一样。Obx 基于 Rx 类型比如 .obs 包装的变量。它内部会收集依赖变量变化时自动刷新。GetBuilder 则更接近手动控制器模式通过 Update() 方法来通知刷新。对很多团队来说Obx 写起来更快但稍不注意就会让粒度失控。因为你可能在 Obx 里一次性引用十几个 Rx 变量任何一个变化都会触发整个 Obx 重建。GetBuilder 的好处是可控性强你显式调用某个方法时才刷新class ProfilePage extends StatelessWidget { final ProfileController controller Get.put(ProfileController()); override Widget build(BuildContext context) { return GetBuilderProfileController( builder: (controller) { return Text(controller.name); }, ); } }后期维护上我会建议团队尽量用 GetBuilder Update 的组合因为它的刷新时机和范围更符合显式优于隐式的工程原则。但如果是为了追求开发速度Obx 也不是不能用只是要控制好每个 Obx 的边界避免出现一个超大 Obx 包住整页的情况。3.3 路由、翻译、主题与 controller 的互相绑定GetX 的系统依赖不仅体现在状态上还把路由、翻译、主题都纳入了同一个容器体系。例如在 GetMaterialApp 里直接配置 translations 和 themeGetMaterialApp( translations: AppTranslations(), locale: Locale(zh, CN), theme: ThemeData.light(), getPages: AppPages.routes, );然后任意页面里你既可以用 Get.toNamed(/detail) 跳路由也可以用 Get.find () 之类的引用访问翻译能力。controller 由路由模块在页面生命周期内自动注册页面销毁时也会自动释放。这套模式极大的降低了入门门槛。一个新开发只需要知道 Get. 前缀能解决大部分问题就能在半天内写出一个完整的页面。但问题是当项目膨胀到几十个模块、几百个文件后系统依赖的隐性成本开始凸显。你很难通过阅读一个页面代码判断它到底依赖了外面的哪些状态因为所有的依赖都是在运行时从容器里临时拉取的。3.4 GetX 方案为什么会让人上手快我踩过不少坑之后再回头看 GetX 为什么如此流行最核心的原因是它降低了三个层面的心智负担不需要理解 Provider 和 InheritedWidget 的层级关系所有状态都在全局容器里。不需要为状态管理写额外的订阅代码.obs 与 Obx 天然绑定。不需要引入额外路由框架路由功能开箱即用。对原型开发、活动页、工具类 App 来说这些优势特别实用。团队里新人多、业务迭代快、屏级别复杂度不高的话用 GetX 能省下大量时间。它不是不能写大项目只是需要更严格的设计规范来填补它缺失的约束。4. 关键差异对照约束、安全、可测试性在具体选型前我习惯把两个方案放到几个工程维度上做对比。只看 demo 决胜负没有意义真正影响决策的是这些维度上的长期差异。4.1 编译期安全类型签名 vs 字符串路由Riverpod 的 provider 在编译期就是一个完整类型对象。watch 一个 provider类型不对编译直接报错。你在重构时改了一个状态的字段名所有引用这个字段的地方会全部标红。GetX 的依赖获取主要靠运行时类型查找。Get.find () 在类型不匹配时通常抛一个运行时异常而不是编译期错误。如果我改了某个 controller 的命名编译期并不一定会报出所有问题只有跑到那条逻辑才会显示异常。路由方面差距更明显。Riverpod 传统上是代码定义页面跳转或配合 go_router 这类方案GetX 自带字符串路由表灵活性高但一旦路由命名写错错误也会延迟到运行时才发现。我并不是说字符串路由不能用但对于长久迭代的项目让错误尽可能早地在编译期暴露永远比运行时弹窗更舒服。4.2 可变状态模型与不可变状态模型Riverpod 推荐使用不可变数据。你通过创建新对象来更新状态而不是在原有对象上修改属性。这样做的好处是状态变化有迹可循调试时可以回看快照也更容易触发精确的监听逻辑。// Riverpod 风格返回一个新列表 ref.read(cartItemsProvider.notifier).update((items) [...items, newItem]);GetX 中的 Rx 变量天然是可变对象.observable 直接对内部对象做修改// GetX 风格直接向现有列表添加 controller.items.add(newItem);优雅程度不评价只能说后者在业务开发中确实更简洁短平快的需求里效率很高。但到了复杂逻辑场景尤其是撤销、恢复、历史记录这类功能时不可变模型的优势就特别明显——你永远知道上一次状态是什么样的。4.3 测试环境的依赖注入差异Riverpod 主打可测试性核心方式是 ProviderScope.overrides。你可以在单元测试里用一个 Mock 对象替换掉真实的 Providerfinal mockProvider ProviderUserService((ref) MockUserService()); ProviderScope( overrides: [userServiceProvider.overrideWithValue(mockProvider)], child: MaterialApp(home: HomePage()), );这让测试变得非常干净。你不需要真正启动数据库、不需要初始化网络层就能测试页面 UI 在特定数据下是否正确渲染。GetX 的测试也不复杂但方式不同。全局容器是动态的你可以在测试前重新 put 一个 mock controller 进去也可以重置整个容器。问题是项目一旦很大依赖关系散落在各个页面里测试前的容器初始化会变得非常繁琐。你应该先清理再注册再跑用例漏掉一步就可能在测试中互相污染。4.4 选型对照表维度RiverpodGetX定位响应式缓存 依赖注入轻量级全栈框架依赖来源Provider 对象显式监听全局容器 Get.put / Get.find状态更新不可变数据换新对象Rx 可变对象原位修改局部刷新provider 粒度精确订阅Obx / GetBuilder 局部包裹编译期安全较强类型签名完整较弱运行时查找测试ProviderScope.overrides 注入容器重置 put mock学习曲线概念偏多需要适应上手快开箱即用适合项目中大型、多人协作、复杂状态小团队、快速迭代、全栈需求路由集成需配合 go_router 等自带路由表格只是概括具体决策还是要落到项目规模、团队基础和代码风格上。5. 迁移实例登录模块在两种方案下的落地聊完原理看一个具体的迁移实例。我们之前有个老模块是用 GetX 写的最近迁到 Riverpod第一块下刀的就是登录页。登录页虽然逻辑不复杂但涵盖了表单状态、接口请求、路由跳转、全局 loading 等多个关键点非常适合做迁移认知的入口。5.1 从 GetX 迁出时的第一刀先拆依赖我见过不少团队迁移时习惯用替换 API的方式硬搬比如把 Get.put 换成 Provider 容器然后把页面里的 controller 全部改个名字最后整体重构失败。真正的迁移第一步不是改代码而是理清依赖。老登录页的 GetX 版本往往会这样class LoginController extends GetxController { final username .obs; final password .obs; final loading false.obs; void login() async { loading.value true; try { final auth Get.findAuthService(); await auth.login(username.value, password.value); Get.offAllNamed(/home); } finally { loading.value false; } } }我先不急着把它改成 Riverpod而是问自己几个问题用户名和密码这些表单字段在登录成功后还需要吗AuthService 是一个长期依赖还是一个可以局部注入的服务loading 状态应该由谁监听回答完这些问题才清楚该把哪个状态拆成独立 provider。5.2 登录模块的 Riverpod 实现Riverpod 版本我通常会拆成这样final authServiceProvider ProviderAuthService((ref) AuthService()); final loginFormProvider StateProviderLoginForm((ref) LoginForm.empty()); final loginLoadingProvider StateProviderbool((ref) false); final loginControllerProvider ProviderLoginController((ref) { return LoginController( authService: ref.watch(authServiceProvider), form: ref.watch(loginFormProvider), loading: ref.watch(loginLoadingProvider), ); });页面上不再继承 Contoller而是普通的 ConsumerWidget在 build 里分别 watch 表单、loading 等对象。登录按钮的点击事件里去调用 provider 里的方法class LoginPage extends ConsumerWidget { override Widget build(BuildContext context, WidgetRef ref) { final loading ref.watch(loginLoadingProvider); return Scaffold( body: Stack( children: [ _buildForm(ref), if (loading) CircularProgressIndicator(), ], ), ); } }对比新老实现最大的感受是以前我要去 container 里找依赖现在依赖就在 ref 后面编译器帮我盯着。当页面体量变大时这种盯住的价值会被放大很多倍。5.3 登录模块的 GetX 实现如果继续使用 GetX 写新版本其实更简洁。页面和 controller 几乎不分家class LoginPage extends StatelessWidget { final LoginController controller Get.put(LoginController()); override Widget build(BuildContext context) { return Obx(() { return Scaffold( body: Stack( children: [ _buildForm(controller), if (controller.loading.value) CircularProgressIndicator(), ], ), ); }); } }开发速度快逻辑直接短小页面几乎零负担。不过如果登录成功后需要释放 controller或者在多个页面里复用登录过程的状态你就得额外关心容器的生命周期。这一点在复杂页面里会比自由取用更考验设计能力。5.4 同一功能两套代码体感最大的区别两套代码我都完整跑过后体感区别主要集中在三件事上第一定位问题速度。Riverpod 下看到某个 UI 不更新我能从依赖链上快速判断是哪个 provider 没更新GetX 下看到 UI 不更新我得先猜测某个 Rx 变量是否还被同一个 controller 持有。第二代码评审体验。Riverpod 的 PR 里依赖关系一目了然GetX 的 PR 里如果 reviewer 不熟悉全局容器里有哪些已注册对象评审基本只能凭感觉。第三模块复用难度。Riverpod 的 Provider 天然支持覆盖把某个模块单独抽成 package 很方便GetX 的全家桶模式会让包内代码高度依赖全局容器抽取时容易牵一发动全身。6. 常见问题与避坑实录状态管理的坑很隐蔽很多问题不是当场爆发而是跑了几周之后在某个特殊操作路径上冒出来。我把实际操作中遇到的高频问题做个速查表式整理希望能帮你提前绕开。6.1 Riverpod 的全局状态与异步刷新坑Riverpod 用顺手之后我踩过最大的坑是异步 provider 的缓存策略。默认情况下一个 FutureProvider 的结果会被缓存哪怕是同一个 provider 已经被跳过下次 watch 可能拿到的还是旧值。若想让数据在某个 action 之后自动失效需要使用 ref.invalidate 或监听相关状态的变化。final userInfoProvider FutureProvider.autoDisposeUserInfo((ref) async { final api ref.watch(apiProvider); return api.fetchUserInfo(); });加 autoDispose 是个好习惯它能让不再被监听的 provider 自动销毁避免状态堆积和内存泄漏。类似页面退出后再进入数据还是旧的这类问题多半和没有正确管理 provider 生命周期有关。还有一个常见误区把 ref.watch 放在事件回调里。Riverpod 会直接报错因为 watch 只能在 build 过程中使用。事件回调要用 ref.read这一点对新手来说需要适应。6.2 GetX 的全局容器与生命周期坑GetX 全局容器在页面销毁时是否自动释放取决于注册方式。如果使用 Get.put 且没有指定 permanent页面销毁时默认会随路由销毁如果使用 Get.lazyPut 或者在某些全局位置注册controller 则可能常驻内存。一旦 controller 里持有大的数据对象、流订阅、定时器等资源常驻内存就会造成隐性泄漏。我排查过一个问题用户反复进入某个支付结果页内存只增不减。后来定位发现支付 controller 在路由上是自动注册的但页面内部又对某个全局 service 做了强引用service 里订阅了一个持续摇动的流最终 controller 永远释放不掉。对这种系统依赖型方案团队必须约定清楚哪些 controller 是页面级别的哪些是全局 service 级别的。不能把所有依赖一股脑塞进全局容器否则排查成本会越来越高。6.3 团队协作时的隐性成本状态管理选型不完全是技术问题更多是组织问题。Riverpod 的约束会迫使开发者在写页面之前先想清楚状态来源适合有架构师角色、愿意做设计评审的团队。但如果团队都是拿需求直接出活不愿意花时间抽象 providerRiverpod 也会写出很难看的代码——一个文件里堆了十几个互相关联的 provider可读性迅速下降。GetX 则相反它允许开发者在没有设计文档的情况下快速开发但代码腐化速度也会更快。多个开发同时往全局容器里塞 controller命名冲突、实例覆盖、循环依赖这些现象很快就会冒出来。我见过两个页面都调用 Get.put(HomeController())后者直接把前者的实例覆盖掉界面数据互相错乱。无论选哪个方案团队里最好有一个人专门负责状态管理规范定期 review 依赖关系否则时间一长哪个方案都会变成祖传代码。6.4 到底怎么选我给判断标准如果正在选型我建议按下面的标准而不是纯看社区风评项目生命周期超过一年模块数量超过十个优先考虑 Riverpod。你需要的不是开发速度而是可维护性。团队人数少业务是活动、页面原型、小工具类选 GetX 会更高效。如果团队已经习惯 GetX 且没有明显痛点不要单纯为了技术潮流迁移。迁移成本会吃掉你原本的效率优势。状态共享很复杂比如购物车、用户登录态、权限、主题Riverpod 的显式依赖更可控。项目需要频繁写单元测试Riverpod 的 overrides 会舒服很多。这套标准不能解决所有问题但能帮你避开因为别人说好就无脑冲的坑。7. 写在最后我的实际体会说实话我刚从 GetX 迁到 Riverpod 的头两周非常不适。习惯了全局容器里随手拿 controller 的便利之后突然要求我先把每个状态声明成一个 provider、再把依赖关系摆清楚写代码的速度确实慢了不少。但我把一个小模块完整迁移完又完成两轮线上 bug 定位后这种不适感慢慢消失了。因为每次出问题时我都能在十分钟内顺着依赖链找到源头而不是像以前一样先猜 controller 是哪来的。我不觉得 GetX 是坏方案它至今仍然是一个高效率的工具。但状态管理这件事本质上是在和系统的复杂性做对抗。简单页面用什么都能跑复杂应用里越显式的依赖往往越安全。用 GetX 时可以约束好全局容器边界用 Riverpod 时也可以不过度设计。真正让项目失控的通常不是框架本身的缺陷而是团队缺少对依赖关系的统一管理意识。最后再分享一个小技巧选型时不要只看两个库在 hello world 上的表现而是拿一个真实模块分别用两种方案写出来再模拟一次需求变更、一次线上故障定位、一次新成员接手代码。这三轮模拟做完答案通常就很明确了。
返回列表