Flutter日历项目的优化记录

3,190 阅读9分钟

FlutterCalendarWidget

Flutter上的一个日历控件,可以定制成自己想要的样子。

实现日历并不难,主要是之前实现的性能有点差,进行了下优化。这篇文章主要记录一下自己对这个日历项目的优化过程。

截图

项目地址

日历支持web预览:点击此处进入预览

项目结构

下图就是项目的整体结构,没啥特殊的,就是写一个日历需要的一些数据。

  • constants:存放一些常量
  • model:自定义日历用的实体类DateModel
  • style:自定义的一些style
  • utils:工具类
  • widget:显示月视图、周视图的widget。
  • calendar_provider:使用provider创建的共享状态类
  • configuration:定义配置信息
  • controller:日历控制器,可以对日历进行一些操作或者配置
  • flutter_custom_calendar: 日历Widget

Widget层级

  • 整体是一个Column,顶部固定一个自定义的weekbar。
  • 下面是一个AnimatedContainer,用来实现周视图和月视图切换的高度变化的动画效果。
  • IndexedStack用来存放周视图和月视图的widget。与Stack相同的地方是都是实现层叠的布局,与Stack不同的地方是Stack显示的时候会把children都绘制出来。而IndexedStack只会绘制指定index的那个child。所以这里利用切换index来切换显示周视图和月视图。具体文章:Exploring Stack and IndexedStack in Flutter

优化记录

优化后的性能

优化前,真tm是一片红。

进行各种优化后,最终的性能如下面几个图所示。整体性能还好,页面间切换也没那么卡来。每帧的耗时大部分小于16ms,达到期望,还可以继续优化下。

  • 图一:AndroidStudio自带的工具栏Flutter Performance,可以查看相关的性能数据
  • 图二:打开Show Performance Overlay的开关,就可以在app上显示性能统计数据的浮窗。
  • 图三:Flutter提供的Devtools工具,在浏览器中可以查看各种性能的数据。(具体使用百度一下)

代码优化

之前日历的配置信息全都是放在controller里面的,而且日历可配置的信息会有很多,感觉有点乱。这次将配置的信息都抽放到一个CalendarConfiguration对象中。并且这个CalendarConfiguration对象存放在顶层的provider状态类中,可以让子Widget直接获取到配置信息。

引进provider状态管理框架

引进provider状态管理框架,一方面是可以避免数据各种嵌套传递,一方面是实现局部刷新提高性能。

关于provider的使用:Flutter | 状态管理指南篇——Provider

  • 引进provider前的代码

各种数据和状态都需要一层一层往下传递给子Widget,有点恶心。

  • 引进provider后的代码

创建状态类CalendarProvider,用于共享日历的数据和状态,子widget可以直接获取到CalendarProvider中的数据。代码比之前好看一点,不过后面还是得继续优化。

没有了代码嵌套,构造方法简单了很多。在子组件的build方法里,也可以获取到各种状态数据。

使用compute加载数据

相关文章:Flutter 异步编程:Future、Isolate 和事件循环

  • Dart是一种单线程语言,Isolate就是Dart中的线程。

  • 默认的Flutter代码是运行在同一个isolate里面的。每个Isolate都有着自己的事件循环EventLoop和两个队列(MicroTask和Event)。如下图所示,MicroTask队列的优先级优先于Event队列,当没有MicroTask事件的时候,才会去执行Event队列中的第一项。

  • 注意:Future操作也是通过Event队列处理。Future和async并非并行执行,而是遵循事件循环处理事件的顺序规则执行。 如果繁重的处理可能需要一些时间才能完成,并且可能影响应用的性能,考虑使用 Isolate。 所以,可以利用多个Isolate来实现真正的并行处理。

  • Flutter提供了一个compute的方法,让我们可用来直接创建一个isolate。Compute函数对isolate的创建和底层的消息传递进行了封装,使得我们不必关系底层的实现,只需要关注功能实现。 使用Compute写isolates

  • Isolate的使用场景

    所以一些比较耗时的操作,我们可以放在另一个isolate中进行并行执行。

    • JSON 解码:解码 JSON(HttpRequest 的响应)可能需要一些时间 => 使用 compute
    • 加密:加密可能非常耗时 => Isolate
    • 图像处理:处理图像(比如:剪裁)确实需要一些时间来完成 => Isolate
  • 在日历项目中的应用

比如我准备显示月视图,需要进行数据的加载拿到42个item。就需要去计算这个月对应的是42个item,以及去计算出每个item所对应的DateModel,以及各种所需要的信息。这个过程是比较耗时的,所以我就使用compute将这个操作放在另一个isolate里面进行操作。

