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<KeepAliveFutureBuilder> 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的决这个问题。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,546评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,224评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,911评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,737评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,753评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,598评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,338评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,249评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,696评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,888评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,013评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,731评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,348评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,929评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,048评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,203评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,960评论 2 355

推荐阅读更多精彩内容