那个评论最多的Issue
关注Flutter的同学们可能经常会去Github上看看Flutter现状。现在star数量已经是10.4w了,但是,近一年以来处于open状态的issue数量一直徘徊在7k+。这一方面说明Flutter确实火爆,另一方面open issue这平稳的走势也确实让广大开发者对Flutter的未来有些许担心。这个问题可能大家各自会有不同的看法,这里我就不展开说了。
这7k多open(以及37k+closed)的issue中,评论最多就是这条:Reusing state logic is either too verbose or too difficult,在我写这篇文章的时候已经有407条评论。足足是评论数第二多issue的两倍还有余。issue的提出者是@rrousselGit,他是Flutter官方推荐的状态管理库Provider的作者,也是flutter_hook的作者。
到底是什么样的issue这么的火爆呢?把上面的issue标题翻译过来就是复用状态逻辑要么太麻烦要么太困难。状态逻辑是什么,太麻烦和太困难又是指什么呢?由于篇幅有限,这里就不引用issue的全部内容。感兴趣的同学可以点上面的链接看全文。但我的感觉是这个issue想表达的东西和我们这些Flutter开发者息息相关,以后有可能会完全改变当前的开发方式。所以希望大家能早点关注,以便为未来的变化做好准备。以下就状态逻辑复用方式的问题做一个介绍。
状态逻辑复用问题
我们都知道Flutter体系里有两种Widget
,无状态的StatelessWidget
和有状态的StatefulWidget
。Widget
是不可变的。如果需要在Element
生命周期内拥有可变的状态,那就只好把这些可变的东西都塞进State
里面了。可变的状态其实就是个时间的函数,S = f(t)
。如果说S
是状态值,那么这个函数f()
就是状态逻辑了,而时间t
的取值范围是Element
的生命周期。可变状态值是状态逻辑的时间函数值。这里的状态逻辑在我们实际开发中遇到的可能是从网络获取数据,加载图片,播放动画等等。所以这里讨论的复用状态逻辑就是在讨论这个f()
如何在不同的Widget
之间复用。
那我们先来看看原生Flutter中如何来做复用。这里假设我们有一个自己实现的特殊的网络请求类MyRequest
,在我们的app中只要是网络请求都需要使用这个类。那么一般的实现是这个样子的:
class Example extends StatefulWidget {
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
MyRequest _myRequest = MyRequest();
@override
void initState() {
super.initState();
_myRequest.start();
}
@override
void dispose() {
_myRequest.cancel();
super.dispose();
}
}
我们需要自定义一个StatefulWidget
类和一个对应的State
类。在State
内部实例化MyRequest
, 在initState
和dispose
内分别做初始化和清理释放。
要复用的话就需要把上面做的事情在其他Widget
那里重复。情况可能会再稍微复杂一些,上面的例子Example
这个Widget内部没有任何属性,它的State
没有对外依赖。所以上面的实现没什么问题。但当我们的请求需要外部传入一个用户名uerId
的时候。可能就变成下面这样的了:
class Example extends StatefulWidget {
//多了个userId.
final userId;
const Example({Key key, @required this.userId})
: assert(userId != null),
super(key: key);
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example> {
MyRequest _myRequest = MyRequest();
@override
void initState() {
super.initState();
_myRequest.start(widget.userId);
}
// 需要重写didUpdateWidget。当userId变化的时候重新做请求
@override
void didUpdateWidget(Example oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.userId != oldWidget.userId) {
_myRequest.cancel();
_myRequest.start(widget.userId);
}
}
@override
void dispose() {
_myRequest.cancel();
super.dispose();
}
}
多了个userId
之后,我们就需要重写didUpdateWidget
了。状态逻辑的复用就更加复杂和繁琐了。
更进一步,如果State
中有多个请求,那复杂度就更上一个台阶了。如果要添加/删除一个MyRequest
就需要至少在initState
,didUpdateWidget
和dispose
等函数中做操作。因为一个StatefulWidget
对应一个State
,所以复用其实就是在做零碎的复制粘贴。这显然是繁琐且容易出bug的操作。
解决方案是怎样的
通过上面的分析。我们就可以得出解决方案要满足哪些标准了,新方案以下就称为“模块”吧。
首先,就是“模块”应该是包含有一块独立的状态逻辑。比如上面说的一个网络请求,一次IO操作等等。“模块”应该是与UI无关的,所以“模块”内部最好不依赖于外部的Widget
。
其次,就是我们也看到了,原生方式繁琐复杂的一个原因是一个独立的状态逻辑被切分开来分散到了State
的生命周期函数中了。所以新的方案最好能让程序自己去处理“模块”的生命周期回调而不需要用户手动操作。
再次,“模块”可以组合起来提供更复杂的状态逻辑。也就是说如果状态逻辑可以被表达为S = f(t)
,那么组合起来看起来会是这样的S = f(a(t),t)
或者S = f(a(t),b(t),t)
。Widget
其实就是这样组合起来的。
最后,就是新方案在性能上不能有不可接受的下降。不管是在时间(响应)还是空间(内存)方面都要对比原生做法不能有较大的降低。
总结下来就是以下几点:
- 独立性,“模块”包含一个独立的状态逻辑。
- 自管理,自动处理
initState
等生命周期。 - 可组合,“模块”可以组合起来提供更复杂的状态逻辑
- 性能优,性能上不应该有不可接受的劣化。
可能的解决方案
明确了目标以后,接下来看看issue中讨论的解决方案有哪些,有什么样的优缺点。
Mixin
使用Mixin改造以后的状态逻辑可能是像这样的:
mixin MyRequestMixin<T extends StatefulWidget> on State<T> {
MyRequest _myRequest = MyRequest();
MyRequest get myRequest => _myRequest;
@override
void initState() {
super.initState();
_myRequest.start();
}
@override
void dispose() {
_myRequest.cancel();
super.dispose();
}
}
使用起来是这样的:
class Example extends StatefulWidget {
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example> with MyRequestMixin<Example> {
@override
Widget build(BuildContext context) {
final data = myRequest.data;
return Text('$data');
}
}
关于具体如何使用Mixin来分离视图与数据,可以参考我的另外一篇文章。
- Mixin方式满足上述条件中的独立性和性能优两个指标,但是自管理只是部分满足。当
Widget
里不含有Mixin需要的参数的时候是没有问题的。可当Widget
里含有Mixin需要的参数的时候,例如上面说的userId
。那么代码就飘红了:
- 组合能力方面也有缺陷。一个
State
只能混入一个同类型的Mixin。所以给一个State
混入多个同类型的状态逻辑是不可行的。 - 还有一个缺陷是当不同的Mixin定义了相同的属性时会造成冲突。
Builder
Buidler模式其实在Flutter框架里面已经有很多现成的例子,比如StreamBuilder
,FutureBuilder
等等。
使用Builder改造以后的MyRequest
状态逻辑可能是像这样的:
class MyRequestBuilder extends StatefulWidget {
final userId;
final Widget Function(BuildContext, MyRequest) builder;
const MyRequestBuilder({Key key, @required this.userId, this.builder})
: assert(userId != null),
assert(builder != null),
super(key: key);
@override
_MyRequestBuilderState createState() => _MyRequestBuilderState();
}
class _MyRequestBuilderState extends State<MyRequestBuilder> {
MyRequest _myRequest = MyRequest();
@override
void initState() {
super.initState();
_myRequest.start(widget.userId);
}
@override
void didUpdateWidget(MyRequestBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.userId != oldWidget.userId) {
_myRequest.cancel();
_myRequest.start(widget.userId);
}
}
@override
void dispose() {
_myRequest.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _myRequest);
}
}
用起来是这样的:
class Example extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MyRequestBuilder(
userId: "Tom",
builder: (context, request) {
return Container();
},
);
}
}
可见,Builder模式基本上是满足上面那几个条件的。是一种目前看来可行的状态逻辑复用方式。但也有另外几个缺陷:
- 多个Builder组合起来的时候代码可读性下降:
class Example extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MyRequestBuilder(
userId: "Tom",
builder: (context, request1) {
return MyRequestBuilder(
userId: "Jerry",
builder: (context, request2) {
return Container();
},
);
},
);
}
}
Builder模式其实并不是一种优雅的解决办法。本来是并列关系的状态逻辑被组合成了父子关系。我们想要的应该是这样的而不是嵌套起来的模式:
MyRequest request1 = MyRequest();
MyRequest request2 = MyRequest();
... //使用request1和request2
这就是Builder模式的一个缺陷,如果嵌套的Builder比较多的话缩进会非常难看。
- 在
Element
树里增加了节点。可能对性能有一些影响。 - 最后就是状态逻辑无法在Builder之外不可见。外层
build
函数无法直接访问request1
,一种变通办法就是使用GlobalKey
,但这样的话复杂性又增加了。
Properties/MicroState
这个解决方案是把状态逻辑封装到一个个类似于State
的类里面,称之为Property
,使用的时候会把封装好的Property
集中安装到宿主State
中,然后由宿主State
来自动处理Property
的生命周期回调。
封装长这样:
// Property 接口,和State生命周期回调一致
abstract class Property {
void initState();
void dispose();
}
// Property实现
class MyRequestProperty extends Property {
MyRequest _myRequest = MyRequest();
@override
void initState() {
_myRequest.start();
}
@override
void dispose() {
_myRequest.cancel();
}
}
// 宿主 State
abstract class PropertyManager extends State {
// 复用的状态逻辑保存在这里
final properties = <Property>[];
@override
void initState() {
super.initState();
// 遍历回调initState
for (final property in properties) {
property.initState();
}
}
@override
void dispose() {
super.dispose();
// 遍历回调dispose
for (final property in properties) {
property.dispose();
}
}
}
这种模式的关键点其实就是在宿主State
中用一个列表来保存添加进来的Property
。然后宿主在自己的生命周期回调里遍历Property
,然后调用它们相应的回调函数。可见这种方式满足独立性和性能方面的要求,自管理在不依赖Widget
属性的情况下也还行,但是当有像userId
这样的依赖的时候则需要宿主重写didUpdateWidget
,提取出依赖的属性然后再发送给对应的Property
。可见Property
方式也有和Mixin类似的缺陷。另外在组合方面,当Property
是并列关系的时候也没什么问题,但是如果要把几个Property
组合成大一点的Property
就比较麻烦一些了。
Hooks
最后就是这个评论数最高issue的主角了,Hooks。如果引入Hooks的话,MyRequest的状态逻辑复用就会变成下面这个样子了:
// 不再需要StatefulWidget
class MyRequestWidget extends HookWidget {
final userId;
const MyRequestBuilder({Key key, @required this.userId)
: assert(userId != null),
super(key: key);
@override
Widget build(BuildContext context) {
// 一个函数搞定一切
final myRequest = useMyRequest(userId: userId);
return Container();
}
}
瞬间变得无比简洁有木有?没有initState
,didUpdateWidget
和dispose
等生命周期回调,没有Builder那样的嵌套,没有零碎的复制粘贴,甚至连StatefulWidget
也都不再需要了。只需要在build
中加一行useXXX
函数就可以了。独立性,自管理,性能都不存在问题,组合性上也不存在问题。具体可以参考我之前介绍Hooks的文章《Flutter Hooks 使用及原理》。
缺点嘛就是Hooks太过激进(简洁),有些方面和Flutter的理念是相抵触的。从State
的设计就能看出来,每个生命周期回调都给你整的明明白白,什么阶段做什么事情,都让开发者能自己掌控。而现在呢?没有了生命周期,没有了State
,所有这一切全部被一个build
函数里的useXXX
所替代。这可能会让习惯掌控生命周期的开发者感到惶恐,这个函数的背后到底发生了什么?会不会有什么不可预知的后果?我们一直都谨记在build
函数中不可以调用复杂耗时函数,build
函数应该保持纯净,只能做和构建相关的事情,其他的初始化,清理等等工作应该在相应的回调里去做才对啊。可是这里的useXXX
似乎把这些活全都安排了,这不合适吧。
这也就是这个issue能一口气盖了4百多层楼的原因,其实背后就是这两种理念(甚至是OOP与FP之间)的交锋。
通过围观我们能学到什么
通常我们学习新技术的时候都是去看别人写好的文档,去研读别人写好的源代码。照猫画虎的写一写自己的代码,这样下来只能说是会用了而已。文档和源代码都已经是成品,你看到的成品是一个样子,但背后可能会有很多的草稿,为什么这个设计能脱颖而出,必定是通过不停的交流和迭代才击败了其他竞争者。我们看到的掌握了API只能说是知其然可能却不知其所以然。要知其所以然,就要参与到设计过程中去,即使还不能提出自己的观点,但持续关注各方大牛的讨论也绝对受益匪浅。
通过对一个问题的剖析能了解到更多的信息,之前可能是知其一而不知其二,但是通过围观可能会获得我们不知道的其二。
通过对一个解决方案的正反两方面的交锋,能更清楚的知道其来龙去脉,优缺点。
通过对正方两方互相交换意见(吵架)的围观可以学习到怎样的交流方式是建设性的,错误的交流方式会使交流越来越偏离交流的目的。不仅浪费时间,而且对团队,组织,或社区有破坏性作用,是要避免的。
通过围观也可以学到如何来掌控交流的方向,敏锐察觉交流进程中的异常状况,如何及时采取措施确保交流回到正确的轨道上来。
所以我建议大家,有事没事多多关注业界新动向,不仅仅是这个具体的issue,确实能学到看文档得不到的知识。
最后,回到本文这个issue。对于hooks背后两种理念的争议我也没有结论,但是我建议大家可以自己来评估一下,也许在自己的项目里用hooks做那么一两个页面尝试一下,梨子是啥滋味总归要自己尝一下。不过据有些人说,hooks属于那种用了就回不去类型的:)另外,除了React, Vue、iOS SwiftUI以及Android Jetpack Compose也都引入了类似hooks的实现。
(全文完)