开源鸿蒙跨平台:Flutter 分页列表的状态管理与 API 集成
自主设计并实现一个通用的Flutter分页列表模块。掌握 pull_to_refresh 库与Flutter的深度集成设计合理的状态管理架构处理分页逻辑封装高可复用的UI组件实现完整的加载状态流(加载中/空数据/错误/成功)分层设计:数据模型(Model)→ 业务逻辑(Controller)→ UI 组件(Widget)→ 页面(Page),解耦各层职责;响应式状态:通过Stream实现状态驱动
一、 项目概述
自主设计并实现一个通用的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方法封装标签样式,减少重复代码; - 自适应布局:
- 标题 / 摘要设置
maxLines和TextOverflow.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. 总结
- 分层设计:数据模型(Model)→ 业务逻辑(Controller)→ UI 组件(Widget)→ 页面(Page),解耦各层职责;
- 响应式状态:通过
Stream/StreamBuilder实现状态驱动 UI,替代 setState 减少冗余刷新; - 通用化封装:分页控制器使用泛型,适配任意数据类型,可复用至其他列表场景;
- 用户体验:处理空状态、加载状态、错误状态,实现下拉刷新 / 上拉加载,提升交互体验;
- 资源管理:所有
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
更多推荐


所有评论(0)