Flutter项目快速搭建指南

5028次阅读  |  发布于1年以前

前言

笔者在这之前已经有一年时间没有接触过flutter项目的开发了,目前由于新项目的需要,要重新开始搭建一个flutter项目。让我感到惊讶的是,flutter经历了多年的发展,生态真的越来越完善了,很多之前开发中遇到痛点问题都得到了很好的解决,开发体验倍增。下面我就分享一下这次flutter新项目搭建的经验,主要包括网络请求、数据持久化存储、路由、多机型屏幕适配、闪屏页配置、状态管理、widget生命周期、Key的使用等方面的内容。

一、网络请求处理

互联网时代,网络请求几乎是每个app开发都必不可少的部分,开发时需要注意的地方大概有实体类封装、请求调用封装、拦截器处理等。

1. 实体类封装

实体类就是我们经常说的bean,大多数的开发场景中,bean都是由json字符串解析而来的,我们所要做的封装过程,其实也就是对json字符串的序列化及反序列化。但是在这之前,我想先介绍另外一种(除了json协议之外)零封装的实体数据序列化协议。What?零封装?所谓零封装,就是连bean的实体类都不用自己去创建了,解析过程也不需要自己去关心,这个协议就是protobuf。

protobuf是一种二进制的数据传输协议

相比于json字符串,它是天然加密的,而且体积更小,网络传输速度更快。

更为难得的是,protobuf协议的实体数据可以使用脚本自动生成各个语言的实体类代码,这样就可以保证客户端与服务端的实体类完全一致,比如字段类型、字段名等和服务端的实体数据都是完全一致的。由此可以解决json协议带来的种种痛点,比如它提供int64和int32,不用再纠结这个字段是int还是long了,甚至有些变态的服务端接口会返回BigDecimal这样的数据类型,让客户端开发人员气得跺脚。

由于所有代码都是自动生成的,只要服务端对实体类做了改动,客户端就可以重新生成一份新的代码,不管是字段名还是字段类型做了修改,客户端开发人员都不需要再和后端去一一对接了,只需要重新生成代码,修改编译产生的错误即可,大大降低了两端对接的成本。

如果是之前一直使用json,没有使用过protobuf,一定会爱上它的,对客户端开发者实在是太友好了。但考虑到后端万年不变的开发习惯,protobuf还是比较难以推广,毕竟说服后端人员换新的技术是个非常大的挑战,如果是小公司小团队小项目,真的推荐使用protobuf,有兴趣的朋友可以去做更多的了解。既然还是无法避免使用json,下面就说一说flutter项目对json解析的封装。

json解析

有网页版在线生成,IDE插件也有相关工具,这里就不详细介绍了。

一般生成实体类代码的工具也会附带生成对应的序列化,反序列化的函数,即fromJson和toJson,但这种方式是很脆弱的,如果修改了某个变量的名称或者是类型,那么也要修改对应的序列化函数,大量的代码很容易出错。所以这里推荐使用一个自动生成序列化函数的插件:

如上,只需要使用JsonSerilizable注解,然后运行flutter pub run build_runner build就会帮你自动生成序列化函数了。

通常我们希望对get请求做类似如下的封装,通过指定范型,直接返回我们想要的那个实体类:
但flutter是禁用dart的反射的,范型是没有运行时的,也就是说通过范型没办法直接生成对象,即T.fromJson(json)是没法实现的。所以我们要换个思路去实现,即借助dart语言的函数引用:
这样就绕过了范型不能运行时的问题。

2. Dio框架的使用

dio是flutter开发最热门的网络请求框架,所以不需要犹豫使用它就对了。使用dio时需要注意的是,它不像android中的okhttp框架那样功能一体化,dio自身没有集成任何的拦截器,所有拦截器都需要开发者自己去配置,好处是比较灵活,坏处呢,就是又要麻烦开发者了。需要添加如下的依赖去使用常用的拦截器及适配器:

具体配置:

一定要注意拦截器的顺序,第一个为缓存,如果缓存命中就不需要走下面的逻辑了,网络拦截器注意放在最后。

3. 缓存处理

