flutter ListView懒加载(上)——先搞大概的原理

序:温馨提醒

干活一定要细致,有可能一些鸡毛蒜皮的参数错误就会让你找一下午甚至一天都没什么进展。
为了学习懒加载,后端插入了100条假数据,写了一个接口叫listPage——结果我从调list接口的方法复制粘贴后恰恰没有改接口地址,导致我对着那个List.from(pageBean.records!.map((e) => ContactBean.fromJson(e)))(这行代码实际上是对的)想破头都没想出来这行代码为啥报错:明明数据是个List,在records里,为什么这行一直报错?!而list接口的这行却不会?!

人在江湖飘哪有不挨刀,常在河边走怎能不湿鞋呢!

还有,貌似在Windows下,RefreshIndicator是不响应的……

好吧,开始正文了。

1、首先要搞懂懒加载(分页加载)是什么东西

平时翻各式各样的app,大列表(例如淘宝饿了吗美团什么的)100%不是一下子全部加载出来的(它们都还有个性化推荐,所以几乎不可能)——那就是和竹子一样一截一截加载出来的,既然是一截一截的数据,那我就需要知道这些东西是出自哪一截,于是就有了PageBean这种对象的存在,可以告诉你这些数据是整体的哪一部分。

就拿以下数据举例(纯AI生成,如有雷同纯属巧合):

{
    "code": 200,
    "msg": "操作成功",
    "data": {
        "records": [
            {
                "id": 1,
                "firstName": "三",
                "lastName": "张",
                "phoneNumber": "13333333333",
                "age": 18,
                "sex": 1,
                "address": "就住在那个屯"
            },
            {
                "id": 1936971071475949571,
                "firstName": "伟",
                "lastName": "张",
                "phoneNumber": "13811234567",
                "age": 28,
                "sex": 1,
                "address": "北京市朝阳区建国路88号"
            }
        ],
        "total": 101,
        "size": 10,
        "current": 1,
        "pages": 11
    }
}

最外层的基本是个人都认识:返回状态和消息,也就是codemsgdata里面放请求回来的数据。有些时候这个data里直接就是数据json,但要是有分页,data这层就首先是一些分页数据。data里面的records才是真正的数据。
有人说我能不能去掉这外面的分页——虽说也不是不行,但起码后端提供分页数据的方式是目前来说最佳实践、简便路径和通用做法。从这个分页数据来看,
total能告诉你后端总共有多少条,
size告诉你这回返回来几条(当然这个数字是错的,因为要照顾阅读体验故意删的剩两条而非10,勿喷),
current告诉你这是第几页
pages告诉你后端总共有多少页
注:我后端springboot用mybatisplus自带的分页器,人家长这个样,如果是mybatis或者别的衍生产品甚至是hibernate/jpa之类的不保证字眼一样,但提供的内容类似,具体请问你的后端同学或者看自己用的什么包。

好了说完PageBean是什么,然后看看通常怎么用(业务逻辑):
1、刚进去页面、下拉刷新请求加载第一页数据,先清空List容器,请求到数据后再填充进去List容器;
2、列表拉到底就在最下面显示loading样式,请求加载第n+1页的数据(n指当前页),然后把请求到的数据往后跟着填充进去List容器;
3、列表再次拉到底部重复第二步;
4、我所有数据都展示出来了,列表理论上拉到最底下了,最下面不能是loading了应该提示结束了,不请求数据了。

理论完毕,开始实操!

2、 网络请求部分怎么写

惯例先上代码:

main_provider.dart
import 'dart:convert';

import 'package:day05/bean/base_bean.dart';
import 'package:day05/bean/contact_bean.dart';
import 'package:day05/dio_util.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';

import 'bean/page_bean.dart';

class MainProvider with ChangeNotifier {
  String _text = '';

  String get text => _text;

  List<ContactBean> _dataList = [];
  List<ContactBean> get dataList => _dataList;

  int _pageNum = 1;
  int get pageNum => _pageNum;


  Future<void> getList() async {
    await DioUtil.getInstance().get(
      "http://192.168.5.116:20080/contact/list",
      null,
      () {
        _text = "loading";
        notifyListeners();
      },
      (data) {
        BaseBean bean = BaseBean.fromJson(data);
        _dataList=List.from(bean.data.map((e) => ContactBean.fromJson(e)));
        notifyListeners();
      },
      (reason) {
        _text = reason;
        notifyListeners();
      },
    );
  }

