likes
comments
collection
share

围观Github上Flutter评论最多的Issue.

作者站长头像
站长
· 阅读数 16

那个评论最多的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的作者。 围观Github上Flutter评论最多的Issue. 到底是什么样的issue这么的火爆呢?把上面的issue标题翻译过来就是复用状态逻辑要么太麻烦要么太困难。状态逻辑是什么,太麻烦和太困难又是指什么呢?由于篇幅有限,这里就不引用issue的全部内容。感兴趣的同学可以点上面的链接看全文。但我的感觉是这个issue想表达的东西和我们这些Flutter开发者息息相关,以后有可能会完全改变当前的开发方式。所以希望大家能早点关注,以便为未来的变化做好准备。以下就状态逻辑复用方式的问题做一个介绍。

状态逻辑复用问题

我们都知道Flutter体系里有两种Widget,无状态的StatelessWidget和有状态的StatefulWidgetWidget是不可变的。如果需要在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, 在initStatedispose内分别做初始化和清理释放。

要复用的话就需要把上面做的事情在其他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就需要至少在initStatedidUpdateWidgetdispose等函数中做操作。因为一个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方式满足上述条件中的独立性和性能优两个指标,但是自管理只是部分满足。当Widget里不含有Mixin需要的参数的时候是没有问题的。可当Widget里含有Mixin需要的参数的时候,例如上面说的userId。那么代码就飘红了: 围观Github上Flutter评论最多的Issue.
  • 组合能力方面也有缺陷。一个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();
  }
}

缺点嘛就是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的实现。

(全文完)