客户端通常会做一套get请求的缓存封装,对一些不经常更新的或者没网情况下也需要展示的数据进行缓存。通常的做法是写一套文件缓存的逻辑,自己维护缓存健值对,但是如果能利用到dio的缓存拦截器,会不会就省了很多事呢?dio的缓存拦截器本来是基于http协议给服务端用作缓存配置的,但是服务端开发人员往往没那么关心缓存的配置,所以客户端可以自己来处理缓存,节省沟通的成本。先看下缓存拦截器有一个缓存策略的枚举类:

其中request是默认的,基于http协议去作缓存,我们可以使用refreshForceCache去强制刷新缓存,同时需要网络请求的时候使用noCahce绕开缓存。在封装缓存前先对请求结果做一个包装,用于识别请求结果是来自于缓存还是网络:

然后对get请求做一个封装:

先对缓存拦截器进行一个通用的配置:

每次请求发生两次回调,先回调缓存的结果,再回调实际请求的结果,适用于先展示缓存数据,得到网络请求结果后再刷新页面的场景。没有缓存时只回调网络请求的结果,网络请求失败时只回调缓存的结果。

二、数据持久化存储


客户端里的数据持久化存储大多数都是这三种情况:文件读写、键值对存储、数据库。其中文件读写不需要多说,flutter的framework层已经封装的很好了,直接用就可以。下面就主要说一下键值对存储以及数据库的搭建方案。

1. 键值对存储

和android类似,flutter也有一套叫shared_preferences的官方框架,使用方式也是类似的,就是简单的键值对存储。

这是官方的示例代码,笔者早期从事flutter开发的时候也是使用它,目前pub.dev上的流行量来看也是最主流的一款。虽然比较主流,但是从我的开发经验上来看,还是存在一些痛点的,主要有以下几个方面:

如果想要在同步方法中使用会有一些烦恼,比如getter、setter方法是没办法写成async的。

所有数据都是存在一个文件里,如果我想删除某一个模块的键值对,比如一个用户退出了,我要删掉这个用户的所有数据,只能遍历所有相关的键去一个一个删,非常不方便。所有数据共用一个文件,当数据量特别大的时候,难免会存在键冲突的问题。

只能保存在application目录下,如果想要保存在外部存储或者临时文件目录里就没法实现了。鉴于这些没法解决的痛点,我在新的项目中没有再使用它,而是寻找到了其他的代替方案,下面我就介绍一下mmkv这款非主流的框架。

mmkv的使用

mmkv是腾讯开发的一款键值对存储库,笔者第一次接触到它还是在进行android开发的时候,它的功能非常强大,相比于sp性能也更好。mmkv可以提供多进程的读写,也就是说内存缓存可以更大,读写效率可以更高。笔者能想到它,也是因为flutter几乎可以利用到所有的native资源,既然natvie端可以使用,那么开发一套flutter插件也并非难事。果然没有令笔者失望,确实找到了mmkv的flutter插件,既可以解决我上面所说的sp的各种痛点,又可以利用它高效的内存缓存机制,于是就果断选择了它。看下mmkv的官方示例代码:

和sp的使用方式都是大同小异的,只不过写数据的时候不再需要异步进行了,同步写数据多好啊!

数据的存储也可以分块进行,指定文件名就可以,不再像sp那样摊大饼了,更加的灵活,可操作性更强:

文件的存储目录也可以自己配置:

还支持使用aes加密,真的算是尽善尽美了。

2. 数据库存储

说到客户端的数据库存储方案,一定会想到sqlite,flutter官方也提供了sqlite数据库插件,叫做sqflite。根据笔者多年的客户端开发经验,在大部分客户端项目中数据库的使用确实是必不可少的,但是使用程度上来说又并不会深度去使用数据库,往往都是一些简单的表,一些简单的增删改查。而sqlite却是一款重量级的数据库方案,即便是简单的使用,也需要小心翼翼地编写sql语句,无形中增加了开发成本。总结起来就是一句话,大部分客户端项目使用sqlite都算是大材小用了。所以,如果确实需要很复杂的增删改查,就推荐使用sqllite,如果是简单的使用,下面就推荐一款轻量级数据库ObjectBox。

关于ObjectBox