  Future<void> getListPage(int pageNum) async {
    await DioUtil.getInstance().get(
      "http://192.168.5.116:20080/contact/listPage",
      {
        'page': pageNum,
        'size': 10,
      },
          () {
        _text = "loading";
        notifyListeners();
      },
          (data) {
        var bean = BaseBean.fromJson(data);
        var pageBean = PageBean.fromJson(bean.data);
        if(pageBean.current==1) {
          _dataList.clear();
        }
        _dataList.addAll(List.from(pageBean.records!.map((e) => ContactBean.fromJson(e))));
        notifyListeners();
      },
          (reason) {
        _text = reason;
        notifyListeners();
      },
    );
  }

  void addPage(){
    _pageNum++;
  }

  void resetPage() {
    _pageNum = 1;
  }

  void addContact() async {
    DioUtil.getInstance().addHeader("token", "123456789");
    await DioUtil.getInstance().post(
      "http://192.168.5.116:20080/contact/add",
      {
        'firstName': '王',
        'lastName': '六',
        'phoneNumber': '13456789010',
        'age': '22',
        'sex': 1,
        'address': '山西省晋城市',
      },
      () {
        _text = "loading";
        notifyListeners();
      },
      (data) {
        _text = '添加成功';
        notifyListeners();
      },
      (reason) {
        _text = reason;
        notifyListeners();
      },
    );
  }
}

以上这个是让你们复制的,现在摘出来重点:

List<ContactBean> _dataList = [];
List<ContactBean> get dataList => _dataList;

int _pageNum = 1;
int get pageNum => _pageNum;

Future<void> getListPage(int pageNum) async {
   await DioUtil.getInstance().get(
     "http://192.168.5.116:20080/contact/listPage",
     {
       'page': pageNum,
       'size': 10,
     },
         () {
       _text = "loading";
       notifyListeners();
     },
         (data) {
       var bean = BaseBean.fromJson(data);
       var pageBean = PageBean.fromJson(bean.data);
       if(pageBean.current==1) {
         _dataList.clear();
       }
       _dataList.addAll(List.from(pageBean.records!.map((e) => ContactBean.fromJson(e))));
       notifyListeners();
     },
         (reason) {
       _text = reason;
       notifyListeners();
     },
   );
 }

void addPage(){
  _pageNum++;
}

void resetPage() {
  _pageNum = 1;
}

因为众所周知,UI是StatelessWidget,所以状态都在provider里。
现在新增了一个_pageNum的私有变量,默认就是1,代表第一页
addPage()方法和resetPage()方法是用来页数+1和重置回1的
然后就讲getListPage(int pageNum)方法,传入要请求的页数,页数加减通过刚上面讲的两个方法控制,里面的逻辑也和我们讲原理时候的情况是符合的:
第一页就先清空再重新填入数据,其他页就往后跟数据。

我感觉没什么需要额外说的了,这个ListView暂时看起来要比Android原生的RecyclerView要注意的东西要少很多。

3、我数据拿到了如何展示?

先试一下手动请求分页数据~
上代码!

main.dart
import 'package:day05/main_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_styled_toast/flutter_styled_toast.dart';
import 'package:provider/provider.dart';


void main() {
  runApp(
    MultiProvider(
      providers: [ChangeNotifierProvider(create: (context) => MainProvider())],
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    final provider = Provider.of<MainProvider>(context);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme
            .of(context)
            .colorScheme
            .inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(provider.text),
            ElevatedButton(
              onPressed: provider.getList,
              child: const Text('请求数据'),
            ),
            ElevatedButton(
              onPressed: () => provider.addContact(),
              child: const Text('添加一个联系人'),
            ),
            Expanded(
                child: RefreshIndicator(
                  onRefresh: () async {
                    // 刷新时重置页码并重新加载数据
                    provider.resetPage();
                    await provider.getListPage(1);
                  },
                  child: ListView.builder(
                    itemCount: provider.dataList.length + 1, // 多一个加载提示项
                    itemBuilder: (context, index) {
                      if (index < provider.dataList.length) {
                        return ListTile(
                          title: Text('${provider.dataList[index].lastName} ${provider.dataList[index].firstName}'),
                          subtitle: Text('${provider.dataList[index].phoneNumber}'),
                          onTap: () async  {
                            if(index%2==0){
                              showToast('点击了第${index+1}个联系人:${provider.dataList[index].lastName} ${provider.dataList[index].firstName}',context: context);
                            } else {
                              var data =await showADialog(context, '提示',
                                  '点击了第${index + 1}个联系人:${provider
                                      .dataList[index].lastName} ${provider
                                      .dataList[index].firstName}');
                              showToast(data.toString(), context: context);
                            }
                          },
                        );
                      } else {
                        // 显示加载提示
                        return ElevatedButton(onPressed: (){
                          provider.addPage();
                          provider.getListPage(provider.pageNum);
                        }, child:const Text('刷新列表'));
                      }
                    },
                  ),
                )
            )
          ],
        ),
      ),
    );
  }

  Future<dynamic> showADialog(context,title,content){
    return showDialog(
        context: context,
        builder: (context)=>AlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(3),
              child: Text('某种中立按钮?'),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(2),
              child: Text('取消'),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(1),
              child: Text('确定'),
            ),
          ],
        )
    );
  }
}

