本文翻译自
原文地址:Flutter: Creating a ListView that loads one page at a time
作者:AbdulRahman AlHamali
这是官方文档在Flutter 应用性能优化最佳实践中推荐的一篇文章
以下是正文翻译:
更新:本文中采用的方法非常简单,但可能不是最佳或者最有效的,你能在线找到其他方式,这些方式并不要求你把一个ListView放置到另一个ListView中。继续阅读下去吧,喜欢本文的方式或者其他方取决于你自己式。
Flutter提供了高效的ListView
构造函数ListView.builder : 允许我们创建一个懒加载的列表,其中的条目仅当向下滑动到他们的时候才会创建,这个构造函数接收一个命名为itemBuilder
的callback回调函数,它的作用是在滑动的过程中通过触发这个callback回调,来创建新的item。
ListView.builder(
itemBuilder: (context, index) {
return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text('product $index'),
subtitle: Text('price: ${Random().nextInt(100)} USD'),
);
}
)
这种方式将会创建一个无限滑动的列表,仅当我们向下滑动到他们时才进行加载。
然而当我们想要在真实场景中使用时,事情就会变得复杂了,我们可能想要:
- 异步的从服务端获取数据
- 批量加载条目(也称为页面),比如说一次加载20个,而不是一个一个的加载。
在本文中,我们将要讨论如何做到这一点!我们先从学习使用FutureBuilder
来异步批量获取ListView
条目开始吧。接下来我们将会看到,如何一次加载一个页面的条目。
如果你比较着急,并且没有时间阅读本教程,你可以在pub上查看我的packageflutter_pagewise,它可以完全展现我在本文解释的内容。不过,我还是建议您完整阅读本文,因为文中谈论一些Flutter概念或许对你有帮助。
异步获取条目
我们想要:仅当我们向下滑动的底部时才异步的从服务端获取条目,为了实现这个效果,我们使用前文提到的ListView.builder
构造函数以及FutureBuilder
假设我们有一个函数,长这样子
_fetchEntry(int index) async {
await Future.delayed(Duration(milliseconds: 500));
return {
'name': 'product $index',
'price': Random().nextInt(100)
};
}
- 这个函数延迟500毫秒执行,模拟服务端返回数据的过程,返回的数据是在指定索引下的商品名称和价格。
接下来我们就可以在ListView.builder
中调用这个函数了:
ListView.builder(
itemBuilder: (context, index) {
return FutureBuilder(
future: this._fetchEntry(index),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return CircularProgressIndicator();
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
var productInfo = snapshot.data;
return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text(productInfo['name']),
subtitle:
Text('price: ${productInfo['price']}USD'),
);
}
}
},
);
}
)
-
FutureBuilder
是一个等待指定future
的widget,使用FutureBuilder的builder构造函数,可以用来创建其他依赖指定future的Widget。 - 当这个future还没被调用,或者被调用了还没返回结果,我们在界面上展示一个CircularProgressIndicator。
- 当这个future执行结束,我们先检查一下返回结果是否出错,如果出错的话,展示一个错误信息。
- 其他情况展示从future获取到的商品名称和价格信息。
做完这些,当我们重新打开APP,可以看到整个列表开始加载,然后展现在屏幕上,然后当你滑动到底部,同样的事情会再次发生,加载更多的条目。
看起来还不错,但是仍然有需要改进的地方:
加载过程看起来好丑
可以看到加载进度条看起来太宽了,因为它会尝试适配允许的最大宽度。为了解决这个问题,我们可以将进度条使用Alignwidge包起来,并且设置它的alignment属性为center
return Align(
alignment: Alignment.center,
child: CircularProgressIndicator()
);
这样进度条看起来好看多了
但是,主要的问题,也是这篇文章的重点:一次加载20个,而不是一个一个的加载。
一次加载整个页面
为了达到这样的效果,我们创建一个ListView
的ListView
!每个ListView.builder
的子节点,都将是包含了一整个页面条目的ListView
。
假定我们的函数_fetchEntry
不再获取单个条目数据了,而是整个页面!我们称之为_fetchPage
,如下:
_fetchPage(int pageNumber, int pageSize) async {
await Future.delayed(Duration(seconds: 1));
return List.generate(pageSize, (index) {
return {
'name': 'product $index of page $pageNumber',
'price': Random().nextInt(100)
};
});
}
- 这个函数根据
pageSize
大小,模拟一次从服务端获取一个页面的数据。 - 模拟每次请求从服务端返回数据,需要花费1秒时间。
我们的ListView.builder
现在看起来长这样子了:
ListView.builder(
itemBuilder: (context, pageNumber) {
return FutureBuilder(
future: this._fetchPage(pageNumber, 20),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Align(
alignment: Alignment.center,
child: CircularProgressIndicator()
);
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
var pageData = snapshot.data;
return this._buildPage(pageData);
}
}
},
);
}
)
- 在
ListView.builder
的构造函数中,现在调用_fetchPage
而不是_fetchEntry
,并且制定每个页面的大小为20。 - 当页面准备好了之后,我们不在返回一个上文中提到的
ListTile
,取而代之的是,取而代之的是我们调用一个_buildPage
函数,参数传入刚才从服务端获取到的一个页面的数据。
_buildPage
是做什么的呐?它创建了一个内部展示Page的ListView
!
Widget _buildPage(List page) {
return ListView(
shrinkWrap: true,
primary: false,
children: page.map((productInfo) {
return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text(productInfo['name']),
subtitle: Text('price: ${productInfo['price']}USD'),
);
}).toList()
);
}
- 这个函数返回一个
ListView
,这就是外部的ListView
的子节点,也是一个ListView
,包含一整个页面的条目。 - 我们使用Map函数来转换数据list到
ListTiles
list,每一个ListTitle
相应一条数据。 - 我们设置primary属性为
false
,来告诉flutter,这个内部ListView
不是主要的滑动响应者,因为外部的ListView
才是真正的滑动相应对象,而不是里面的。 - 我们设置shrinkWrap属性为
true
,这这个属性告知flutter,内部的ListView
不应该在垂直滚动时尝试展现无限列表,这也是父视图ListView
应该做的事情。
新的效果如下:
这...仍然不是我们想要的效果!实际上它并没有加载一页,而是大约15页。
为什么会这样!问题出现在加载进度条上,当我们第一次启动时,加载进度条被展示出来,它们比较小,因此ListView.builder
判断可以显示15个左右的条目在屏幕上,自然也就给我们展示了15页。
接下来我们能做什么呐?继续将加载进度条包裹在一个更大的widget中
SizedBox(
height: MediaQuery.of(context).size.height * 2,
child: Align(
alignment: Alignment.topCenter,
child: CircularProgressIndicator()
),
);
- SizedBox是一个可以允许我们固定尺寸的widget。
- 我们设置了高度为两倍的屏幕尺寸。这样builder就只会一次加载一个条目了,为了获取屏幕高度我们使用了MediaQuery.of函数,它会给我们返回当前屏幕尺寸。
- 我们也设置了
Align
widget的alignment
属性为topCenter
,这样进度条就会显示在SizedBox
的顶部区域了。
现在新的效果看起来更好了:
这样,我们差不多快达成目标了!
最后一个问题
还有什么问题呐?这个实现仍然有一点小bug,一开始很难发现,但是加如你向下滚动几页,然后向上滚动,你会发现滚动有被中断的现象,它总是以一种奇怪的方式,扰乱了我们。
解释这个现象有点复杂:ListView
,也包括通常的ScrollView通常倾向于销毁不在屏幕上的子节点,当我们回滚回来的时候,子节点将会被重新初始化,但是这种情况下,我们的子节点是一个FutureBuilder
,重新初始化它会在短短的1秒内再次创建一个进度条,这混淆了滚动机制,以一个不确定的方式使我们陷入混乱。
怎么解决呢?
一种解决方式是,确保加载进度指示器和我们的页面尺寸完全一样,但是在大多数情况下,这有点不适用。因此,我们将尝试一种效率稍微低一点的方式,但是将能够解决我们的问题:我们防止ListView
销毁子节点,为了做到这一点,我们需要将每个孩子节点,即FutureBuilder
使用AutomaticKeepAliveClientMixin包裹起来。这个mixin
使得每个孩子节点要求父节点让让他们保活,即使他们在屏幕外,这也将能够解决我们的问题。因此:
-
- 使用
KeepAliveFutureBuilder
替换代码中的FutureBuilder
。
- 使用
-
- 创建
KeepAliveFutureBuilder
widget。
- 创建
class KeepAliveFutureBuilder extends StatefulWidget {
final Future future;
final AsyncWidgetBuilder builder;
KeepAliveFutureBuilder({
this.future,
this.builder
});
@override
_KeepAliveFutureBuilderState createState() => _KeepAliveFutureBuilderState();
}
class _KeepAliveFutureBuilderState extends State with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: widget.future,
builder: widget.builder,
);
}
@override
bool get wantKeepAlive => true;
}
- 这个widget仅是对
FutureBuilder
做了一层包裹,它是一个StatefulWidget
widget,它的State继承自State类,并且mixin了AutomaticKeepAliveClientMixin
。 - 重写了wantKeepAlive的getter方法设置返回值为
true
,意味着我们想要ListView
保活。
到此,我们真的结束了!我们创建了一个一次加载一个页面的ListView
,它可能不是最有效的,但是解决了我们的问题。
让我们回顾一下,这段代码实在太多了,我建议将整个逻辑抽象成一个独立的widget,或者使用我的packageflutter_pagewise,该package提供了一个精美的可扩展的widget,可以同时适用于解决ListView
和GridView
的决这个问题。