Flutter:创建一个一次加载一个页面的ListView

本文翻译自
原文地址:Flutter: Creating a ListView that loads one page at a time
作者:AbdulRahman AlHamali

这是官方文档在Flutter 应用性能优化最佳实践中推荐的一篇文章

recommend.png

以下是正文翻译:

更新:本文中采用的方法非常简单,但可能不是最佳或者最有效的,你能在线找到其他方式,这些方式并不要求你把一个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条目开始吧。接下来我们将会看到,如何一次加载一个页面的条目。

最终效果.gif

如果你比较着急,并且没有时间阅读本教程,你可以在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,可以看到整个列表开始加载,然后展现在屏幕上,然后当你滑动到底部,同样的事情会再次发生,加载更多的条目。

ugly.gif

看起来还不错,但是仍然有需要改进的地方:

加载过程看起来好丑

可以看到加载进度条看起来太宽了,因为它会尝试适配允许的最大宽度。为了解决这个问题,我们可以将进度条使用Alignwidge包起来,并且设置它的alignment属性为center

return Align(
  alignment: Alignment.center,
  child: CircularProgressIndicator()
);

这样进度条看起来好看多了


better.gif

但是,主要的问题,也是这篇文章的重点:一次加载20个,而不是一个一个的加载。

一次加载整个页面

为了达到这样的效果,我们创建一个ListViewListView!每个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到ListTileslist,每一个ListTitle相应一条数据。
  • 我们设置primary属性为false,来告诉flutter,这个内部ListView不是主要的滑动响应者,因为外部的ListView才是真正的滑动相应对象,而不是里面的。
  • 我们设置shrinkWrap属性为true,这这个属性告知flutter,内部的ListView不应该在垂直滚动时尝试展现无限列表,这也是父视图ListView应该做的事情。

新的效果如下:

loadpage.gif

这...仍然不是我们想要的效果!实际上它并没有加载一页,而是大约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函数,它会给我们返回当前屏幕尺寸。
  • 我们也设置了Alignwidget的alignment属性为topCenter,这样进度条就会显示在SizedBox的顶部区域了。

现在新的效果看起来更好了:

betteragain.gif

这样,我们差不多快达成目标了!

最后一个问题

还有什么问题呐?这个实现仍然有一点小bug,一开始很难发现,但是加如你向下滚动几页,然后向上滚动,你会发现滚动有被中断的现象,它总是以一种奇怪的方式,扰乱了我们。

bug.gif

解释这个现象有点复杂:ListView,也包括通常的ScrollView通常倾向于销毁不在屏幕上的子节点,当我们回滚回来的时候,子节点将会被重新初始化,但是这种情况下,我们的子节点是一个FutureBuilder,重新初始化它会在短短的1秒内再次创建一个进度条,这混淆了滚动机制,以一个不确定的方式使我们陷入混乱。

怎么解决呢?

一种解决方式是,确保加载进度指示器和我们的页面尺寸完全一样,但是在大多数情况下,这有点不适用。因此,我们将尝试一种效率稍微低一点的方式,但是将能够解决我们的问题:我们防止ListView 销毁子节点,为了做到这一点,我们需要将每个孩子节点,即FutureBuilder使用AutomaticKeepAliveClientMixin包裹起来。这个mixin使得每个孩子节点要求父节点让让他们保活,即使他们在屏幕外,这也将能够解决我们的问题。因此:

    1. 使用KeepAliveFutureBuilder替换代码中的FutureBuilder
    1. 创建KeepAliveFutureBuilderwidget。
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做了一层包裹,它是一个StatefulWidgetwidget,它的State继承自State类,并且mixin了AutomaticKeepAliveClientMixin
  • 重写了wantKeepAlive的getter方法设置返回值为true,意味着我们想要ListView保活。

到此,我们真的结束了!我们创建了一个一次加载一个页面的ListView,它可能不是最有效的,但是解决了我们的问题。

让我们回顾一下,这段代码实在太多了,我建议将整个逻辑抽象成一个独立的widget,或者使用我的packageflutter_pagewise,该package提供了一个精美的可扩展的widget,可以同时适用于解决ListViewGridView的决这个问题。

你可能感兴趣的:(Flutter:创建一个一次加载一个页面的ListView)