注册

【Flutter 状态管理】第一论: 对状态管理的看法与理解

前言

编程技术交流圣地[-Flutter群-] 发起的 状态管理研究小组,将就 状态管理 相关相关话题进行为期 两个月 的讨论。小组将于两个月后解散,并发布相关讨论成果。



目前只有内定的 5 个人参与讨论,如果你对状态管理有什么独特的见解,或想参与其中。可以发表一篇自己对状态管理的认知文章,作为入群的“门票”,欢迎和我们共同交流。




前两周进行第一个话题的探讨 :


你对状态管理的看法与理解



状态管理,状态管理。顾名思义是状态+管理,那问题来了,到底什么是状态?为什么要管理呢?


一、何谓状态


1. 对状态概念的思考

其实要说明一个东西是什么,是非常困难的。这并不像数学中能给出具体的定义,比如


平行四边形: 是在同一个二维平面内,由两组平行线段组成的闭合图形
三角形: 是由同一平面内不在同一直线上的三条线段首尾顺次连接所组成的封闭图形

如果具有明确定义的概念,我们可以很容易理解它的特性和作用。但对于 状态 这种含义比较笼统的词汇,那就仁者见仁,智者见智 了。我查了一下,对于状态而言有如下解释:


状态是人或事物表现出来的形态。是指现实(或虚拟)事物处于生成、生存、发展、消亡时期
或各转化临界点时的形态或事物态势。

如果影射到编程上,状态就是界面各个时期的表现,状态的改变,通过刷新后会导致界面的变化。那 界面状态 有什么区别和联系呢?


比如说一颗种子发芽、长大、开花、结果、枯萎,这是外在的表征,是外界所看到的形态变化。但从根本上来说,这些变化是种子与外界的资源交换,导致的内部数据变化,而产生的结果。也就是一个是 面子 ,一个是 里子


看花人并不会在意种子的内部的变化逻辑,他们只需满足看花的需求就行了。 也就是说 界面是表现 ,是用来给用户看的;状态是本质 ,是需要编程者去维护的。如果一个开发者只能看到 面子 ,而忽略我们本身就是那颗种子,还谈什么状态,想什么管理?。




2.状态、交互与界面

对一个应用而言,最根本的目的在于: 用户 通过操作界面, 可以进行正确的逻辑处理,并得到一定的响应反馈





从用户的角度来看,应用内部运作机制是个 黑盒,用户不需要、也没必要了解细节。但这个黑盒内部逻辑处理需要编程者进行实现,我们是无法逃避的。



拿我们最熟悉的计数器而言,点击按钮,修改状态信息,重新构建后,实现界面上数字变化的效果。





二、为什么需要管理


说到 管理 一词,你觉得什么情况下需要管理?是 复杂,只有 复杂 才有管理的必要。那管理有什么好处?


比如张三开了一家餐馆,雇了四个人,他们各干各的,都要同时进行招乎食客、烧菜、送快递、清洁等任务,那效率将非常低下。如果菜里吃出了不明生物 (bug),也不容易定位问题根源。这很像什么东西都塞在一个 XXXState 里去完成,其中不仅需要处理组件构建逻辑,还掺杂着大量的业务逻辑


如果将复杂的事务,分层次地交由不同人进行处理,各司其职,要比四个人各干各的要高效。而管理的目的就是分层级提高地 处理任务。




1.状态的作用范围

首先来思考一个问题:是不是所有的状态都需要管理?比如说下面的 FloatingActionButton ,在点击时会有水波纹的效果,界面的变化就意味着存在着状态的变化



FloatingActionButton 组件继承自 StatelessWidget,也就是说它并没有改变自身状态的能力。那点击时,为什么状态会发生变化呢?因为它在 build 中使用了 RawMaterialButton 组件,RawMaterialButton 中使用了 InkWell ,而 InkWell 继承自 InkResponseInkResponsebuild 中使用了_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 按钮能要让 page1page2 的数字都重置为 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 是实现状态管理的工具之一,它的核心是:通过 BlocEvent 操作转化成 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,用于整合状态变化的逻辑。在 构造方法 中通过 onTextChanged 事件进行监听,触发 _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 组件,在 TextFieldonChanged 方法中,触发 _githubSearchBlocTextChanged 方法,这样驱动点,让整个状态变化的“齿轮组”运转了起来。


---->[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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册