一、 项目概述

自主设计并实现一个通用的Flutter分页列表模块。不再绑定特定API,创建一个可复用的文章列表展示页,核心目标包括:

  • 掌握 pull_to_refresh 库与Flutter的深度集成
  • 设计合理的状态管理架构处理分页逻辑
  • 封装高可复用的UI组件
  • 实现完整的加载状态流(加载中/空数据/错误/成功)

二、 架构设计

┌─────────────────┐    ┌──────────────────────┐    ┌──────────────────┐
│   ArticleCard   │ ←  │ ArticleListPage      │ ←  │ PaginationController │
│   (展示单元)    │    │ (UI容器)              │    │ (状态大脑)        │
└─────────────────┘    └──────────────────────┘    └──────────────────┘
                              │                              │
                              ↓                              ↓
                    ┌─────────────────┐    ┌──────────────────┐
                    │ SmartRefresher  │    │ 模拟数据服务      │
                    │ (交互引擎)      │    │ (数据源)         │
                    └─────────────────┘    └──────────────────┘

状态集中管理:所有分页状态(页码、是否有更多数据、错误信息)统一由 PaginationController 管理

UI与逻辑分离:页面只负责展示,所有业务逻辑委托给控制器

响应式设计:通过 Stream 实现状态变化到UI的自动更新

三、 代码设计

文件路径 核心功能
lib/models/article.dart 文章数据模型定义,封装文章属性及数据转换方法
lib/widgets/article_card.dart 文章卡片 UI 组件,负责单篇文章的视觉展示
lib/controllers/pagination_controller.dart 分页控制器,封装分页加载、刷新、错误处理等核心逻辑
lib/pages/article_list_page.dart 文章列表页面,整合分页控制器与 UI,实现下拉刷新 / 上拉加载
lib/main.dart 应用入口,配置主题和根页面

1. 数据模型:article.dart

核心功能

定义 Article 数据模型,封装文章的所有属性,并提供从模拟 API 数据转换为模型的工厂方法,统一数据解析逻辑。

class Article {
  // 核心属性:覆盖文章展示所需的所有数据维度
  final String id;
  final String title;
  final String summary;
  final String author;
  final DateTime publishDate;
  final int readCount;
  final List<String> tags;
  
  // 构造函数:强制必填所有核心属性,保证数据完整性
  Article({
    required this.id,
    required this.title,
    required this.summary,
    required this.author,
    required this.publishDate,
    required this.readCount,
    required this.tags,
  });
  
  // 工厂构造函数:处理API数据解析,兼容空值
  factory Article.fromMockApi(Map<String, dynamic> json) {
    return Article(
      id: json['id'] ?? '',
      title: json['title'] ?? '无标题',
      summary: json['summary'] ?? '暂无摘要',
      author: json['author'] ?? '匿名作者',
      publishDate: DateTime.parse(json['publishDate'] ?? DateTime.now().toString()),
      readCount: json['readCount'] ?? 0,
      tags: List<String>.from(json['tags'] ?? []),
    );
  }
}
关键用法
  • 不可变数据设计:所有属性用 final 修饰,保证数据安全性;
  • 空值兜底:解析模拟 API 数据时,为每个字段设置默认值,避免空指针异常;
  • 工厂方法:解耦数据解析与模型创建,适配 API 数据格式。

2. UI 组件:article_card.dart

核心功能

封装文章卡片组件 ArticleCard,作为无状态组件(StatelessWidget),接收 Article 模型数据和点击回调,负责单篇文章的可视化展示。

class ArticleCard extends StatelessWidget {
  final Article article;
  final VoidCallback? onTap;
  
  const ArticleCard({
    super.key,
    required this.article,
    this.onTap,
  });