相较于之前只有下拉刷新那会,ListView.BuilderitemCount属性不是List的长度,而是+1了,多那一条就是为了放上拉加载的框框了。
我这里先暂时放着一个手动的加载按钮,按一下再加载下面10条数据

也就是现在的UI逻辑是:下拉刷新->上拉到最后一条->按一下按钮加载第二页,之后以此类推。
(本来还想的itemCount这么写:itemCount: provider.dataList.isEmpty ? 0 : provider.dataList.length + 1,,但实操证明虽然可以在没有数据的时候隐藏但用起来会卡顿,所以就先露着这个bug之后解决)

实验有效。

这时候有人就问了:这手动的刷新方法能做app?开什么玩笑!
那现在就开始尝试让它自动化!

上代码!

main.dart
import 'package:day05/main_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_styled_toast/flutter_styled_toast.dart';
import 'package:provider/provider.dart';


void main() {
  runApp(
    MultiProvider(
      providers: [ChangeNotifierProvider(create: (context) => MainProvider())],
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    final provider = Provider.of<MainProvider>(context);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme
            .of(context)
            .colorScheme
            .inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(provider.text),
            ElevatedButton(
              onPressed: provider.getList,
              child: const Text('请求数据'),
            ),
            ElevatedButton(
              onPressed: () => provider.addContact(),
              child: const Text('添加一个联系人'),
            ),
            Expanded(
                child: RefreshIndicator(
                  onRefresh: () async {
                    // 刷新时重置页码并重新加载数据
                    provider.resetPage();
                    await provider.getListPage(provider.pageNum);
                  },
                  child: NotificationListener<ScrollNotification>(
                    onNotification: (scrollInfo){
                      if (scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent) {
                        // 滚动到底部,加载下一页
                        provider.addPage();
                        provider.getListPage(provider.pageNum);
                      }
                      return false;
                    },
                    child: ListView.builder(
                      itemCount: provider.dataList.isEmpty ? 0 : provider.dataList.length + 1, 
                      itemBuilder: (context, index) {
                        if (index < provider.dataList.length) {
                          return ListTile(
                            title: Text('${provider.dataList[index].lastName} ${provider.dataList[index].firstName}'),
                            subtitle: Text('${provider.dataList[index].phoneNumber}'),
                            onTap: () async  {
                              if(index%2==0){
                                showToast('点击了第${index+1}个联系人:${provider.dataList[index].lastName} ${provider.dataList[index].firstName}',context: context);
                              } else {
                                var data =await showADialog(context, '提示',
                                    '点击了第${index + 1}个联系人:${provider
                                        .dataList[index].lastName} ${provider
                                        .dataList[index].firstName}');
                                showToast(data.toString(), context: context);
                              }
                            },
                          );
                        } else {
                          // 显示加载提示
                          return const Center(child: CircularProgressIndicator());
                        }
                      },
                    ),
                  ),
                )
            )
          ],
        ),
      ),
    );
  }

  Future<dynamic> showADialog(context,title,content){
    return showDialog(
        context: context,
        builder: (context)=>AlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(3),
              child: Text('某种中立按钮?'),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(2),
              child: Text('取消'),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(1),
              child: Text('确定'),
            ),
          ],
        )
    );
  }
}

主要原理就是在RefreshIndicator下用NotificationListener再包一层,用于监听ListView的滑动事件,再往下才是ListView。
在NotificationListener的onNotification事件中将刚才手动按按钮的事件给自动化:当ListView划拉到最底下后去调接口请求下一页的数据。
而ListView中+1的那一块返回一个加载圈圈用来表示加载状态。

这样你就能在手机上得到一个能看的ListView,但还离能用差的很远。
所以该篇文章有后续,等我摸透了马上更。

上一篇
下一篇