ObjectBox是一款轻量级数据库,底层使用C/C++实现,不但使用起来便捷高效,性能上相比sqlite也有大幅度的提升,下面就看一下官方的介绍:

从图上可以看出来它的性能更加出色:

使用起来就是简单的对对象的操作,不再使用繁琐的sql更加高效。非常推荐中小型项目,以及对数据库不深度使用的大型项目采用objectbox数据库方案。

三、路由

Flutter内置的路由包经历过两个版本的变化,分别为Navigator1.0和Navigator2.0,Navigator1.0使用起来非常的简洁,基本上使用push和pop就可以控制页面的导航了,但是设计上过于黑盒了,在一个页面里很难获取到其他页面的情况,遇到复杂一点的场景,比如退出路由栈中的其他页面、页面的重定向等,就会显得比较蠢。

所以在flutter1.22版本中出现了Navigator2.0,它对Navigator1.0是个很好的补充,可以更灵活地去控制路由,面对复杂场景时可操作性更强。

但是Navigator2.0的api实在是太复杂了,笔者当时看了两三遍官方的demo都还是很难理解,使用成本太高了,必须要很深入的理解它的机制并且做很多的封装才可以使用。但技术的发展一定会是不断的降低开发成本,所以必定会出现一个好用的轮子去让我们的开发变得容易,下面就介绍一款好用的路由框架 GoRouter。

下面是官方的示例代码:

其中routeInfoformationProvider和routeInformationParser都是在Navigator2.0中需要自己去创建的,这里GoRoute已经帮我们封装好了,直接配置就可以。GoRoute的功能非常强大,几乎可以满足所有我们开发中的路由需求,我这里就不详细说了,有兴趣的朋友可以看一看这篇文章:https://juejin.cn/post/7047035390003249189

四、多机型屏幕适配

多机型屏幕适配,不仅仅是flutter开发,所有客户端开发都会比较头疼。当遇到适配问题时,大多都会采用百分比、字体自适应等方法费尽九牛二虎之力才能把ui调到勉强看得过去。究其原因,各种各样的机型,屏幕宽和高的比例存在差异,传统的dp和sp单位是不区分宽高的,这就使得手机的宽高比不一样时难免会遇到适配问题。所以为了从根本上解决问题,就有大神把屏幕宽和高的单位区分开了,下面就介绍一下如何使用flutter_screenutil进行屏幕的适配。以下依然展示官方的示例代码:

初始化时配置一下设计稿上的屏幕尺寸:

设置宽时使用.w,设置高时使用.h,字体使用.sp,这样就区分开了,.r代表的是宽高中尺寸更小的一个。因为dart语言扩展方法和扩展属性的支持,使用起来非常简洁舒适。

除此之外,还可以使用.sw和.sh直接使用屏幕宽高的百分比,以及使用.setVerticalSpacing和.horizontalSpace设置横向和纵向的间距。以上就是flutter_screenutil的使用方式,简单好用,基本可以解决大部分的适配问题了,而且ui还原度更高,如果个别情况还是出现问题,那就只能再使用老套路处理了。

五、闪屏页配置

flutter系统库里是没有支持闪屏页配置的,所以一般情况下需要我们去native端配置闪屏页,一般就是一张图片。这就导致flutter端没法知道闪屏页什么时候结束,如果想在闪屏页做一些初始化的工作就得在flutter端再写一套闪屏页的代码,非常的麻烦。下面就推荐一个好用的轮子,可以在flutter端统一配置闪屏页,而且可以拿到闪屏页结束的回调,如此就省去了两端分别配置闪屏页的工作量,flutter端也不需要重复写闪屏页的代码。

flutter_native_splash的使用,只需要两步:

1.在pubspec.yaml中进行配置

2.初始化结束后移除闪屏页

六、状态管理

状态管理可以帮助我们管理开发中存储的数据,以及处理这些数据所绑定ui组件的刷新逻辑。它就好像是一个交通秩序的维护者,让数据的处理不再凌乱于widget的构建中,让ui组件的刷新范围变得精准可控。主流的状态管理框架有Provider和GetX,下面我们就揭开这两款框架的神秘面纱,看看它们如何优雅的实现状态管理。