  // 标签芯片封装:复用标签样式
  Widget _buildTagChip(String tag) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      margin: const EdgeInsets.only(right: 6),
      decoration: BoxDecoration(
        color: Colors.blue[50],
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.blue[100]!, width: 1),
      ),
      child: Text(tag, style: const TextStyle(fontSize: 11, color: Colors.blue)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 1,
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      child: InkWell(
        onTap: onTap, // 点击回调透传
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 标题(最多2行,超出省略)
              Text(article.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis),
              const SizedBox(height: 8),
              // 摘要(最多2行,超出省略)
              Text(article.summary, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[700]), maxLines: 2, overflow: TextOverflow.ellipsis),
              // 标签云(横向滚动,无标签则不显示)
              if (article.tags.isNotEmpty) ...[
                SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: Row(children: article.tags.map((tag) => _buildTagChip(tag)).toList()),
                ),
              ],
              // 底部信息:作者头像/名称、阅读量、发布日期
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  // 作者信息
                  Row(children: [
                    CircleAvatar(radius: 12, backgroundColor: Theme.of(context).primaryColor, child: Text(article.author.substring(0, 1), style: const TextStyle(color: Colors.white))),
                    const SizedBox(width: 8),
                    Text(article.author, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
                  ]),
                  // 阅读量+日期
                  Row(children: [
                    Icon(Icons.remove_red_eye_outlined, size: 14, color: Colors.grey[500]),
                    Text('${article.readCount}', style: TextStyle(fontSize: 12, color: Colors.grey[600])),
                    Text('${article.publishDate.month}-${article.publishDate.day}', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
                  ]),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
关键用法
  • 组件复用:通过 _buildTagChip 方法封装标签样式,减少重复代码;
  • 自适应布局:
    • 标题 / 摘要设置 maxLinesTextOverflow.ellipsis,避免文字溢出;
    • 标签云使用 SingleChildScrollView 横向滚动,适配多标签场景;
  • 交互适配:InkWell 包裹卡片,实现点击水波纹效果,透传 onTap 回调;
  • 主题适配:使用 Theme.of(context) 获取全局主题色,保证样式统一。

3. 分页控制器:pagination_controller.dart

核心功能

封装通用分页逻辑(支持任意数据类型 T),通过 Stream 管理数据、加载状态、错误信息,实现分页加载、下拉刷新、错误处理等核心能力,解耦数据逻辑与 UI。

import 'dart:async';

class PaginationController<T> {
  // 状态流:广播模式,支持多监听
  final StreamController<List<T>> _dataController = StreamController.broadcast();
  final StreamController<bool> _loadingController = StreamController.broadcast();
  final StreamController<String?> _errorController = StreamController.broadcast();
  
  // 内部状态管理
  List<T> _items = []; // 已加载的所有数据
  int _currentPage = 1; // 当前页码
  bool _hasMore = true; // 是否还有更多数据
  bool _isLoading = false; // 是否正在加载
  
  // 对外暴露流,供UI监听
  Stream<List<T>> get dataStream => _dataController.stream;
  Stream<bool> get loadingStream => _loadingController.stream;
  Stream<String?> get errorStream => _errorController.stream;
  
  // 核心加载方法:支持刷新/加载更多,适配自定义数据获取函数
  Future<void> loadData({
    required Future<List<T>> Function(int page, int perPage) fetchFn,
    bool refresh = false,
    int perPage = 15,
  }) async {
    // 防重复加载
    if (_isLoading) return;
    // 刷新模式:重置页码、数据、是否有更多
    if (refresh) {
      _currentPage = 1;
      _hasMore = true;
      _items.clear();
    }
    // 无更多数据且非刷新,直接返回
    if (!_hasMore && !refresh) return;

    // 更新加载状态
    _isLoading = true;
    _loadingController.add(true);
    _errorController.add(null);

    try {
      // 调用外部传入的数据源函数
      final newItems = await fetchFn(_currentPage, perPage);
      // 更新数据:刷新=覆盖,加载更多=追加
      refresh ? _items = newItems : _items.addAll(newItems);
      // 判断是否还有更多(数据量>=每页条数则认为有更多)
      _hasMore = newItems.length >= perPage;
      if (_hasMore) _currentPage++;
      // 通知UI更新数据
      _dataController.add(List<T>.from(_items));
    } catch (e) {
      // 错误处理:发送错误信息,刷新模式下清空数据
      _errorController.add('加载失败: ${e.toString()}');
      if (refresh) {
        _items.clear();
        _dataController.add([]);
      }
    } finally {
      // 恢复加载状态
      _isLoading = false;
      _loadingController.add(false);
    }
  }
  
  // 手动刷新方法
  void refreshData(Future<List<T>> Function(int, int) fetchFn) {
    loadData(fetchFn: fetchFn, refresh: true);
  }
  
  // 资源释放:防止内存泄漏
  void dispose() {
    _dataController.close();
    _loadingController.close();
    _errorController.close();
  }
}
关键用法
  • 泛型设计:支持任意数据类型(如 Article),通用性强;
  • 响应式状态:通过 Stream 实现状态通知,UI 可通过 StreamBuilder 监听状态变化;
  • 防重复加载:_isLoading 标记避免多次触发加载;
  • 解耦数据源:fetchFn 为外部传入的异步函数,适配不同的数据源(模拟 / 真实 API);
  • 资源管理:提供 dispose 方法关闭 StreamController,避免内存泄漏。

4. 列表页面:article_list_page.dart

核心功能

整合分页控制器与 UI 组件,实现文章列表的展示、下拉刷新、上拉加载更多,处理空状态、加载状态、错误状态的 UI 展示。

class ArticleListPage extends StatefulWidget {
  const ArticleListPage({super.key});
  
  @override
  State<ArticleListPage> createState() => _ArticleListPageState();
}

class _ArticleListPageState extends State<ArticleListPage> {
  // 初始化分页控制器(指定数据类型为Article)
  final PaginationController<Article> _paginationController = PaginationController<Article>();
  final RefreshController _refreshController = RefreshController();

  // 模拟数据获取函数:适配分页控制器的fetchFn格式
  Future<List<Article>> _fetchArticles(int page, int perPage) async {
    await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络延迟
    final mockData = List.generate(perPage, (index) {
      final id = (page - 1) * perPage + index + 1;
      return Article(
        id: 'article_$id',
        title: 'Flutter开发实战:第$id讲 - 高级状态管理技巧',
        summary: '本文将深入探讨Flutter中的状态管理方案...',
        author: '作者${id % 5 + 1}',
        publishDate: DateTime.now().subtract(Duration(days: id * 2)),
        readCount: 1000 + id * 50,
        tags: ['Flutter', 'Dart', '移动开发'].sublist(0, (id % 3) + 1),
      );
    });
    // 模拟最后一页数据不足
    if (page >= 3) return mockData.sublist(0, perPage ~/ 2);
    return mockData;
  }

  @override
  void initState() {
    super.initState();
    // 初始化加载第一页数据
    _paginationController.loadData(fetchFn: _fetchArticles);
  }

  @override
  void dispose() {
    // 释放控制器资源
    _paginationController.dispose();
    _refreshController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('技术文章'), actions: [
        // 手动刷新按钮
        IconButton(icon: const Icon(Icons.refresh), onPressed: () => _paginationController.refreshData(_fetchArticles)),
      ]),
      body: Column(children: [
        // 错误状态展示
        StreamBuilder<String?>(
          stream: _paginationController.errorStream,
          builder: (context, snapshot) {
            if (snapshot.hasData && snapshot.data != null) {
              return Container(
                padding: const EdgeInsets.all(16),
                color: Colors.red[50],
                child: Row(children: [
                  Icon(Icons.error_outline, color: Colors.red[400]),
                  Expanded(child: Text(snapshot.data!, style: TextStyle(color: Colors.red[600]))),
                  TextButton(onPressed: () => _paginationController.refreshData(_fetchArticles), child: const Text('重试')),
                ]),
              );
            }
            return const SizedBox.shrink();
          },
        ),
        // 核心列表区域:下拉刷新/上拉加载
        Expanded(
          child: SmartRefresher(
            controller: _refreshController,
            enablePullDown: true, // 开启下拉刷新
            enablePullUp: true, // 开启上拉加载
            header: const ClassicHeader(), // 刷新头部样式
            footer: const ClassicFooter(), // 加载底部样式
            onRefresh: () async {
              // 下拉刷新:重置加载第一页
              await _paginationController.loadData(fetchFn: _fetchArticles, refresh: true);
              _refreshController.refreshCompleted();
            },
            onLoading: () async {
              // 上拉加载:加载下一页
              await _paginationController.loadData(fetchFn: _fetchArticles, refresh: false);
              _refreshController.loadComplete();
            },
            // 列表内容:监听分页控制器的数据流
            child: StreamBuilder<List<Article>>(
              stream: _paginationController.dataStream,
              initialData: const [],
              builder: (context, snapshot) {
                final articles = snapshot.data ?? [];
                // 空状态展示
                if (articles.isEmpty) {
                  return Center(child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.article_outlined, size: 64, color: Colors.grey[300]),
                      const Text('暂无文章内容', style: TextStyle(fontSize: 16, color: Colors.grey)),
                    ],
                  ));
                }
                // 正常列表:遍历文章生成ArticleCard
                return ListView.builder(
                  itemCount: articles.length,
                  itemBuilder: (context, index) => ArticleCard(
                    article: articles[index],
                    onTap: () {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('点击了: ${articles[index].title}')),
                      );
                    },
                  ),
                );
              },
            ),
          ),
        ),
        // 加载中状态展示
        StreamBuilder<bool>(
          stream: _paginationController.loadingStream,
          initialData: false,
          builder: (context, snapshot) {
            if (snapshot.data == true) {
              return const Padding(
                padding: EdgeInsets.all(16),
                child: Center(child: CircularProgressIndicator()),
              );
            }
            return const SizedBox.shrink();
          },
        ),
      ]),
    );
  }
}
关键用法
  • 状态管理:通过 StreamBuilder 监听分页控制器的 dataStream/loadingStream/errorStream,实现 UI 与状态的联动;
  • 刷新 / 加载封装:使用 pull_to_refresh 插件的 SmartRefresher 实现下拉刷新、上拉加载,关联分页控制器的 loadData 方法;
  • 多状态 UI:处理空数据、加载中、加载失败等状态,提升用户体验;
  • 生命周期管理:initState 初始化加载数据,dispose 释放控制器资源。