使用getter实现数据的懒加载

Dart中的某个变量,默认都有setter和getter方法,getter方法就是用来获取变量的值。

如果这个变量的值是需要一系列计算(可能比较耗时)后才能得到结果。 如果在创建这个对象的时候就去计算结果并赋值给这个变量,就会花费额外的一些时间。也有可能计算了,但是这个对象后面也不会用到,那就很没必要了。

所以可以使用类似下面的写法,实现类似懒加载的功能。调用getter方法的时候,才去判断值是否为空,为空的话才开始进行数据计算。

每个DateModel都包含了一大堆属性,需要我去计算,比如农历、传统节日、24节气。这些的计算是比较复杂和耗时的,所以我就将部分属性的计算操作放到对应getter方法中,这样的话,就不用在一开始加载数据的时候,将所有的属性都进行计算。

点击item后进行刷新日历

呵呵,之前点击item后,然后不管三七二之一,调用setState方法去刷新整个日历的状态,搞定。可想而知,性能肯定会差点。

提高Build效率的一种方法就是降低遍历的出发点。直接在日历Widget内调用它的setState的话,那rebuild的时候,就需要将整个日历Widget树进行遍历刷新。

所以这里的做法是将日历的item抽成一个StatefulWidget,这样的话,如果调用日历item的setState方法的话,就只会刷新这个item,实现将刷新范围缩小到item级别。

  • 这里写了一个refreshItem的方法,可以给item自身调用或者外部调用。

注意:这里要判断mounted后才去调用setState方法。因为有可能这个节点已经从element树移除了,这个时候如果调用setState的话,就会报错。在平常的开发中,也要注意这种问题。比如在获取网络数据的时候,如果当前页面被dispose了,等接口的数据返回后,直接调用setState的话就会报错。

Exception caught by gesture
        The following assertion was thrown while handling a gesture:
        setState() called after dispose()
  • 多选模式:只刷新当前的item就行了。

多选模式就很简单,每个item,都利用GestureDetector监听日历item的点击事件,然后调用setState方法刷新自身就行。

  • 单选模式:只刷新两个item,当前item和上一个item。

单选模式,比如我们选中某一个item,需要刷新这个item,并且将上一个选中的item的Widget进行刷新。所以这里定义了一个lastClickItemState变量来保存上一个点击的item的State对象,每次点击item的时候,调用这个lastClickItemState的refreshItem方法。

  ItemContainerState lastClickItemState;//上一个点击的item

使用AutomaticKeepAliveClientMixin,使PageView保存内部item状态

这个相信搞过PageView的朋友,都想切换到其他页面的时候,需要实现页面保持状态。 AutomaticKeepAliveClientMixin 这个 Mixin 是 Flutter 为了保持页面设置的。哪个页面需要保持页面状态,就在这个页面进行混入。

只有两个组件才能保持页面状态:PageView 和 IndexedStack。

所以这里利用AutomaticKeepAliveClientMixin实现切换月份的时候,会维持上一个月或者下一个月的页面。

加入 AutomaticKeepAliveClientMixin 混入,并重写 wantKeepAlive 方法。

使用IndexStack实现切换功能:

周视图和月视图的切换功能的实现,这个暂时是用AnimatedContainer+IndexStack来实现的。不清楚还有没有其他更好的实现方案,还请大佬们给给意见。

  • 一个是动画效果。两个视图之间的切换,高度变化会有个动画效果,可以使用现成的AnimatedContainer来实现。比自己用AnimationController会方便很多。
  • 用什么widget来放周视图和月视图这两个Widget,现在是用IndexStack。

一开始是用Stack来放周视图和月视图两个widget,也是可以实现效果的。后面看到有IndexStack这个东西,就拿来使用了。

与Stack相同的地方是都是实现层叠的布局,与Stack不同的地方是Stack显示的时候会把children都绘制出来。而IndexedStack只会绘制指定index的那个child。所以这里利用切换index来切换显示周视图和月视图。

Stack对应的renderObject是RenderStack,可以看到,paint方法,最后是会将所有的child的给绘制出来。

IndexedStack其实是继承Stack,相应的renderObject是RenderIndexedStack,也是继承于RenderStack。重写了paintStack方法,只会绘制指定index的child。

总结

自己写一个开源库,虽然写得很辣鸡,不过还是有挺多收获的。通过这个项目,把Flutter性能优化的方法进行了实践,更深入地去了解Flutter的一些原理。