1. 状态管理的意义

相信大部分人都听说过mvc,mvp,mvvm这些概念,其实在flutter的官方demo中,使用的就是mvc模式,在State中需要保存数据,处理数据,以及构建widget。但是在这么多年的前端开发模式演进中,mvc显然已经不再符合复杂的业务逻辑需求,所以无论是provider还是GetX中的controller都是会将数据逻辑的处理单独拆分出来,实现数据与ui的分离达到更高的可维护性。

无论是StatelessWidget还是StatefulWidget,它们之间进行数据传递只能通过构造方法。例如页面的跳转顺序是A -> B -> C -> D, 页面D如果需要页面A的数据,那么只能通过B,C去传递到D,这显然不符合单一职责,在B和C中引入无关的数据是一种糟糕的设计。即便如此可以实现页面间的数据传递,由于页面D不具备更新页面A的能力,拿到的也只是只读的数据,无法通过数据的变化去更新页面A。通过状态管理,可以实现更合理的跨页面数据的传递,以及页面的更新,后面我们会详细介绍。

flutter官方demo中提供的页面更新方式,只有setState,数据发生了变化,使用setState就可以重新构建State中的widget,刷新widget所绑定的数据。这种方式有一个很明显的弊端,就是会更新与所变化数据无关的widget,特别是一些复杂的页面,进行全页面的widget构建是会付出很大性能代价的。Provider与GetX都提供了自己的一套机制去控制widget重新构建的范围,只更新被修改数据所关心的widget。

2. Provider原理

Provider主要借助flutter自带的控件InheritedWidget来实现状态管理,Provider其实就是完善的封装了InheritedWidget,我们只需要理解InheritedWidget就可以了。InheritedWidget就只有两个作用,一个是实现父组件向子组件的数据传递,另一个是控制子组件的更新范围。拥有这两个作用就几乎可以实现我们前面所说的状态管理了。

很简单就是将数据存在它对应的InheritedElement中。

取数据也很简单,就是通过context(或者说是element),传入对应的widget范型去得到存数据的element。你下意识可能会认为它查询的实现方式是通过树形结构一层一层去找到想要的element,但并不是这样的,如此就不得不说底层框架设计者的精明之处了,通过树的层次结构向上查询,效率会受到element树深度的影响,不符合那些底层开发技术大牛对于效率的极致追求,我们来看看它的原理吧。

没错,一点也不神秘,甚至非常简单,Element里保存了它所有祖先中InheritedElement节点的引用,空间换时间,因为保存的仅仅是InheritedElement的引用,浪费的空间几乎可以忽略不计,如此就提升了查询的效率。类似于使用HashMap代替list去提升查询效率,O(n)的复杂度降为O(1)。

我们都知道setState会重新构建整个StatefulWidget,而provider的notifyListeners()却可以只重构widget树中的Consumer,我们来看它是怎么做到的(只展示部分核心代码):

Consumer既然是观察者,那么就会有主题,它的主题是谁呢?当然是ChangeNotifyProvider节点了,上面代码中的InheritedElement就是ChangeNotifyProvider的Element节点,它的_dependents保存了所有依赖它的Consumer的Element节:

如此便很明了了,Provider调用notifyListeners(),会遍历它对应的InheritedElement中的_dependents,然后调用Element的didChangeDependencies(),使用markNeedsBuild()标记该节点进行更新。

InheritedWidget利用树形结构的便利实现数据共享,借助Element树去管理数据的生命周期,但是这种方式既成就了它,却也限制了它。我们知道Flutter的路由结构,通过路由所有跳转的页面都属于同一层级,这就意味着它要实现页面间的数据共享就需要把InheritedWidget放在最上层,这明显不符合逻辑,只有A、B两个页面共享的数据,A、B页面都还没有打开,就要初始化它的数据,并且页面关闭后资源也无法得到释放,如此便成了它最大的痛点。

3. GetX使用及原理

