深入理解声明式界面编写的思维方式,探讨Flutter的设计模式

看到有很多同学仍然使用命令式思维方式写Flutter的声明式UI,希望通过这篇文章帮助他们扭转思维方式。

我们知道,flutter是”声明式“UI, 声明式UI显著的特点是UI界面的布局和样式在一个代码块中集中声明,你无法”直接“控制某个控件的样式和数据,而只能通过改变你的”状态“,并通过声明的关联达到更新和控制UI的目的。 由此为出发点,我们得到Flutter设计模式的两个核心目标:

1.基于这个"声明式"UI的约束的条件,强制我们把UI界面抽象为(状态State),我们需要做的是做好业务数据和状态的关联以及更新通知。

2.抽象无法脱离实现存在,本质上每个状态"State"是对应了UI界面上的一块区域与逻辑,为了追求更合理的性能和最小更新UI的原则,我们的状态"State"应做好合理的划分,避免所有的逻辑集中在一个大模块中。

为了巩固对以上两点的理解,我们画了图进行额外说明和巩固: 基于声明式UI约束下的 State和业务逻辑的关系:

 

对State的合理划分能降低代码复杂度,也能对更新区域做细粒度区分:

 

细心的你会发现,Flutter的逻辑和UI的关系图,不就是一个MVP的设计模式吗?

没错,本质上两者是非常相似的,但是Flutter一定程度上巧妙的弥补了MVP中Presenter过于庞大的缺点(Presenter容易变得庞大),说他巧妙也行,说他流氓也行,因为如果你的State变得庞大的时候,性能就会出现明显的下降(无法最小化更新UI),所以你会迫不得已的把State进行更加合理的划分。当然,如果你是一个不羁少年,对性能和代码可读性有着独特的见解,写出庞大的State,业务也是能照样上线。

一定程度上,声明式UI的设计模式是MVP的进化版。

在简单的场景中(大部分场景),你可以直接将AppData(Model)作为State的成员变量存储。但是你要清楚这部分数据其实是Model部分。

读到这里,是不是感觉Flutter的设计模式也不外乎如此嘛,是的,虽然上面的讨论没有区分StateLessWidget和Widget这些过于细节的区别,但是不阻碍理解核心思想并准确掌握。

显然我们发现,State和Widget之间的通信和逻辑是玩不出花样的(这样多好)。 所以接下来,我们看看AppData和State之间是怎么玩出花的。

首先,我们思考AppData放在哪里,知道AppData放在哪里就自然知道如何被State Access。 最差劲的做法是把AppData放到一个单例里。如下图:

 说他差劲,是因为无法支持“多实例”。比如产品希望直接使用“我的页面中的个人信息模块”用于直接展示好友信息,但是你代码中的个人信息数据只能通过单例获取,无法复用,只能重新开发。

画外音:很多业务代码存在大量单例的数据被State直接使用。很多时候确实很便捷,但是要知道这样做不好。

为了支持多实例,我们可以考虑给一个界面数关联一个数据类,如下图:

这种方式基本能满足目前所有的业务需求了,但是一些特殊场景不好满足,比如:

如果想要满足这种需求,我们可以尝试直接把业务数据AppData(图中的主题Data)挂到界面树的结点下,让孩子取到的永远是父节点的业务数据就好了:

只需要在合适的地方插入一个主题Data节点,就能轻松完成需求。

说到这里你会发现,这不就是Flutter的InheritedWidget吗?

我们可以直接使用InheritedWidget把我们的业务数据挂到界面树上,孩子使用dependOnInheritedWidgetOfExactType就可以获取离自己最近的业务数据(AppData)了。 为了让代码更加好看,Flutter约定俗称在业务数据类提供静态方法of来提高代码可读性。

"State" Acces "AppData"(Model) 的部分我们到此已经很好的解决了。 of函数调用的dependOnInheritedWidgetOfExactType直接注册了更新依赖,界面树rebuild的时候,可以通过复写updateShouldNotify判断数据是否发生变化从而减少孩子UI的重建。

使用InheritedWidget需要使用SetState触发更新你“无法”在业务AppData(Model)触发InheritedWidget关联的孩子进行更新(不是绝对的)。

第一次接触 InheritedWidget 我感到很奇怪,为什么不暴露 notifyChange 之类的接口,能让业务随时能直接更新对应的UI。
一定程度上,这个设计是Flutter对声明式UI的践行,即UI=f(state); 你永远都只应该关注更新你的state,别想其他旁门左道直接更新UI
虽然你知道你在命令式UI模式下更新一个数据只需要更新特定的某个Widget,这样很直观也很高效,但是在Flutter你还是要SetState一下,让框架来计算那个特定的某个Widget是谁,这就是声明式UI的游戏规则。
为了让框架高效计算出那些特定的Widget需要更新,你需要仔细实现 updateShouldNotify的接口。不要偷懒哦!
看似多此一举的做法,其实很好的解决了命令式UI容易漏更新UI的问题(一个数据需要更新一个UI控件还好,假如需要N个UI控件,并且还有更深的相关依赖UI控件需要更新时,框架帮你计算更新Widget的优势就凸显出来了)。

