【Flutter 状态管理】第一论: 对状态管理的看法与理解
前言
由 编程技术交流圣地[-Flutter群-]
发起的 状态管理研究小组
,将就 状态管理
相关相关话题进行为期 两个月
的讨论。小组将于两个月后解散,并发布相关讨论成果。
目前只有内定的 5 个人参与讨论,如果你对状态管理
有什么独特的见解,或想参与其中。可以发表一篇自己对状态管理
的认知文章,作为入群的“门票”
,欢迎和我们共同交流。
前两周进行第一个话题的探讨 :
你对状态管理的看法与理解
状态管理,状态管理。顾名思义是状态+管理
,那问题来了,到底什么是状态?为什么要管理呢?
一、何谓状态
1. 对状态概念的思考
其实要说明一个东西是什么,是非常困难的。这并不像数学
中能给出具体的定义,比如
平行四边形: 是在同一个二维平面内,由两组平行线段组成的闭合图形
三角形: 是由同一平面内不在同一直线上的三条线段首尾顺次连接所组成的封闭图形
如果具有明确定义的概念,我们可以很容易理解它的特性和作用。但对于 状态
这种含义比较笼统的词汇,那就仁者见仁,智者见智
了。我查了一下,对于状态而言有如下解释:
状态是人或事物表现出来的形态。是指现实(或虚拟)事物处于生成、生存、发展、消亡时期
或各转化临界点时的形态或事物态势。
如果影射到编程上,状态就是界面各个时期的表现,状态的改变,通过刷新后会导致界面的变化。那 界面
和 状态
有什么区别和联系呢?
比如说一颗种子发芽、长大、开花、结果、枯萎,这是外在的表征,是外界所看到的形态变化。但从根本上来说,这些变化是种子与外界的资源交换
,导致的内部数据变化
,而产生的结果。也就是一个是 面子
,一个是 里子
。
看花人并不会在意种子的内部的变化逻辑,他们只需满足看花的需求就行了。 也就是说 界面是表现
,是用来给用户看的;状态是本质
,是需要编程者去维护的。如果一个开发者只能看到 面子
,而忽略我们本身就是那颗种子,还谈什么状态,想什么管理?。
2.状态、交互与界面
对一个应用而言,最根本的目的在于: 用户
通过操作界面, 可以进行正确的逻辑处理,并得到一定的响应反馈
。
从用户的角度来看,应用内部运作机制是个 黑盒
,用户不需要、也没必要了解细节。但这个黑盒内部逻辑处理需要编程者进行实现,我们是无法逃避的。
拿我们最熟悉的计数器而言,点击按钮,修改状态信息,重新构建后,实现界面上数字变化的效果。
二、为什么需要管理
说到 管理
一词,你觉得什么情况下需要管理?是 复杂
,只有 复杂
才有管理的必要。那管理有什么好处?
比如张三开了一家餐馆,雇了四个人,他们各干各的,都要同时进行招乎食客、烧菜、送快递、清洁等任务,那效率将非常低下。如果菜里吃出了不明生物 (bug),也不容易定位问题根源。这很像什么东西都塞在一个 XXXState
里去完成,其中不仅需要处理组件构建逻辑
,还掺杂着大量的业务逻辑
。
如果将复杂的事务,分层次地交由不同人进行处理,各司其职,要比四个人各干各的要高效。而管理的目的就是分层级
、提高地
处理任务。
1.状态的作用范围
首先来思考一个问题:是不是所有的状态都需要管理?比如说下面的 FloatingActionButton
,在点击时会有水波纹
的效果,界面的变化就意味着存在着状态的变化
。
但FloatingActionButton
组件继承自 StatelessWidget
,也就是说它并没有改变自身状态的能力。那点击时,为什么状态会发生变化呢?因为它在 build
中使用了 RawMaterialButton
组件,RawMaterialButton
中使用了 InkWell
,而 InkWell
继承自 InkResponse
,InkResponse
在 build
中使用了_InkResponseStateWidget
,这个组件中维护了水波纹在手势中的状态变化
逻辑。
class FloatingActionButton extends StatelessWidget{
---->[FloatingActionButton#build]----
Widget result = RawMaterialButton(
onPressed: onPressed,
mouseCursor: mouseCursor,
elevation: elevation,
focusElevation: focusElevation,
hoverElevation: hoverElevation,
highlightElevation: highlightElevation,
disabledElevation: disabledElevation,
constraints: sizeConstraints,
materialTapTargetSize: materialTapTargetSize,
fillColor: backgroundColor,
focusColor: focusColor,
hoverColor: hoverColor,
splashColor: splashColor,
textStyle: extendedTextStyle,
shape: shape,
clipBehavior: clipBehavior,
focusNode: focusNode,
autofocus: autofocus,
enableFeedback: enableFeedback,
child: resolvedChild,
);
也就是说:点击时,水波纹的变化效果,被封装在 _InkResponseStateWidget
组件状态中。像这种私有的状态,我们并不需要进行管理,因为它能够独立完成自己任务,而且外界并不需要了解这些状态。比如水波纹的圆心
、半径
等会变化的状态信息,在外界是不关心的。Flutter
中的 State
本身就是一种状态管理的手段。因为:
1. State 具有根据状态信息,构建组件的能力
2. State 具有重新构建组件的能力
所有的 StatefulWidget
都是这样,变化逻辑及状态量都会被封装在对应的 XXXState
类中。是局部的,私有的,外界无需了解内部状态的信息变化,也没有可以直接访问
的途径。这一般用于对组件的封装,将复杂且相对独立
的状态变化,封装起来,简化用户使用。
2.状态的共享及修改同步
上面说的 State
管理状态虽然非常小巧,方便。但同时也会存在不足之处,因为状态量被维护在 XXXState
内部,外界很难访问
或修改
。比如下面 page1
中,C
是数字信息,跳转到 page2
时,也要显示这个数值,且按下 R
按钮能要让 page1
、page2
的数字都重置为 0
。这就存在着状态存在共享及修改同步更新
,该如何实现呢?
我们先来写个如下的设置界面:
class SettingPage extends StatelessWidget {
const SettingPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('设置界面'),),
body: Container(
height: 54,
color: Colors.white,
child: Row(
children: [
const SizedBox(width: 10,),
Text('当前计数为:'),
Spacer(),
ElevatedButton(child: Text('重置'),onPressed: (){} ),
const SizedBox(width: 10,)
],
),
),
);
}
}
那如何知道当前的数值,以及如何将 重置
操作点击时,影响 page1
的数字状态呢?其实 构造入参
和 回调函数
可以解决一切的数据共享和修改同步
问题。
3.代码实现 - setState 版:源码位置
在点击重置
时 ,由于 page2
的计数也要清空,这就说明其状态量需要变化,要用 StatefulWidget
维护状态。在构造时,通过构造方法传入 initialCounter
,让 page2
的数字可以与 page1
一致。通过 onReset
回调函数来监听重置按钮的触发,以此来重置 page1
的数字状态,让 page1
的数字可以与 page2
一致。这就是让两个界面的同一状态量保持一致
。如下图:
class SettingPage extends StatefulWidget {
final int initialCounter;
final VoidCallback onReset;
const SettingPage({
Key? key,
required this.initialCounter,
required this.onReset,
}) : super(key: key);
@override
State<SettingPage> createState() => _SettingPageState();
}
跳转到设置页 | 设置页重置 |
---|---|
class _SettingPageState extends State<SettingPage> {
int _counter = 0;
@override
void initState() {
super.initState();
_counter = widget.initialCounter;
}
//构建同上, 略...
void _onReset() {
widget.onReset();
setState(() {
_counter = 0;
});
}
在 _SettingPageState
中维护 _counter
状态量,在点击 重置
时执行 _onReset
方法,触发 onReset
回调。在 界面1
中监听 onReset
,来重置 界面1
的数字状态。这样通过 构造入参
和 回调函数
,就能保证两个界面 数字状态信息
的同步。
---->[界面1 跳转代码]-----
Navigator.push(context,
MaterialPageRoute(builder: (context) => SettingPage(
initialCounter: _counter,
onReset: (){
setState(() {
_counter=0;
});
},
)));
但这样,确定也很明显,数据传来传去,调来调去,非常麻烦,乱就容易出错。如果再多几个需要共享的信息,或者在其他界面里还需要共享这个状态,那代码里将会更加混乱。
4.代码实现 - ValueListenableBuilder 版:源码位置
上面的 setState
版实现 数据共享和修改同步
,除了代码混乱之外,还有一些其他的缺点。首先,在 SettingPage
中我们又维护了一个状态信息,两个界面的信息虽然相同,却是两份一样的。如果状态信息是比较大的对象,这未免会造成不必要的内存浪费。
其次,就是深为大家诟病的 setState
重构范围。State#setState
执行后,会触发 build
方法重新构建组件。比如在 page1
中,_MyHomePageState#build
构建的是 Scaffold
,当状态变化时触发 setState
,其下的所有组件都会被构建一遍,重新构建的范围过大。
大家可以想一下,这里为什么不把 Scaffold
提到外面去?原因是:FloatingActionButton
组件需要修改状态量 _counter
并执行重新构建,所以不得不扩大构建的范围,来包含住 FloatingActionButton
。
其实 Flutter 中有个组件可以解决上面两个问题,那就是 ValueListenableBuilder
。使用方式很简单,先创建一个 ValueNotifier
的可监听对象 _counter
。
class _MyHomePageState extends State<MyHomePage> {
final ValueNotifier<int> _counter = ValueNotifier(0);
@override
void dispose() {
super.dispose();
_counter.dispose();
}
void _incrementCounter() {
_counter.value++;
}
如下使用 ValueListenableBuilder
组件,监听 _counter
对象,当该可监听对象的数值变化时,会可以通知监听者,重新构建 builder
方法里的组件。这样最大的好处在于:不需要
通过 _MyHomePageState#setState
对内部整体进行构建,仅对需要改变的局部
进行重新构建。
ValueListenableBuilder(
valueListenable: _counter,
builder: (ctx, int value, __) => Text(
'$value',
style: Theme.of(context).textTheme.headline4,
),
),
可以将 对于_counter
可见听对象传入 page2
中,同样通过 ValueListenableBuilder
监听 counter
。这就相当于观察者模式中,两个订阅者
同时监听一个发布者
。在 page2
中让发布者信息变化,也会通知两个订阅者
,比如执行 counter.value =0
,两处的 ValueListenableBuilder
都会触发局部重建。
这样就能达到和 setState 版
一样的效果,通过 ValueListenableBuilder
简化了入参和回调通知,并具有局部重构组件
的能力。可以说 State
在 状态的共享及修改同步
方面是被 ValueListenableBuilder
完胜的。但话说回来, State
本来就不是做这种事的,它更注重于私有状态的处理。比如ValueListenableBuilder
的本质,就是一个通过 State 实现的私有状态封装
,所以没有什么好不好,只有适合或不适合。
三、使用状态管理工具
1. 状态管理工具的必要性
其实前面的 ValueListenableBuilder
的效果以及不错了,但是在某些场合仍存在不足。因为 _counter
需要通过构造方法进行传递,如果状态量过多,或共享场合变多、传递层级过深,也会使代码处理比较复杂。最致命的一点是:业务逻辑处理
和界面组件
都耦合在 _MyHomePageState
中,这对于拓展
和维护
而言并不是件好事。所以 管理
对于 复杂逻辑性下的状态的共享及修改同步
是有必要的。
2.通过 flutter_bloc 实现状态管理: 源码位置
我们前面说过,状态管理的目的在于:让状态可以共享
及在更新状态时
可以同步更新相关组件显示,且将状态变化逻辑
和界面构建
进行分离。flutter_bloc
是实现状态管理的工具之一,它的核心是:通过 Bloc
将 Event
操作转化成 State
;同时通过 BlocBuilder
监听状态的变化,进行局部组件构建。
通过这种方式,编程者可以将 状态变化逻辑
集中在 Bloc
中处理。当事件触发时,通过发送 Event
指令,让 Bloc
驱动 State
进行变化。就这个小案例而言,主要有两个事件: 自加
和 重置
。像这样不需要参数的 Event
, 通过枚举进行区分即可,比如定义事件:
enum CountEvent {
add, // 自加
reset, // 重置
}
状态,就是界面构建需要依赖的信息
。这里定义 CountState
,持有 value
数值。
class CountState {
final int value;
const CountState({this.value = 0});
}
最后是 Bloc
,新版的 flutter_bloc
通过 on
监听事件,通过 emit
产出新状态。如下在构造中通过 on
来监听 CountEvent
事件,通过 _onCountEvent
方法进行处理,进行 CountState
的变化。当 event == CountEvent.add
时,会产出一个原状态 +1
的新 CountState
对象。
class CountBloc extends Bloc<CountEvent, CountState> {
CountBloc() : super(const CountState()){
on<CountEvent>(_onCountEvent);
}
void _onCountEvent(CountEvent event, Emitter<CountState> emit) {
if (event == CountEvent.add) {
emit(CountState(value: state.value + 1));
}
if (event == CountEvent.reset) {
emit (const CountState(value: 0));
}
}
}
画一个简单的示意图,如下:点击 _incrementCounter
时,只需要触发 CountEvent.add
指令即可。核心的状态处理逻辑会在 CountBloc
中进行,并生成新的状态,且通过 BlocBuilder
组件 触发局部更新
。这样,状态变化的逻辑
和界面构建的逻辑
就能够很好地分离。
// 发送自加事件指定
void _incrementCounter() {
BlocProvider.of<CountBloc>(context).add(CountEvent.add);
}
//构建数字 Text 处使用 BlocBuilder 局部更新:
BlocBuilder<CountBloc, CountState>(
builder: _buildCounterByState,
),
Widget _buildCounterByState(BuildContext context, CountState state) {
return Text(
'${state.value}',
style: Theme.of(context).textTheme.headline4,
);
}
这样,设置界面的 重置
按钮也是类似,只需要发出 CountEvent.reset
指令即可,核心的状态处理逻辑会在 CountBloc
中进行,并生成新的状态,且通过 BlocBuilder
组件 触发局部更新
。
由于 BlocProvider.of<CountBloc>(context)
获取 Bloc
对象,需要上级的上下文存在该 BlocProvider
,可以在最顶层进行提供。这样在任何界面中都可以获取该 Bloc
及对其状态进行共享。
这是个比较小的案例,可能无法体现 Bloc
的精髓,但作为一个入门级的体验还是挺不错的。你需要自己体会一下:
[1]. 状态的 [共享] 及 [修改状态] 时同步更新。
[2]. [状态变化逻辑] 和 [界面构建逻辑] 的分离。
个人认为,这两点是状态管理的核心。也许每个人都会有各自的认识,但至少你不能在不知道自己要管理什么的情况下,做着表面上认为是状态管理的事。最后总结一下我的观点:状态就是界面构建需要依赖的信息
;而管理,就是通过分工,让这些状态信息可以更容易维护
、更便于共享
、更好同步变化
及 更'高效'地运转
。flutter_bloc
只是 状态管理
的工具之一,而其他的工具,也不会脱离这个核心。
四、官方案例 - github_search 解读
1. 案例介绍:源码位置
为了让大家对 flutter_bloc
在逻辑分层上有更深的认识,这里选取了 flutter_bloc
官方的一个案例进行解读。下面先简单看一下界面效果:
[1] 输入字符进行搜索,界面显示 github 项目
[2] 在不同的状态下显示不同的界面,如未输入、搜索中、搜索成功、无数据。
[3] 输入时防抖 debounce。避免每输入一个字符都请求接口。
注: debounce
: 当调用动作 n 毫秒后,才会执行该动作,若在这 n 毫秒内又调用此动作则将重新计算执行时间。
搜索状态变化 | 无数据时状态显示 |
---|---|
项目结构
├── bloc # 处理状态变化逻辑
├── view # 处理视图构建
├── repository # 处理数据获取逻辑
└── main.dart # 程序入口
2.仓储层 repository
我们先来看一下仓储层 repository
,这是将数据获取逻辑
单独抽离出来,其中包含model
包下相关数据实体类 ,和 api
包下数据获取操作。
有人可能会问,业务逻辑都放在 Bloc
里处理不就行了吗,为什么非要搞个 repository
层。其实很任意理解,Bloc
核心是处理状态的变化,如果接口请求代码都放在 Bloc
里就显得非常臃肿。更重要的有点是: repository
层是相对独立的,你完全可以单独对进行测试,保证数据获取逻辑的正确性。
这样能带来另一个好处,当数据模型确定后。repository
层和界面层
完全可以同步进行开发
,最后通过 Bloc
层将 repository
和 界面
进行整合。分层是进行管理的一种手段,就像不同部门来处理不同的事务,一旦出错,就很容易定位是哪个环节出了问题。当一个部门的进行拓展升级,也能尽可能不波及其他部门。
repository
层也是通用的,不管是 Bloc
也好、Provider
也好,都只是管理的一种手段。repository
层作为数据的获取方式是完全独立的,比如 todo
的案例,Bloc
版和 Provider
可以共用一个 repository
层,因为即使框架的使用方式有差异,但数据的获取方式是不变的。
下面来简单看一下repository
层的逻辑,GithubRepository
依赖两个对象,只有一个 search
方法。其中 GithubCache
类型 cache
对象用于记录缓存,在查询时首先从缓存中查看,如果已存在,则返回缓存数据。否则使用 GithubClient
类型的 client
对象进行搜索。
GithubClient
主要通过 http
获取网络数据。
GithubClient
就是通过一个 Map
维护搜索字符
和搜索结果
的映射。这了处理的比较简单,完全可以基于此进行拓展:比如设置一个缓存数量上限,不然随着搜索缓存会一直加入;或将缓存加入数据库,支持离线缓存。将 repository
层独立出来后,这些功能的拓展就能和界面层解耦。因为界面只关心数据本身,并不关心数据如何缓存、如何获取。
3. bloc 层
首先来看事件,整个搜索功能只有一个事件:文字输入时的TextChanged
,事件触发时需要附带搜索的信息字符串。
abstract class GithubSearchEvent extends Equatable {
const GithubSearchEvent();
}
class TextChanged extends GithubSearchEvent {
const TextChanged({required this.text});
final String text;
@override
List<Object> get props => [text];
@override
String toString() => 'TextChanged { text: $text }';
}
至于状态,整个过程中有四类状态:
- [1].
SearchStateEmpty
: 输入字符为空时的状态,无维护数据。 - [2].
SearchStateLoading
: 从请求开始到响应中的等待状态,无维护数据。 - [3].
SearchStateSuccess
: 请求成功的状态,维护SearchResultItem
条目列表。 - [4].
SearchStateError
:失败状态,维护错误信息字符串。
最后是 Bloc
,用于整合状态变化的逻辑。在 构造方法
中通过 on
对 TextChanged
事件进行监听,触发 _onTextChanged
产出状态。比如 searchTerm.isEmpty
说明无字符输入,产出 SearchStateEmpty
状态。在 githubRepository.search
获取数据前,产出 SearchStateLoading
表示等待状态。请求成功则产出 SearchStateSuccess
状态,且内含结果数据,失败则产出 SearchStateError
状态。
class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required this.githubRepository})
: super(SearchStateEmpty()) {
on<TextChanged>(_onTextChanged);
}
final GithubRepository githubRepository;
void _onTextChanged(
TextChanged event,
Emitter<GithubSearchState> emit,
) async {
final searchTerm = event.text;
if (searchTerm.isEmpty) return emit(SearchStateEmpty());
emit(SearchStateLoading());
try {
final results = await githubRepository.search(searchTerm);
emit(SearchStateSuccess(results.items));
} catch (error) {
emit(error is SearchResultError
? SearchStateError(error.message)
: const SearchStateError('something went wrong'));
}
}
}
到这里,整个业务逻辑就完成了,不同时刻的状态变化也已经完成,接下来只需要通过 BlocBuilder
监听状态变化,构建组件即可。另外说明一下 debounce
的作用:如果不进行防抖处理,每次输入字符都会触发请求获取数据,这样会造成请求非常频繁,而且过程中的输入大多数是无用的。这种情况,就可以使用 debounce
进行处理,比如,输入 300 ms 后才进行请求操作,如果在此期间有新的输入,就重新计时。
其本质是对流的转换操作,在 stream_transform
插件中有相关处理,在 pubspec.yaml
中添加依赖
stream_transform: ^2.0.0
在 on<TextChanged>
的 transformer
参数中可以指定事件流转换器
,这样就能完成防抖效果:
const Duration _duration = Duration(milliseconds: 300);
EventTransformer<Event> debounce<Event>(Duration duration) {
return (events, mapper) => events.debounce(duration).switchMap(mapper);
}
class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required this.githubRepository})
: super(SearchStateEmpty()) {
// 使用 debounce 进行转换
on<TextChanged>(_onTextChanged, transformer: debounce(_duration));
}
4.界面层
界面层的处理非常简单,通过 BlocBuilder
监听状态变化,根据不同的状态构建不同的界面元素即可。
事件的触发,是在文字输入时。输入框被单独封装成 SearchBar
组件,在 TextField
的 onChanged
方法中,触发 _githubSearchBloc
的 TextChanged
方法,这样驱动点,让整个状态变化的“齿轮组”
运转了起来。
---->[search_bar.dart]----
@override
void initState() {
super.initState();
_githubSearchBloc = context.read<GithubSearchBloc>();
}
return TextField(
//....
onChanged: (text) {
_githubSearchBloc.add(TextChanged(text: text));
},
这样一个简单的搜索需求就完成了,flutter_bloc
还通过了非常多的实例、文档,有兴趣的可以自己多研究研究。
五、小结
这里小结一下我对状态管理的理解:
[1]. [状态] 是界面构建需要依赖的信息。
[2]. [管理] 是对复杂场景的分层处理,使[状态变化逻辑]独立于[视图构建逻辑]。
再回到那个最初的问题,是所有的状态都需要管理吗?如何区分哪些状态需要管理?就像前端 redux
状态管理,在 《You Might Not Need Redux
》 (可自行百度译文) 中说到:人们常常在正真需要 Redux 之前,就选择使用它
。对于状态管理,其实都是这样,往往初学者 "趋之若鹜"
,不明白为什么要状态管理,为什么一个很简单的功能,非要弯弯绕绕一大圈来实现。就是看到别用了,使用我也要用,这是不理智的。
我们在使用前应该明白:
[1]. 状态是否需要被共享和修改同步。如果否,也许通过 [State] 封装为内部状态是更好的选择。
[2]. [业务逻辑] 和[界面状态变化] 是否复杂到有分层的必要。如果不是非常复杂,
FutureBuilder、ValueListenableBuilder 这种小巧的局部构建组件也许是更好的选择。
作者:张风捷特烈
链接:https://juejin.cn/post/7012032007110656013
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。