前面说过了InheritedWidget的局限性,但是如果不使用InheritedWidget,不借助Element树,那么Flutter Framework层就已经没有可以借力的地方了,只能走出一条自己的路,GetX便是如此。很多实用且经久不衰的技术都是从最基础的理论出发的,朴实却不平凡。LruCache说白了就是个队列+软引用,HashMap也就是哈希+数组+链表+红黑树,AES加密更简单,就是分组加密而已。这些技术看起来都并不复杂华丽,却被广泛使用。GetX也是一样的,借助Framework层去实现会被束缚住,那么就一切都自己来,页面间的数据传递使用单例,数据的创建和销毁在应用层上去自己封装,控制刷新范围也通过封装StatefulWidget去监听数据变化刷新,如此便可以自己去定制化状态管理的逻辑了。原理虽然简单,但是要实现完善的状态管理,还是需要很多巧妙的设计,我们一起来看吧。

GetX中的数据管理者叫做XxxGetxController,通过GetInstance单例对象去管理controller(只展示部分核心代码):

和Provider其实是非常类似的,都是通过范型去作为key存储和查找,但和Provider不一样的是,Provider在一颗子树上是唯一的,而GetX中没有树形结构,所以要使用多个Controller就需要加上tag去区分。顺便提一下这个单例的写法,非常简洁舒适。


不用多说,和Provider几乎一摸一样,Controller相当于provider,GetBuilder相当于consumer。

(只展示部分核心代码):

如果设置了filter就使用filterUpdate,没有设置filter就直接去setState刷新。这里的filter与provider中的Selector一样,只关注某一个变量,当关注的变量进行了变化后才会刷新,即newFilter!=filter。filter是和引用有关,不能用于监听list,map等集合里的数据变化,使用时一定要注意去更改变量的引用,否则不能保证准确的刷新。所以filter更适合于不可变对象,如int,String,bool等。

这是GetX独有的,非常惊艳的设计,因为根据filter模式去细化刷新范围必须要定义一个变量,并且要修改变量的引用才能触发刷新,这很难适用于所有场景,比如响应某个事件,单独为这个事件去定义一个变量非常莫名其妙,又比如list里的数据变化无法去监听。(只展示部分核心代码):

注意在addListener的时候,会接收一个remove的回调,这也是非常好的设计思路,在dispose中调用remove就可以从controller里移除监听了。GetBuilder中会传入autoRemove,默认为true,根据需要决定是否在GetBuilder销毁后释放controller。(只展示部分核心代码):

七、Widget生命周期及key的妙用

前面介绍的都是一些开源框架的使用,借助好用的轮子去提升开发的效率,而最后这一部分则是一些实际开发中有用的干货,帮助你更好的理解flutter的ui构建过程,知其然知其所以然才能在开发中游刃有余地解决所有问题。

1. Widget生命周期

在flutter中,widget分为两种,一种是StatelessWidget,一种是StatefulWidget,他们的生命周期也对应不同的两种。但是在说生命周期之前,必须先要搞清楚Element和Widget之间的关系,Widget只是一份ui属性的配置信息,而Element才是视图树上真正的节点,Widget和Element一一对应,Widget可以被构建多次,但并不是每一次的构建都会更新Element,是否更新Element根据Widget.canUpdate()这个静态方法的返回结果决定,后面讲到Key的时候会详细说这个更新的算法,这里暂时先不用关心。- StatelessWidget**生命周期**

我们都知道StatelessWidget是无状态的,只读的Widget,所以它的生命周期就是其所对应Element的生命周期,分为第一次创建和更新两种:- 第一次创建时的生命周期

StatelessWidget第一次创建时会调用createElement()方法创建Element,紧接着调用Element的mount方法将其挂载到视图树上,然后就是调用build方法构建widget。至此Element的创建就完成了,之后就是如何更新它。- 更新时的生命周期

虽然StatelessWidget自身是无状态的,只读的,但是它的父Widget却可以重新构建它,比如它的父Widget是StatefulWidget,调用了setState,那么就会重新生成一个新的Widget,Element会调用Widget.canUpdate(oldWidget,newWidget)判断是否可以更新,如果可以更新就调用build构建widget,Element调用update方法进行更新,即复用了原来的Element。

如果不可以更新,则是出现了两种情况:a.需要移除它,此时Element会调用deactive方法变为inactive状态,最后会调用Element的unmount方法彻底移除。b.需要替换它,调用新Widget的createElement()方法重新创建一个Element去替换原来的Element。

