哈喽,我是老刘
老刘前段时间接到两个外包项目,都属于是中途接手的类型。
共同特点是都是东拼西凑的缝合怪。
其中一个短期项目使用Riverpod重构状态管理部分后已经顺利交付了。
这里把重构后Riverpod的使用场景和大家的疑问梳理出来,作为其它项目的一个参考。
注意本文不是Riverpod的基础讲解,主要是为了梳理应用场景。
学习Riverpod基础用法还是建议去看官网的说明,里面有很详尽的使用讲解。
一、为啥选Riverpod?
和一个客户的开发人员交流的时候我提到建议换成Riverpod,他也同意,理由是因为官方推荐。
我想说的是官方推荐确实能增加信任背书,但是我们做技术选型的时候还是要考虑项目自身的情况,应该要清楚的知道选择一个技术方案的理由。
其实老刘自己的团队一直以来使用的是bloc,因为做了比较深入的封装,各种业务场景也都覆盖到了,所以没有替换的需求。
那为啥我会给客户推荐Riverpod呢?我觉得有以下几个原因:
- Riverpod很轻量,逻辑结构也很清晰。
我想这一点符合大多数中小团队的情况,后期维护的成本也比较低。 - Riverpod更灵活。
其实不管是bloc、Provider还是Riverpod,是否灵活更多的取决于你的使用方法和理解是否到位。
但是Riverpod本身推荐的用法更容易引导开发者对业务逻辑进行拆分解耦,进而增加灵活性。 - 代码生成的方式可以省去很多套路化的代码。
在使用频率最高的场景下能做到最大程度的简化代码,这是Riverpod比其它状态管理工具做的更好的地方。 - 对测试的良好支持。
老刘自己的团队是使用TDD流程的,所以我们对可测试性的要求比较高。
但是即使你的团队没有使用TDD或者单元测试这样的流程,在技术选型的时候也可以通过对测试的友好程度间接的判断开发者是否足够专业。
说了不少Riverpod的优点,但是在实践中发现Riverpod对于初学者来说还是容易理解不到位。
所以接下来我们先来看一下Riverpod的基本思路。
一、Riverpod的基本思想
通常在应用App的架构体系中我们会拆分成至少三层:
比如我们要开发一个商品详情页,就会有详情页的UI、负责业务逻辑的模块和底层支持模块比如服务端Api。
如果使用之前比较常用的bloc作为状态管理,App的多个页面大概如下图所示:
其中每一个UI对应一个页面,每一个BLoC对应一个页面的业务逻辑。
当然也会有一些Bloc为全局多个页面提供服务。
这样当Bloc中的业务逻辑的状态产生变化时,就会通知关注这个状态的UI页面,页面响应状态变化后更新内容。
在Riverpod中也遵循这个核心理念,只不过在具体应用时做了一些扩展:
- Riverpod中封装业务逻辑的部分被叫做Provider,可以理解为业务状态的提供者。
- Riverpod更鼓励将业务逻辑中不同的状态拆分到不同的Provider中,相互独立。
其实bloc也可以这样处理,只不过我们更习惯于将一个页面的业务逻辑放到同一个bloc中。 - 在bloc中每个页面会管理自己的bloc,在页面关闭时进行资源回收。
在Riverpod中所有的Provider默认统一进行全局管理,这样可以方便页面间共享状态。
同时Riverpod也会在一个Provider没有关注者时自动进行资源回收。 - Riverpod通过Provider容器实现逻辑与UI的彻底解耦,消除传统InheritedWidget对BuildContext的依赖。
因为老刘的团队本身使用bloc比较多,所以这里以bloc为视角介绍Riverpod的特点,熟悉其它状态管理框架的同学简单类比即可。
接下来我们结合不同页面的场景,说明几种典型的使用方式,帮助大家理解。
二、标准应用场景
下面的示例代码我会优先使用官方文档中的例子,这样大家可以更好的相互印证。
全局Provider管理
Riverpod中推荐将所有的Provider在全局范围内统一管理。
这样组件树的任何一个子节点都可以获取到所有的Provider,方便页面间的数据共享。
void main() {
runApp(
// To install Riverpod, we need to add this widget above everything else.
// This should not be inside "MyApp" but as direct parameter to "runApp".
ProviderScope(
child: MyApp(),
),
);
}
通过全局的ProviderScope管理所有的Provider。
Provider在没有关注者时会自动回收释放,不用担心内存泄露的问题。
场景一:展示单一接口数据的页面
用户打开页面后调用一个服务端接口获取数据,拿到数据后页面展示相关的信息。
这种场景是App中数量最多的页面,占整个页面数量的50%左右。
而Riverpod也针对这种场景做到了最极致的简化:只需要定义一个方法
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'activity.dart';
// Necessary for code-generation to work
part 'provider.g.dart';
/// This will create a provider named `activityProvider`
/// which will cache the result of this function.
@riverpod
Future<Activity> activity(Ref ref) async {
// Using package:http, we fetch a random activity from the Bored API.
final response = await http.get(Uri.https('boredapi.com', '/api/activity'));
// Using dart:convert, we then decode the JSON payload into a Map data structure.
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Finally, we convert the Map into an Activity instance.
return Activity.fromJson(json);
}
在activity方法中实现调用接口并返回生成的数据结构。
代码生成工具会生成对应的activityProvider。
activityProvider包含三种状态,初始状态是loading。
根据服务端返回数据的不同可以输出AsyncData和AsyncError两种状态。
那么在UI层面如何响应状态的变化并更新UI呢?
UI更新
Riverpod中提供了两种响应状态变化的组件:Consumer和ConsumerWidget。
ConsumerWidget其实是对Consumer的封装,相当于在StatelessWidget中直接返回一个Consumer。
如果状态变化时整个页面都需要重建,那么推荐使用ConsumerWidget。
但是在实际开发的场景中我们几乎只使用了Consumer本身。
原因是对大多数页面来说,跟随状态变化的只是页面的一部分。
比如展示用户信息、物品信息的页面,页面的标题、返回按钮、菜单按钮以及底部的导航栏都是不随着状态变化的,只有页面中间的信息部分会随着状态更新。
因此使用Consumer确保只更新必要的部分可以帮助优化性能以及简化代码结构。
下面是使用场景一中activityProvider状态的UI代码:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'activity.dart';
import 'provider.dart';
/// The homepage of our application
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
// Read the activityProvider. This will start the network request
// if it wasn't already started.
// By using ref.watch, this widget will rebuild whenever
// the activityProvider updates. This can happen when:
// - The response goes from "loading" to "data/error"
// - The request was refreshed
// - The result was modified locally (such as when performing side-effects)
// ...
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(
/// Since network-requests are asynchronous and can fail, we need to
/// handle both error and loading states. We can use pattern matching for this.
/// We could alternatively use `if (activity.isLoading) { ... } else if (...)`
child: switch (activity) {
AsyncData(:final value) => Text('Activity: ${value.activity}'),
AsyncError() => const Text('Oops, something unexpected happened'),
_ => const CircularProgressIndicator(),
},
);
},
);
}
}
当调用ref.watch(activityProvider)方法时,当前的Consumer成为了activityProvider的关注者。
这时activityProvider的当前状态是loading,同时开始执行activity方法中的内容,去调用服务端接口。
当服务端返回数据或者接口调用失败后,activityProvider会通知所有关注者(也就是当前的Consumer)状态更新为AsyncData或AsyncError。
Consumer根据新的状态将展示的内容从loading组件替换为具体内容或者错误页。
当用户点击返回按钮关闭页面后Consumer在回收时会取消对activityProvider的关注。
activityProvider发现没有关注者后会触发自身的dispose动作回收资源。
这里要注意一种情况,如果展示的数据是调用多个接口然后本地拼装成的,因为对外呈现的也是一个状态,因此也算是场景一,这一点要和场景二区分开。
下面我们来看场景二,多接口多状态。
场景二:展示多个接口数据的页面
考虑这样一种情况,商品详情页需要展示商品信息、商品评论、根据账户情况实时核算的价格以及是否加入购物车(这里只考虑展示的场景,不包含用户交互)。
这些数据可能都是单独的接口提供,并且每一种数据都是有单独的状态变化,和其它状态无关。
比如新增了评论不需要更新商品信息,也不需要更新价格。
这种情况下在过去使用bloc的情况下很多人可能会选择在同一个bloc中管理所有数据。
不管任何一个数据产生变化,bloc会通知关注者状态变化。
UI模块则可以对状态变化进行更具体的筛选,比如购物车按钮只关心是否加入购物车这一个数据,其它的过滤掉。以此来避免没有必要的UI刷新。
但是在Riverpod中更推荐将商品信息、商品评论、价格以及是否加入购物车分别在不同的Provider中进行管理,分别调用各自的接口。
UI层面整个页面可能是一个Statelesswidget,每种数据由一个独立的Consumer负责响应其状态变化。
定义Provider的示例代码如下(注意本例中只考虑展示没有用户交互的功能):
// product_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'product_provider.g.dart';
// 商品基本信息(异步接口)
@riverpod
Future<Product> product(ProductRef ref, String productId) async {
// 调用商品信息接口
}
// 商品评论(异步接口)
@riverpod
Future<List<Review>> productReviews(ProductReviewsRef ref, String productId) async {
// 评论接口
}
// 实时价格(依赖账户状态)
// 购物车状态
}
不同部分的数据用相互独立的Provider获取。
UI层的代码如下:
class ProductPage extends StatelessWidget {
const ProductPage({required this.productId});
final String productId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('商品详情')),
body: Column(
children: [
// 商品基本信息模块
_buildProductHeader(),
// 实时价格模块
_buildPriceDisplay(),
// 商品评论模块
_buildReviewList(),
// 购物车状态模块
_buildCartButton(),
],
),
);
}
Widget _buildProductHeader() {
return Consumer(
builder: (context, ref, child) {
final productState = ref.watch(productProvider(productId));
return Padding(
padding: const EdgeInsets.all(16),
child: switch (productState) {
AsyncData(:final value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value.name, style: Theme.of(context).textTheme.headlineSmall),
Text('库存: ${value.stock}', style: Theme.of(context).textTheme.bodyMedium),
],
),
AsyncError(:final error) => ErrorCard(
message: '商品加载失败: ${error.toString()}',
onRetry: () => ref.invalidate(productProvider(productId)),
),
AsyncLoading() => const Center(child: LoadingIndicator()),
_ => const SizedBox.shrink(),
},
);
},
);
}
Widget _buildPriceDisplay() {
// 展示价格
}
Widget _buildReviewList() {
// 展示评论
}
}
这里有两点需要注意:
1、页面默认使用Statelesswidget类型,要求开发者主动缩小页面更新的范围。
2、每个需要根据状态变化的部分独立使用Consumer,并且只关注与自己相关的Provider。
也就是说可能出现商品信息已经正常展示,但是评论部分还在loading的场景。
前面两种场景都只是展示接口信息,并不包含和用户的交互。
接下来我们来看一下当增加用户交互的时候如何处理,也就是Provider如何给外部提供更多的调用接口。
场景三:包含用户交互的页面
这次回到官网中的例子,考虑一个待办事项列表页,页面加载时获取待办列表并展示。
用户可以添加新的事项。
这种情况下Provider需要给外部使用者提供一个可以调用的接口用于添加事项:
@riverpod
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
// The logic we previously had in our FutureProvider is now in the build method.
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// We serialize our Todo object and POST it to the server.
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}
1、_$TodoList是AsyncNotifierProvider类型。
2、代码生成工具会生成TodoListProvider对象。
3、build方法是这个Provider的初始化方法,这里可以理解为初始状态为loading,然后开始调用build方法初始化数据(这里是获取待办列表),获取数据成功后状态变为AsyncData。
4、addTodo方法是用户点击添加按钮后调用的方法,接下来看一下如何调用这个方法。
class Example extends ConsumerWidget {
const Example({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// Using "ref.read" combined with "myProvider.notifier", we can
// obtain the class instance of our notifier. This enables us
// to call the "addTodo" method.
ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
},
child: const Text('Add todo'),
);
}
}
1、通过ref.read可以在不成为关注者的情况下获取到对应的Provider。注意入参是todoListProvider.notifier。
2、可能有人会疑问为啥不拿着todoListProvider直接使用?这个我们后面讲到内存原理的时候会提到,其实真正工作的不是这个Provider,而是底层的ProviderElement。这也是通过Provider参数实现多个同类型页面需要分别管理自己的独有状态的底层逻辑。
到这里为止我们已经实现了调用Provider提供的api去添加事项。
但是当前的代码添加完成后UI并不会显示新增的事项,那如何在添加todo后更新状态让UI刷新呢?
这里要区分两种情况:
如果添加todo的接口会返回最新的事件列表,需要手工更新本地缓存:
Future<void> addTodo(Todo todo) async {
// The POST request will return a List<Todo> matching the new application state
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
// We decode the API response and convert it to a List<Todo>
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();
// We update the local cache to match the new state.
// This will notify all listeners.
state = AsyncData(newTodos);
}
将接口返回的事件列表转换为List,然后直接给state赋值。
这个赋值动作会触发所有关注者更新。
如果添加todo的接口没有返回新的任务列表,则需要客户端自己做一次数据的全量更新:
Future<void> addTodo(Todo todo) async {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
// Once the post request is done, we can mark the local cache as dirty.
// This will cause "build" on our notifier to asynchronously be called again,
// and will notify listeners when doing so.
ref.invalidateSelf();
// (Optional) We can then wait for the new state to be computed.
// This ensures "addTodo" does not complete until the new state is available.
await future;
}
1、invalidateSelf会触发build方法被重新调用,调用完成后会自动更新所有的本地缓存数据并通知UI更新状态。
这样对开发者来说最省事,不需要手动更新本地的state。
2、await future的作用:如果UI层根据addTodo方法的异步返回决定展示和关闭一个loading组件,那么这句话可以保证关闭loading后新的事件列表已经就绪了。await future会等待loading状态的结束并返回新的数据。
好了,在一个小型项目中用到场景就这三种。其它的例如传入参数、多页面共存等情况都属于这三种场景的具体应用技巧。可以参考官方文档的说明。
这里主要还是帮助大家梳理一下不同页面类型下使用Riverpod的方式。
接下来我们来解释一下Provider作为全局变量会不会有内存泄露的问题。
三、关于全局变量的焦虑
如果大家没有使用代码生成的方式,那应该会看到官方推荐的方法是将所有的Provider定义为全局变量。
当我的页面关闭了,Provider没有关注者了,但是全局变量本身并没有清除掉。那这个全局变量为啥不会造成内存泄露呢?
我们先来看一下Riverpod的内存分布:
其实本质上不管是通过代码生成还是手工定义的那个Provider,都只是一个配置模板。
全局ProviderScope管理的并不是Provider本身。ProviderScope中包含一个名为ProviderContainer的容器,ProviderContainer负责管理所有的Provider。
当我们的UI第一次调用watch方法时,比如第一次调用ref.watch(counterProvider)。
其实是做了下面几件事情:
1、ProviderContainer 调用 Provider 的 createElement()
方法创建 ProviderElement
2、调用 ProviderElement 中创建 state 的具体方法,也就是那个build方法,或者只有一个函数的情况下的那个函数。
3、在 ProviderElement 上添加监听变化的回调。
4、将 ProviderElement 添加到 _providers 这个map中。
map类型(<ProviderBase, ProviderElement>{})
ProviderBase 是 Provider 的基类
所以,真正替我们管理状态的是ProviderElement,而非Provider本身。
接下来我们看看Provider的销毁流程。
Riverpod中Provider的autoDispose参数默认为true。
当这个Provider没有关注者的时候,比如所有使用这个Provider的页面都关闭了。
这时就会启动autoDispose流程。
1、调用 ProviderElement 的 _dispose() 方法,销毁其持有的状态数据(如释放网络连接、关闭文件句柄等)
2、ProviderElement 会被标记为无效并从 ProviderContainer 的缓存列表中移除
3、ProviderElement本身被 GC 回收
所以当一个Provider的使命结束,从他持有的状态,打开的各种资源到ProviderElement本身都会逐步被清理。也就不用担心内存泄露的问题。
总结一下,对于调用接口然后展示数据这种最简单最常见的场景,可以直接使用场景一中定义一个方法的方案。
对于页面包含多个独立状态的场景,可以将多个状态拆分到不同的Provider中。
对于需要Provider给关注者提供额外的api的场景,可以使用NotifierProvider或者AsyncNotifierProvider。
组合使用这三种方案能够覆盖大部分app中90%以上的场景。
对于大多数中小型APP来说是足够用的。
最后老刘还是要提醒一下,对于状态管理或者说架构设计来说,清晰的思路远比选择哪个库更重要。
心中有剑,落叶飞花皆是兵器 。
如果看到这里的同学对客户端开发或者Flutter开发感兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。