[KF-001] Flutter 状态管理的三个“原语”
原语介绍
在常用的状态管理框架中,尤其是基于提供者模式的设计下,常常会看见三种类型的函数,我称之为状态管理的三个“原语”—— read
、 watch
、 listen
。三者作用也很清晰:
- read:为了用于调用方法,一次性的获取状态管理对象;
- watch:监听状态的改变,并通知小组件刷新;
- listen:监听状态的改变,但不通知小组件刷新。
事实上,我们还会遇到 select
原语,不过介于这只是对 watch
的拓展:从被观察的状态中选择某个部分进行监听。所以暂时不具体讨论他的作用。
read
这是一个很简单的原语,用来获取到管理状态的对象。一般的,我们只会使用这个原语去调用状态管理类内部的用于改变状态的函数:
onPressed: () => context.read<CounterBloc>().add(CounterIncrementPressed()),
💡 但是请注意,他不应该用于获取状态,状态只是凑巧存在于管理状态的对象中。如果直接使用
read
去读取状态,将会导致 UI 不刷新的异常行为。
watch
这是一个易错的原语,用于在状态变化时,通知组件刷新。但是注意编写位置,一般的我们建议使用以下方式显式声明依赖的状态:
Builder(
builder: (context) {
// 显式在 return 语句之前声明状态
final state = context.watch<MyBloc>().state;
return Text(state.value);
},
),
listen
这是一个特殊的原语,在 Flutter 原生和 provider 库中其实是没有的,作用是监听状态的改变,但是不触发小组件的刷新,是一个为了弥补 watch
原语在特殊情况下:弹窗、路由跳转、控制动画时不方便操作,特别是在使用 watch
原语去直接跳转路由时会直接报错:
setState() or markNeedsBuild() called during build.
可能会有人疑惑我的路由跳转,没有调用过 setState()
函数,为什么还会报这个错误,但其实调用 Navigator.of(context)
本质上也是一种 read
原语,修改的是路由的状态,所以触发了 setState()
函数。
如果硬要使用 watch
实现类似的功能,可以尝试使用 Future.microtask
或者 WidgetsBinding.instance!.addPostFrameCallback
让其在渲染完成之后的下一帧之后去跳转路由。
Consumer<XxxProvider>(
builder: (context, xxx, child) {
WidgetsBinding.instance.addPostFrameCallback((_) =>
Navigator.of(context).pushReplacementNamed(MainRoute));
// can also use:
// Future.microtask(() => Navigator.of(context).pushReplacementNamed(MainRoute));
return Container(); // always return a widget in the build method, not null
},
);
所以 watch
似乎很难正常的编写类似不刷新小组件的逻辑,而 read
又难以编写监听状态的逻辑,所以在 bloc 和 riverpod 中产生了 listen
原语。相同的代码使用 listen
原语编写,效果如下:
BlocListener<BlocA, BlocAState>(
listener: (context, state) {
// 登录成功后跳转到主页
if (state == BlocALoginSuccessState) {
Navigator.of(context).pushReplacementNamed(MainRoute);
}
},
child: const LoginModal(),
);
为什么不使用 Consumer 对象
使用原语编程最大的好处就是依赖更清晰明了:
- 你需要使用
watch
提前定义好需要的状态,UI 的改变只会因为罗列出来的状态发生了改变而改变。 - 你需要在调用方法的地方去使用
read
原语,这比从消费者对象身上拿到的管理状态的对象来调用方法更直观清晰。 listen
原语在整个页面的顶层去做状态的监听,可以解耦路由逻辑和业务逻辑,也方便对状态更新输出日志。
同时消费者对象其实是有理解成本的,几大框架的设计方式都有各自的特点:
- provider:一个消费者带泛型只能在
builder
的第二参数去监听一个提供者对象;但同时可以使用第一个参数context
去使用原语监听多个提供者,给开发者带来一些小小的困惑。 - bloc:提供了
BlocBuilder
、BlocProvider
、BlocListener
和BlocConsumer
,理解成本大大提高,但是还好职责是清晰的,熟悉之后会觉得还不错。 - riverpod:类似 provider 一样,但是消费者不再监听具体某个提供者,而是提供了
ref
参数去获取原语,并新增了一个十分好用的listen
原语。所以表面上 riverpod 提供了消费者类,但事实上写法完全是原语的味道。 而之前的context
被完全用于使用上下文本身的功能,不再接手开发中涉及的业务状态。
原语在不同框架下的对比
框架 | READ | WATCH | LISTEN |
---|---|---|---|
provider | context.watch() | context.read() | Consumer + Future.microtask |
bloc | context.watch() | context.read() | BlocListener |
riverpod | ref.watch(xxProvider) | ref.read(xxProvider) | ref.listen(xxProvider) |
可以看出 riverpod 几乎是为了这套设计模式而开发的,api 的使用有高度的统一,bloc 虽然没有 listen
的原语,但也意识到了来自 provider 的一些不便。
最后,结合起来使用
这里以一个简单的登录案例来感受原语的使用。你将不会去分析 return 语句中的某个类是否是一个消费者,因为状态被原语修饰为变量,那么 return 语句中将是存粹的 UI 代码,而 build 函数到 return 语句中间是对状态依赖。
63 行通过 listen
原语监听状态执行路由跳转或弹窗(这里是snackbar)报错,81、86 通过 read
原语执行状态改变,105、122 通过 watch
原语获取到状态本身,其中 105 使用了 select 原语。
注意,我们希望刷新小组件的范围尽可能的小,这需要避免我们直接在 59 行去 watch
状态,但是我们仍然可以直接使用 read
listen
和简单的 context
,这不会刷新组件导致性能损耗。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'login_page.g.dart';
enum LoginStatus {
initial,
failure,
success,
loading,
}
class LoginModel {
LoginModel({required this.status, required this.user, required this.passwd});
final LoginStatus status;
final String user;
final String passwd;
@override
String toString() => 'user: $user\npasswd: $passwd\nstateus: ${status.name}';
}
@riverpod
class LoginController extends _$LoginController {
@override
LoginModel build() {
return LoginModel(status: LoginStatus.initial, user: '', passwd: '');
}
// 偷懒写法,不建议
void onChange({LoginStatus? status, String? user, String? passwd}) =>
state = LoginModel(
status: status ?? state.status,
user: user ?? state.user,
passwd: passwd ?? state.passwd,
);
Future<void> login() async {
print('object');
onChange(status: LoginStatus.loading);
// mock login
return Future.delayed(
const Duration(seconds: 1),
() => throw Error(), // TODO: return null for success
)
.then((value) => onChange(status: LoginStatus.success))
.onError((error, stackTrace) => onChange(status: LoginStatus.failure));
}
}
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final navigatorState = Navigator.of(context);
final scaffoldMessengerState = ScaffoldMessenger.of(context);
// 监听状态并反馈
ref.listen(loginControllerProvider, (pre, next) {
if (next.status == LoginStatus.success) {
navigatorState.pushNamedAndRemoveUntil('/main', (route) => false);
}
if (next.status == LoginStatus.failure) {
scaffoldMessengerState.showSnackBar(
const SnackBar(content: Text('发生错误')),
);
}
});
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const LoginStatusWidget(),
TextField(
onChanged: (value) => ref
.read(loginControllerProvider.notifier)
.onChange(user: value),
),
TextField(
onChanged: (value) => ref
.read(loginControllerProvider.notifier)
.onChange(passwd: value),
),
const LoginButton(),
],
),
),
);
}
}
class LoginButton extends ConsumerWidget {
const LoginButton({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading =
ref.watch(loginControllerProvider.select((value) => value.status)) ==
LoginStatus.loading;
return ElevatedButton(
onPressed:
isLoading ? null : ref.read(loginControllerProvider.notifier).login,
child: const Text('登录'),
);
}
}
class LoginStatusWidget extends ConsumerWidget {
const LoginStatusWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final loginModel = ref.watch(loginControllerProvider);
return Text(loginModel.toString());
}
}
转载自:https://juejin.cn/post/7367630980943642662