InheritedWidget虽然没有直接暴露 notifyChange 之类的接口,但是Flutter也提供了一个类满足我们类似的场景(不过确实很绕,让你看不出一点命令式UI的影子。后面Provider库提供了更为友好的方法)。 那就是接下来出场的是InheritedNotifier

InheritedNotifier(使用例子 参考inherited_notifier.0.dart,Flutter框架内部的动画,滚动等内置的controller都支持作为notifier激活事件):

InheritedNotifier是Flutter框架中内置的UI事件驱动UI更新的核心类。如果你需要根据滚动位置或者动画进度做一些额外的逻辑,你就会自然的用到它。 InheritedNotifier构造时你需要提供了一个notifier,这个notify就是刚刚我们寻找的notifyChange 之类的接口, 你在任何地方调用notifier,就能触发其依赖的Widget的更新。

很多时候,我们的AppData(Model)是复杂的,经常是局部少量数据更新。无论是dependOnInheritedWidgetOfExactType还是InheritedNotifier,每次更新是不知道哪部分更新,所有依赖的Widget都会重建自己,很多场景下,特定的UI只和特定的部分数据有关联,因此大部分场景我们不需要大部分孩子进行更新。 因此,知道什么更新,可以更新部分的 InheritedModel 应运而生:

至此,Flutter官方推荐的设计模式已经浮出水面。

简单用图表示我们刚刚讨论的结论:

静态的of函数通过调用dependOnInheritedWidgetOfExactType达到两个作用:

  • 1.关联类型和context的依赖。(方便后面的InheritedNotifier进行更新)

  • 2.返回最近一个父节点XXXXModel的实例。

在上面这张图中,由于UserName的TextWidget和HeadImage的ImageWidget在同一个build上下文中创建,关联的是同一个context,因此即使是使用updateShouldNotifyDependent(“HeadImage”)一样会导致两个元素UI重新创建。 为此,我们要这样,让需要不在同一时机更新的Widget放在不同的上下文中(context):

Flutter提供Builder(WidgetBuilder)能很方便的进行context隔离,Builder源码如下:

是个很常用的帮助类。

由此上面的讨论可知,合理的划分Widget(合理隔离context)是非常重要的。 因此在官方的建议中,是 不应该 在一个Build写出几百行的Widget树的,这样无法细粒度控制UI更新不说,还让代码变得隐晦难读,地狱嵌套也就出来了。 所以当有人像你吐槽Flutter地狱嵌套的问题时,不要急着反驳,有时是理解角度不同。

书归正传,我们讨论的是Flutter设计模式,总结一下刚刚我们的结论:

  • 1.官方给出的最佳指引是使用InheritedWidget、对业务数据进行访问(access)。

  • 2.在SetState时框架会根据你提供的updateShouldNotify避免不必要的更新,但是InheritedWidget自己无法主动更新被依赖的UI。

  • 3.使用InheritedNotifier实现数据驱动UI更新(notify)。

  • 4.使用InheritedModel合理的划分(aspect块),并通过添加合理的Widget进行隔离,实现精细化控制UI更新

额外说明一点,在Widget体系中,我们可以方便的使用dependOnInheritedWidgetOfExactType函数建立的更新机制; 那么如果不在Widget体系里,我们可以使用:Stream 体系提供的事件流达到类似的Notify的效果,从设计的角度来看,Stream的Notify体系更加完备:

很多业务或者UI框架都大量使用Stream 体系提供的事件流达到类似的Notify(使用StreamBuilder)。

读到这里,你对官方给出的答卷满意吗? 一定程度上,官方交出的答卷并不如人意。原因列举如下:

1.InheritedWidget的使用太重了,需要创建一个新类,写一堆业务无关的代码。不够便捷。

2.InheritedModel对Model的分片和更新需要自己处理和设计,过度的灵活,也很容易掉到坑里;

3.在静态函数of中调用dependOnInheritedWidgetOfExactType发生了关联,但是特别容易让人忽视,of函数的命名不理想。(命名的问题其实是比较挺严重的问题)

4.性能容易失控,需要仔细实现updateShouldNotify才能避免不必要的UI重建,需要使用InheritedModel实现对复杂Model的精细控制。

虽然官方提供的答卷直接使用存在不便,但是他的设计是完备的,也就是说,我们可以对其进行友好的封装,从而得到更便捷的使用。