5. 应用入口:main.dart

核心功能

Flutter 应用的入口文件,配置全局主题、根页面,启动应用。

import 'package:flutter/material.dart';
import 'pages/article_list_page.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter分页列表示例',
      // 全局主题配置
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: const ArticleListPage(), // 根页面为文章列表页
      debugShowCheckedModeBanner: false, // 隐藏调试标签
    );
  }
}
关键用法
  • 主题配置:使用 ThemeData 设置全局主色调(蓝色),启用 Material3 设计;
  • 根页面指定:将 ArticleListPage 作为应用首页,简化路由配置。

5. 总结

  1. 分层设计:数据模型(Model)→ 业务逻辑(Controller)→ UI 组件(Widget)→ 页面(Page),解耦各层职责;
  2. 响应式状态:通过 Stream/StreamBuilder 实现状态驱动 UI,替代 setState 减少冗余刷新;
  3. 通用化封装:分页控制器使用泛型,适配任意数据类型,可复用至其他列表场景;
  4. 用户体验:处理空状态、加载状态、错误状态,实现下拉刷新 / 上拉加载,提升交互体验;
  5. 资源管理:所有 StreamController/RefreshController 均在 dispose 中释放,避免内存泄漏。

6. api替换

步骤 1:添加网络库依赖

