起因
每隔一段时间,都会出现一个新的状态管理框架,最近在YouTube上也发现了有人在推signals, 一个起源于React的状态管理框架,人们总是乐此不疲的发明各种好用或者为了解决特定问题
而产生的方案,比如Bloc
, 工具会推陈出新,新的语法
会带来更便捷的方式,但原理和优缺点是更重要的一面,我们接下来聊聊这一点。
原理
状态管理的起点是值的改变
也就是通常代码中的set
方法, 状态的终点
在Flutter或者其他UI框架中,对单个绘制节点进行setDirty
来标记一个需要重新绘制节点,最后生成新的持续帧
来承接新的数据,在Flutter中通过setState
来实现。当我们明确了状态管理的起点
和终点
, 中间对于值的操作和缓存,diff算法,状态传递,状态转移过程等是这些状态管理框架们根据要解决的实际问题
来做的权衡
(trade off)。
- figure 1 状态管理的空间
需要权衡什么?
易用性
在Flutter开发中,有一句著名的话你可以不了解Flutter,但不能不知Getx
, 这点很像Java服务端的spring
框架, 提供了一套完备的开发工具,也许不是最合适于特定项目,但一定合适于简单项目
。那Getx
的权衡是什么? 笔者觉得托管
,完备
、简单
,是Getx的最大权衡,比如,你需要全局使用Getx的组件,来包裹所有的Widget,这样Getx就可以托管你的程序,依赖注入,主题,屏幕尺寸,路由都可以被Getx管理
。比如简单的Obx函数将StatefulWidget
封装,通过全局的NotifyManager
来注册,管理被捕获的值。
总的来说,Getx通过巧妙的架构设计,侵入式的托管式框架,确实做到了在简单项目中的面向Getx开发
。
所以我们知道在状态管理时,第一个权衡,简单
,好上手
。
与Getx简单相反的是Bloc
的复杂, Bloc 提供了全面且复杂
的状态管理模式,Predictable
可预测的以及Bloc的设计模式的结合使框架有很多的模版
,是的更细致
的状态更新管理,隔离的状态
称为可能, 后面会结合Riverpod
来解释这一优势。
状态传递
第一个需要权衡的点是状态转移
,如果使用原生StatefulWidget
,当我们Widget数开始庞大起来,不可避免的要进行组件的拆分
降低维护难度,或者出于重用
的目的,这样的操作势必会进行状态的转移
,在这个维度就是百花齐放了,Getx和Rivierpod是将依赖管理
和状态
相结合,通过全局的状态声明
和状态获取
来达到这个目的,例如
dart
/// 声明并加入到全局Scope
@riverpod
class DayChecked extends _$DayChecked {
@override
DateTime build() {
return DateTime.now();
}
}
/// 任意地方使用
final focusedDay = ref.watch(dayCheckedProvider); // 读取
ref.read(dayCheckedProvider.notifier).set(focusedDay) // 修改
// Getx用法
class ScheduleState {
Rx<DateTime> focusedDay = DateTime.now().obs;
}
class ScheduleLogic extends GetxController {
final ScheduleState state = ScheduleState();
}
// 使用
Get.find<ScheduleLogic>().state.focusedDay
全局的状态管理易用程度是中等的,Getx和flutter_hooks 都提供了局部的状态声明,这在Widget内部状态
处理时更方便的,hook的概念起源于React,状态管理相对于Getx是一种更轻量
的解决方案,稍后我们提及其他方面的优势。我们可以认为Hook更适合内部
状态变化。
全局的状态管理带来一个较为严重的问题,虽然可以通过组合别的方式来解决,但确实不够完备。
例如:TodoList中,任务的子父级
关系的,虽然是同一个页面,使用同一个状态管理类,但状态应该是独自的
,全局的状态管理解决这个问题的方法是通过入参
,这种形式,例如Getx中Get.find(tag)
和riverpod的build(params)
来生成多个状态管理.
dart
// 使用参数化区分不同状态
logic = Get.find<TaskDetailLogic>(tag: task.id);
ref.watch(taskDetailProvider(task.id));
这样会带来一个比较棘手的问题,我们必须将logic
或者参数tag
一级一级的传递,或者通过callback一级一级的回传操作,来保证状态的正确读取和修改,这点在复杂项目当中是很棘手的,当锚定
的入参过多时,我们会考虑父子Widget传递logic
和state
,保证状态的正确引用,但这丧失了灵活性和Widget重用的范围。
这种情况下,在全局状态和Widget内部状态中间,需要一种状态管理, 局部状态管理
的概念就能够很好的解决这个问题,Bloc
是这种方式的代表,通过BlocProvider
提供一个局部
, 被挂载到局部的状态,可以被context.read<SignInBloc>()
获取相关联的状态。riverpod也能做到局部状态,只是他的设计初衷并不是如此,所以使用局部状态时,会显得很蹩脚
,分离context,使用ref, 也导致官方并不推荐使用局部状态
的方式,这将会造成混乱。
figure 2 状态范围(Scope)
状态操作
使用Riverpod的同学会特别喜欢Flutter Hook, 因为riverpod对状态的声明和操作是分离的,尽管代码上他们封装在一起,但我们知道,这是OOP
的写法, 便于理解,因为在一些语言中,方法
和状态
是分离的,对象中内聚函数,第一个参数是Self
, 同样,生成式的Riverpod通过另一种方式实现了分离。如下
dart
@internal
abstract class NotifierBase<State> {
NotifierProviderElement<NotifierBase<State>, State> get _element;
@protected
@visibleForTesting
State get state {
_element.flush();
return _element.requireState;
}
@protected
@visibleForTesting
set state(State value) {
// ignore: invalid_use_of_protected_member
_element.setState(value);
}
@protected
Ref<State> get ref;
我们暂时关心如何通过设计达到这个目的,我们需要关心的是状态的操作是内部的
,也就是被限制的,在ReactHook中,类似于[set, get], 返回的值并没有直接修改的能力,相较于Getx的 1.obs
和 flutter hook的useState(1)
,是一个更加内聚的设计,但丧失了灵活性,两者需要在不同场景下结合使用
。
状态的声明有两种,基于Stream
或者Listenable
,两者都是响应式
中重要的组成部分,不同的状态管理框架会权衡选择或者组合使用,例如,riverpod是基于Listenable
, Bloc基于Stream
, Getx则是混合使用的,我们大部分情况下不需要考虑状态的声明
, 状态的修改是更影响开发体验,例如我们使用riverpod必须使用模版方法
改变内部的值,然后借用freezed生成不可变对象
, 保证值被完整的更新。或者实现equal
方法主动进行diff并通知值的变化。例如:bloc+freezed
dart
@freezed
class SignInState with _$SignInState {
const factory SignInState({
String? password,
required Option<String> passwordError,
}) = _SignInState;
factory SignInState.initial() => SignInState(
passwordError: none(),
);
}
class SignInBloc extends Bloc<SignInEvent, SignInState> {
SignInBloc() : super(SignInState.initial()) {
on<SignInEvent>((event, emit) async {
await event.map(
passwordChanged: (PasswordChanged value) {
emit(
state.copyWith(
password: value.password,
emailError: none(),
),
);
},
);
});
}
}
这种状态的修改对简单状态
修改来说比较冗余,对层级复杂状态的修改,freezed就会很累赘,试想一下一个三级以上
的嵌套, 例如:
dart
@freezed
class Task with _$Task {
const factory Task({
String? id,
Alert? alert,
}) = _Task;
}
@freezed
class Alert with _$Alert {
const factory Alert({
String? id,
Trigger? trigger,
}) = _Alert;
}
@freezed
class Trigger with _$Trigger {
const factory Trigger({
String? id,
required DateTime time,
}) = _Trigger;
}
@riverpod
class TaskDetail extends _$TaskDetail {
@override
Task build() {
return Task();
}
/// 修改其中的一个值
changeTrigger(DateTime dateTime) {
state = state.copyWith(
alert: state.alert?.copyWith(
trigger: state.alert?.trigger?.copyWith(time: dateTime)));
}
}
这是一个很常见的场景,在riverpod的官方文档中,我们可以看到将网络请求
作为状态
返回。这种在实际的复杂项目中是极其不推荐使用freezed或者说,引入不可变性
, 这会导致灾难,虽然可以使用unfreezed
放开部分权限,但这违背了freezed的设计初衷
。多层级且要修改 的Http请求并不适合作为状态返回,我们的状态管理,需要针对是ViewModel, 这种情况下,一般解决方案会有两种。
- 主动通知 ref.notifyListener
- 将多层级数据
结构展开(flat)
这取决于对数据操作
的频率和数据字段
的难度,需要在实际使用当中进行权衡,需要额外关注的是,如果我们需要记录状态的变化,则freezed的不可变性
会是更好的解决方案,需要优先考虑。
第二个常见的场景是需要持久化存储
的时候, 不可变性限制了很多,比如不能修改内部变量,不能添加方法(可以通过extension),不能够使用继承
, 例如 Hive 的Object就不能被继承来实现更改,当然,因为不可变的特性,也无法修改。
总的来说,在Getx中,模版中的state
概念类似ViewModel的载体
,内部是单独的小的state, 我们可以直接监听
小的state,这也是Getx很灵活的一点。其他的框架则更多偏向于state整体的管理和更新(bloc中cubit很像但需要太多模版代码),freezed的限制和使用可以参考我的另一篇文章,不再赘述,因为这种生成式
或者将来添加的宏
都会遇到类似的问题,所以多聊一点。
状态的结束
riverpod在官网提及自己的优势
是,自动生命周期管理。这点确实是状态管理的一个难点,也是开发者经常会忽略的一个点,当我们使用StatefulWidget
时我们并不需要考虑这个问题,因为state随着Widget结束而释放,但我们使用Stream
或者Listenable
时,如何释放,变成了一个棘手的问题,flutter hook通过包装的Element, LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>? _needDispose;
, 调用use时加入dispose列表。riverpod通过侵入Widget树,通过Element监听生命周期,并管理依赖。
dart
@protected
@mustCallSuper
void dispose() {
runOnDispose(); // 魔法
for (final sub in _dependencies.entries) {
sub.key._providerDependents.remove(this);
sub.key._onRemoveListener();
}
_dependencies.clear();
_externalDependents.clear();
}
其中runOnDispose()
就是riverpod的魔法
, 可以通过他来自动取消Http请求的UI相关的耗时作业
。 对于状态结束的监听和操作每个框架的实现细节不一致,但本质上却是要回归到Element 的生命周期函数,以及针对Stream和Listenable两种方式的Dispose的处理。
状态依赖和传递
我们大部分时间,大部分需求都是在处理简单任务
,例如,将数据库数据或服务接口整合,然后布局,填充样式,最终渲染
到页面。简单任务通常是简单的流程,这也是为什么官方更推荐riverpod
的原因,因为它更契合这种场景,我们不需要做业务层级的分离,只需要在某个地方声明某个网络请求provider
,然后监听结果,或者修改结果。这是非常理想的情况,稍微复杂的情况,就需要状态之间的依赖和传递了,例如这个场景,
这个场景下,任务列表
是依赖于选中日期
的,不同的状态处理框架提供了不同的解决方法,虽然本质上都是对Stream和Listenable的监听,但对于状态的依赖管理
和易用性
是有很大差别的,Getx的方式会比较普通,扩展了Stream的函数,但仍需要主动Listen
, riverpod的写法更巧妙,在语法上有很大的改善,比如当前这个场景。我们如果使用riverpod, 代码如下
dart
@Riverpod(dependencies: [DayChecked, TaskList])
class DayTasks extends _$DayTasks {
@override
Future<List<TaskModel>> build() async {
final checkedDay = ref.watch(dayCheckedProvider);
// get task by checkDay
retunr await serivce.get(checkedDay);
}
}
区别于flutter_hook的函数是不明确
的,例如实现相同功能,可能需要如下代码
dart
final checkedDate = useRef(DateTime.now());
final taskList = useState(<TaskModel>[]);
useEffect(() {
taskList.value = service.get(checkedDate);
}, [checkedDate.value]);
对于状态的传递,方程式的传递是相对优雅的,在数学上,我们知道y = 2x
, 或者 z = 3x + 2y + 1
这样的简单等式
, 能够优雅的以纯函数式
来实现状态的转移是极其优雅的,对于这样的依赖传递形式的状态转移方程,riverpod的实现要优雅且严谨很多,更不容易出错。设想一个场景,用户是否为VIP , 这是一个简单状态,大多数情况下比用户信息更频繁的去读取,这个字段也需要被设计到用户信息
接口,如果我们对UI层只提供简单的VIP状态,我们可以使用如下写法。
dart
@Riverpod()
class UserConifg extends _$UserConifg {
@override
UserModel build() {
return UserModel(avatar: '', id: "1", nickName: "nickName", pay: true);
}
}
@Riverpod(dependencies: [UserConifg])
class Vip extends _$Vip {
@override
bool build() {
final user = ref.watch(userConifgProvider);
return user.pay;
}
}
上述方式体现了这种方式的优雅,我们甚至可以将UserConifg放到别的包下,将Vip状态和UI关联,这种方式优雅的结构了复杂场景下的状态转移
和高内聚,低耦合
的原则。回到线性方程,它也符合y = 2x
代数方程的思维。
总结:
任何领域都大概率都没有银弹 , 软件开发领域也是如此,我们创造工具,使用工具,改进工具,才有软件的繁荣。不一定非要讨论那个框架或技术有高低差异。在实际开发中,稳定,熟悉是稳定三角的另外两个重要的方面,不同的框架的缺点,总会有一些或者优雅,或者败絮其中
的解决方案,在项目中,最重要的适合,合适的工具会让我们开发过程事半功倍
,其次是稳定性
和学习难度
, 不过,一切都需要合适的权衡(trade off)动态的去匹配当下最重要的事。