于是社区很快交出了Provider的答卷:

Provider很大程度上弥补了官方基建的不足,比如of函数的命名问题,Provider提供了更为准确命名的版本:

再如,你不需要写个类从InheritedWidget派生才能提供数据:

ChangeNotifierProvider 可以很方便的提供InheritedNotifier提供的服务 ,这回你真的可以直接在你的AppData(Model) 直接调用notifyListeners更新相关UI:

同时对各种场景的考虑和扩展都考虑非常细致:

基本上,Provider提供的便利封装覆盖了官方提供的模式:

读到这里,相信你对Provider好感激增,对项目中的各种Provider已经没有畏惧心理: 

不过说实话,上图的HomePageModel改名为HomePageProvider会更好。虽然你知道Provider是MVP里的Model,但是代码里不能这么叫,有点四不像。 另外我也不建议单独为Provider起个文件去写,毕竟ChangeNotifierProvider缺乏对局部更新的精细控制,单独写个文件想搞大Provider是不利于性能的(除非为了避免文件双向依赖,单独抽出公用部分是合理的。不过话又说回来,dart对文件双向依赖的容忍度很高,不像C++,直接双向依赖编译都不一定过):

另外很多同学无法摆脱命令式UI的思维习惯,喜欢单独弄个Controller。而且特别喜欢用单例(Mgr)?总之在Flutter弄些四不像的设计模式。

过度设计和命令式UI风格是污染Flutter代码的两大元凶。经过上面的讨论相信大家已经对这样的代码嗤之以鼻了。

最后希望可以深刻理解声明式UI的思想,了解官方指导的最佳实践,并以正确的姿势使用Provider。

最后特别强调一点的是,当会用Provider之后,千万不要滥用Provider!就像学会了降龙十八掌之后,拍个苍蝇也恨不得用上。 事实上,50%场景下StateLessWidget满足需求,30%场景下StatefulWidget满足需求,10%场景下Provider满足需求,5%场景下ChangeNotifyProvider满足需求。其他占5%

千万别上来就搞个ChangeNotifyProvider,真的没必要,这说明你还没想好。

我经常强调避免过度的设计的一条黄金原则:你最后完成功能所需要的代码行数越少越好。一个简单的功能本来只需要10行代码就搞定了,结果自己设计出一套规则和基类,最终提交了几百行行代码,这是被面向对象思想荼毒了。

实战例子

说那么多不如我来举个例子。 以一个常见的APP首页为例,假如我们需要实现如下的一个页面:

首先我先简单介绍一下对页面功能逻辑:

接下来我们思考如何实现。首先我们确定需要多少个状态:

如上图总结,我们一共需要4个状态。接下来思考那些状态需要跨Widget访问:

如上图分析,我们有2个状态需要跨Widget访问,因此这两个状态需要以Provider形式呈现。另外两个状态是不需要跨作用域访问所以是有个StatefulWidget就可以了。剩下的都是StateLessWidget。

我们先给主要的类起个名字:

接下来我们可以开始写代码了。

首先把网络请求接口写好,请求很简单,后台只需要给一个TabID和页码以及本次会话ID即可(本次会话ID主要是为了在下拉刷新时,即使页面相同返回推荐数据也不同),我们可以用下拉次数作为本次会话ID即可。

网络接口如下:

下拉刷新和Tab都好办,我们不用自己写,The official repository for Dart and Flutter packages. 上有现成的控件 。不做冗述。

然后,两个跨Widget的状态我们写一下:

把这两个状态挂到合适的位置即可。当收到下拉控件下拉的回调时,对相应的状态加一即可:

变量更新了,还要触发相关Widget重新去拉数据。那么相关Widget只需要watch这个变量即可。

相关Widget的build很简单,拉数据然后展示就好了,那么我们这里的代码长这样:

这样在第一次进来或者下拉刷新,WaterfallContainer 都会重新Build了,实现重新拉数据了。

如上图所示,数据拉回来,上面的WaterfallContainer会把每一项信息流卡片的展示交给WaterfallItemCard,WaterfallItemCard是个纯展示的无状态Widget:

最后再实现下拉加载下一页即可(上面截图已经提前剧透了)。

还有一些边边角角的逻辑,比如加载失败展示提示,因为有了NetWorkDataFetchResult这个状态,所以这个逻辑也很简单;再如顶部的搜索按钮跳转,切换不同类目的Tab等,本身更不复杂。曝光上报和点击上报都只需要在WaterfallItemCard里增加响应代码即可。这些就不展开讲了。

如果你比较注重性能,可以使用Sliver系列的容器承载WaterfallContainer,这样滚了很多页之后能减少内存占用。

通过这里例子我们可以看到,合理的设计能让事情变得简单。

  • 18
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值