在项目根目录的 pubspec.yaml 中添加 dio 依赖(最常用的 Flutter 网络库):

    dio: ^5.4.0  # 新增dio依赖,版本可按需调整

    步骤 2:修改数据获取方法(核心修改)

    打开 article_list_page.dart,替换 _fetchArticles 方法的模拟逻辑为真实 API 请求,保留原有入参(page/perPage)和返回值(Future<List<Article>>),确保和分页控制器兼容

    // 1. 导入dio库
    import 'package:dio/dio.dart';
    
    // 2. 替换原有_fetchArticles方法
    Future<List<Article>> _fetchArticles(int page, int perPage) async {
      // 初始化Dio实例
      final dio = Dio();
      
      try {
        // 发送真实API请求(替换为你的实际接口地址)
        // 示例:GET请求,携带分页参数page和perPage
        final response = await dio.get(
          'https://你的API域名/api/articles', // 替换为真实接口地址
          queryParameters: {
            'page': page,        // 分页页码
            'per_page': perPage, // 每页条数
          },
        );
        
        // 解析返回的JSON数据(根据实际API返回格式调整)
        // 假设API返回格式:{"code":200,"data":{"list":[...]}}
        final List<dynamic> dataList = response.data['data']['list'];
        
        // 将API返回的JSON转换为Article对象列表
        return dataList.map((json) => Article.fromMockApi(json)).toList();
        
      } catch (e) {
        // 捕获网络请求错误,抛给分页控制器的错误处理
        throw Exception('网络请求失败: ${e.toString()}');
      }
    }

    步骤 3:适配实际 API 的返回格式


    如果API 返回格式和 Article.fromMockApi 定义的字段不匹配,只需修改 Article 模型的 fromMockApi 工厂方法,适配真实字段:
    打开 article.dart,调整 fromMockApi 方法的字段映射

    factory Article.fromMockApi(Map<String, dynamic> json) {
      return Article(
        id: json['id'] ?? '', // 替换为API实际的id字段名(如article_id)
        title: json['title'] ?? '无标题', // 匹配API的标题字段
        summary: json['summary'] ?? json['desc'] ?? '暂无摘要', // 示例:兼容不同字段名
        author: json['author_name'] ?? '匿名作者', // 替换为API的作者字段
        publishDate: DateTime.parse(json['publish_time'] ?? DateTime.now().toString()), // 替换为API的日期字段
        readCount: json['read_num'] ?? 0, // 替换为API的阅读数字段
        tags: List<String>.from(json['tags'] ?? json['categories'] ?? []), // 替换为API的标签字段
      );
    }

    欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

    Logo

    开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

    更多推荐