StatefulWidget也是符合Element生命周期的,但是在Element创建的时候还会多创建一个State,而State也维护了一套自己的生命周期,直接上图:

这里要注意一下didChangeDependcies这个方法,特别是使用provider的朋友,因为使用了InheritedWidget的原因,在这里初始化数据不是特别合适,会被调用多次。

2. Key的妙用

Key对大多数flutter开发者而言,应该算是最熟悉的陌生人了。如果作为一个flutter初学者,即便对Key一无所知也不会影响正常的开发,但这并不意味着Key不重要。前面的Widget生命周期中已经提到,Key是决定Element更新的重要元素,而Element的更新又决定了Element是否可以被复用,只有处理好了Element的复用,整个app的性能才能得到提升,所以作为一个进阶的flutter开发者,Key是必须要去理解掌握的知识点。

Widget属性即创建Widget时候通过构造方法配置的参数,当Widget需要更新时,会调用Widget.canUpdate(),这个方法就是分别比较新老Widget的key和runtimeType是否一样,如果一样就会把Widget中的属性更新到Element中。因此Widget中的属性我们不必要操心太多,即便key为空,相同runtimeType的Widget也可以得到正确的更新和复用。

和Widget属性不一样,当新老Widget的key和runtimeType相等时,Element不会去更新State。看个例子:
如上,无论我如何点击又下角的加号图标,屏幕中心的数字都不会改变,除非给WidgetA加一个key 。
这样,数字才会正确的改变。上面的例子只是告诉我们,key可以帮助我们重新创建Element,使Element中的state发生变化,但key更大的作用是帮助我们去复用Element,即新老Widget的key不相同时,会在Element树中寻找和新key相同的Element去复用,下面就会详细说说如何使用key复用Element。- Key的分类****

在说Element的复用之前,先来看一下key的分类:

只在同一父widget里生效,valuekey,objectkey,uniquekey都属于LocalKey- Globalkey

使用一个静态map保存element,不局限于同一父Widget,可以全局使用- 单亲Element的复用

单亲就是指它的父节点只有它一个子节点。从上面的分类可以看到,Localkey只能在兄弟节点中使用,那么要复用单亲Element,就只能使用Globalkey了。

如上面的例子,我将flag由true变为false,中间的数字依然是1,说明Element得到了复用。这个例子只是为了证明GlobalKey可以复用Element,没有实际的意义,再在我们的实际应用中举个例子:A页面有一个播放器,当我从A页面跳转到B页面时,我希望直接把这个播放器移动到b页面,而不是重新创建新的播放器,就可以使用Gloablkey去复用了。

兄弟节点是指同一个父节点的子节点,相比于单亲的Element,兄弟节点之间复用的场景会更多,这和flutter的新老widget树的比较(diff)算法有关:当widget树发生更新时,对于同一父节点的所有子节点会从children的两边向中间进行比较,Widget.canUpdate为true,即old.key == new.key&&old.runtimeType==new.runtimeType时进行Element的更新。前面说到过,当Element更新时,只会刷新Widget中的属性,而State是不会更新的,这就导致了一个问题:如果children里的widget类型(runtimeType)一样并且key为空,那么当对children进行增加、删除、替换等操作时,就会导致State不能得到正确的匹配,发生Widget和State的错位。

所以当遇到这种情况的时候就必须要用到key了,而且是LocalKey,当children发生增加或删除时,被key标记的Widget可以正确的更新State(因为key不一样了,Element不能被直接更新,而是重新创建或移除Element节点)。当children发生替换时,由于新的key和旧的key不一样,会在children中寻找和新key一致的Element节点去复用。由此可以看出,知道如何在兄弟节点间使用key是至关重要的,即可以保证Element更新的正确性,又可以复用已存在的Element。

结语

以上就是我分享的flutter项目快速搭建的内容,首先要使用好已有的轮子,能少走很多弯路,其次要掌握好Widget的生命周期及key的使用,如此就可以把前面的路铺的很平坦了,走起来更为轻松。

Copyright© 2013-2019

京ICP备2023019179号-2