Flutter List 有很多组件ListView,GridView,Sliver家族。 flutter_long_list的目的就是去封装一个组件实现统一的功能:
1.下拉刷新、上拉加载、错误重试
2.数据的注入和维护,列表更新的性能优化
3.Item的曝光事件
4.自定义样式:loading nomore slivers
在一个app中,list的需求是无处不在的,所以封装一个通用的list是很要价值的。接下来就介绍下flutter_long_list是如何一步步实现的。
一、下拉刷新上拉加载和数据的管理
列表的下拉刷新和上拉加载是跟数据分不开的,提到数据状态管理,就会想到前端的一些库redux,mobx,rxjs等等。幸运的是Dart也实现了这些库,其原理都是相似的,所以学习起来相对容易些。具体的比较和差异,在本文这里不是重点(下次再分享数据管理的使用心得吧),最终选用的是Dart官方推荐的Provider库来管理数据,它提供的Selector组件可以控制列表渲染的颗粒度,而不会因改变一个item而导致整个列表重新渲染。
List组件在一个应用内是普遍使用的,那么先举两个业务中经常遇见的小例子:
🌰 1:发现页是一个点赞功能的帖子列表,切换到喜欢页,点赞后就可以看见刚刚点赞的帖子,取消点赞则要删除帖子。
🌰 2:发表帖子跳转到发现页,应该要及时看到刚刚发表的帖子(后台通常要有上传和审核的时间)。
我们该怎么去实现呢?
1.请求接口刷新列表(简单粗暴,体验差)尤其对于第二个例子等待时间会让用户失去耐心。
2.自己维护数据,使用eventbus等(跨页面的通信方法)去传递数据,更新页面。(更加合理,体验较好,不易维护)
个人感受:第三方逻辑代码的不断侵入,会让整个项目的可维护性越来越差。
3.能不能将数据通信封装到flutter_long_list组件内,只触发相对应的list更新操作(增删改),相应的list就会自动更新。
这样做可以使所有api统一,使用Provider数据管理,减少数据通信代码侵入到各个组件内。(性能好,易维护)
接下来就是设计一下数据模型。既然管理一个app内的所有list,自然需要维护一个Map。_hashMapList存储list数据,_listConfig存储list的配置(id, 分页逻辑,加载标识isLoading,是否有更多hasMore,是否请求出错hasError)。
Map<String, List<T>> _hashMapList = {};
Map<String, LongListConfig> _listConfig = {};
数据模型设计好了, 接下来就是实现api了。首先需要在initState中初始化列表,其中pageSize,request是用来完成下拉刷新和上拉加载的,之后开发人员完全不用关心这两个行为了,只管滑就好了,内部会执行相应方法。
init() async{
LongListProvider<FeedItem> longListProvider =
Provider.of<LongListProvider<FeedItem>>(context, listen: false);
longListProvider.init(
id: id, // 唯一标识 建议跟页面名字相关
pageSize: 5, // 请求每页数量
request: (int offset) async => await _getList(offset), // 请求接口
);
}
然后,就是渲染组件了,前面也提到了flutter list组件有很多种,需要用mode字段来自定义使用哪种模式。这里面模式的区分主要考虑两个维度:
1. grid和list是有区别的,多了gridDelegate属性,且如何让最后一个item(loading)处于单独一行也需要处理(使用的是这个库来解决 )。
2. 多个sliver和单个sliver的曝光计算会更复杂些,需要区分处理(下面会介绍)。
itemWidget就是渲染自定义item组件,内部使用Selector包装,每次渲染前都会调用shouldRebuild进行判断是否需要重新渲染。可以对列表数据进行相关操作,来实现上面的🌰 1功能。
值得注意的是FeedItem其实是业务数据模型(接口返回),每个list使用的数据是不确定的,这也是上面的数据模型使用泛型的原因。而且这个数据模型定义时必须要定义clone方法返回一个新对象,原因就是内部使用的Selector要求提供的数据是不可变类型,否则无法进行比较,就不能实现颗粒度渲染。
LongList<FeedItem>(
id: id,
mode: LongListMode.list,
itemWidget: itemWidget,
)
Widget itemWidget(BuildContext context, LongListProvider<FeedItem> provider, String id, int index, FeedItem data) {
print('rebuild${index}'); // 每次点赞 都只会rebuild这一个itemWidget
return Container(
height: 200,
width: double.infinity,
alignment: Alignment.center,
color: data.color,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
provider.removeItem(id, index);
},
child: Text(
'delete ${data.text} ${index}'
)
),
GestureDetector(
onTap: () {
data.like = !data.like;
provider.changeItem(id, index, data);
if (data.like) {
provider.addItem('list_like', 0, data); // 触发list_like更新
} else {
provider.removeItem('list_like', 0); // 触发list_like更新
}
},
child: Icon(
data.like ? Icons.favorite : Icons.favorite_border
)
),
],
),
);
}
最后,当然不要忘了在你的组件外面使用ChangeNotifierProvider,没有它flutter_long_list是无法更新渲染的。
ChangeNotifierProvider<LongListProvider<FeedItem>>(
create: (_) => LongListProvider<FeedItem>(),
child: ListViewDemo()
);
至此,flutter_long_list的基本使用已经介绍完了。跟pub.dev上的一些库(比如:loading_more_list)对比,最大的特点就是数据的管理,你不用关心loadmore, refresh的处理,而且还能快速实现一些业务逻辑需求,你只需要写初始化和自己的组件就可以了。
二、组件曝光
这个功能是在开发完flutter_long_list第一版后,灵感突袭而来。要是加上曝光监听事件,可以让list的功能更加强大且更贴近业务场景,这也是其他List第三方组件并不具备的功能。曝光的计算需要两个要素:一个是可视区域,一个是曝光元素。计算曝光有几种方案:
1. 可视区域就是手机SafeArea的大小,然后通过计算list的高度和滚动条位置来计算曝光元素,但是前提是要通过设置cacheExtent来禁止预加载,所有元素都显示出来,但这样的代价是牺牲了原有滑动性能,这是无法让人接受的。
2. 通过给子节点包裹上自定义的RenderObject在paint方法中计算当前是否可见,这样的方式实现起来比较麻烦并且性能不高,有较大开销。
那有没有其他更好的方案呢?答案当然是有的。Flutter渲染流程是Widget(配置)-> Element (实例)-> Render Object(渲染对象),而Element是持有了Widget和Render Object的,那是不是可以从list的相关Element去获取相关属性进行计算呢?果然在ListView、GridView源码中发现了SliverMultiBoxAdaptorElement这个Element类,里面有一个visitChildren方法可以遍历list中的子节点,接下来就是判断子节点中哪些是可见的就可以了啊(要见到曙光了😸)。
接下来只需要计算每个元素的上边界下边界和滚动位置的关系就得到曝光元素的索引。
int firstIndex = SliverMultiBoxAdaptorElement.childCount;
int endIndex = -1;
void onVisitChildren(Element element) {
final SliverMultiBoxAdaptorParentData parentData =
element?.renderObject?.parentData;
if (parentData != null) {
double boundFirst = sliverHeadHeight != null
? parentData.layoutOffset + sliverHeadHeight + padding.top
: parentData.layoutOffset + padding.top; // 元素顶部位置
double itemLength = scrollDirection == Axis.vertical
? element.renderObject.paintBounds.height; // 元素高度宽度
: element.renderObject.paintBounds.width;
double boundEnd = itemLength + boundFirst; // 元素底部位置
if (boundFirst >= notice.metrics.pixels &&
boundEnd <=
(notice.metrics.pixels + notice.metrics.viewportDimension)) {
firstIndex = min(firstIndex, parentData.index);
endIndex = max(endIndex, parentData.index);
}
}
}
得到每次滑动曝光的索引值后,比较与上次曝光的索引值,进行过滤就能得到要上报的元素了,我们给flutter_long_list添加曝光回调,使它功能更加强大。
LongList<FeedItem>(
id: id,
mode: LongListMode.list,
itemWidget: itemWidget,
exposureCallback: (LongListProvider<FeedItem> provider, List<ToExposureItem> exposureList) {
exposureList.forEach((item) {
print('上报数据:${provider.list[id][item.index]} ${item.time}');
});
},
)
注意:首次曝光需要在initState手动触发一下滚动事件,因为曝光监听的是NotificationListener事件。
至此,关于使用SliverMultiBoxAdaptorElement计算曝光的方案就介绍完了,那么多个sliver怎么曝光呢,一个list里有多个sliver元素时该如何计算,也是去找他们的共同点,都有一个MultiChildRenderObjectElement,计算思路是与单个sliver大致相似的,只是稍微复杂点,需要维护sliver的父级索引和曝光索引一个二维映射关系(哪个sliver的哪些元素曝光了)。在这里就不详细介绍了,感兴趣的同学可以下来了解下。
三、总结
flutter_long_list目前的核心功能都介绍完了,一句话总结下它是一个具有数据管理能力的功能强大的list组件。
希望能对你有所帮助,欢迎使用交流!最后附上地址flutter_long_list