学习内容:
网络请求状态:加载中(
CircularProgressIndicator)、成功、失败错误处理:捕获 Dio 请求异常(如网络错误、接口报错)
实践任务:
改造新闻列表页:请求数据时显示 “加载中”,成功则显示列表,失败则显示 “请求失败,请重试”
加一个 “重试按钮”:失败时点击重新请求数据
一、网络请求状态:从加载到反馈的完整链路※
网络请求是异步操作,在请求发起至结果返回的过程中,需通过不同UI状态向用户传递进度,避免用户困惑。Flutter提供了简洁的组件和状态管理方式来实现这一需求。
1. 三种核心状态解析※
加载中状态:请求已发起但未收到响应时的过渡状态,核心作用是告知用户“操作正在进行中”,避免重复操作。此时需展示加载指示器,Flutter内置的CircularProgressIndicator是标准选择,它支持自定义颜色、尺寸和进度模式,适配不同主题风格。
成功状态:请求正常返回且数据格式合法时的状态,需将解析后的数据源(如:新闻列表)通过列表组件(ListView或ListView.builder)展示,同时做好数据为空时的兜底处理(如:显示“暂无新闻”提示)。
失败状态:请求过程中出现异常时的状态,需明确告知用户失败结果,并提供补救方案。失败原因可能包括网络中断、接口返回4xx/5xx错误、数据解析失败等,UI上需要包含错误提示文本和重试按钮。
2. 状态管理实现方案※
由于网络请求状态会动态变化,需使用有状态组件(StatefulWidget)管理状态。通过定义枚举类型规范状态取值,确保状态切换的严谨性:
// 定义网络请求状态枚举
enum RequestStatus {loading, success, failed}
class NewsListPage extends StatefulWidget {
const NewsListPage({super.key});
@override
State<NewsListPage> createState() => _NewsListPageState();
}
class _NewsListPageState extends State<NewsListPage> {
// 初始化状态为加载中
RequestStatus _status = RequestStatus.loading;
List<NewsModel> _newsList = [];
String _errorMsg = '请求失败,请重试!'; // 错误提示默认文本
// 后续请求逻辑将在这里实现
}3. 状态对应的UI构建逻辑※
在build方法中通过状态判断渲染不同UI,遵循“单一状态对应唯一UI”的原则,确保代码可读性
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新闻列表')),
body: _buildBodyByStatus(), // 根据状态构建主题内容
);
}
// 根据请求状态构建对应UI
Widget _buildBodyByStatus() {
switch (_status) {
case RequestStatus.loading:
// 加载中:居中显示圆形进度条,自定义颜色适配主题
return const Center(
child: CircularProgressIndicator(color: Colors.blue)
);
case RequestStatus.success:
// 成功:使用ListView.builder高效加载新闻列表
return ListView.builder(
itemCount: _newsList.length,
itemBuilder: (context, index) {
final news = _newsList[index];
return ListTile(
title: Text(news.title),
subtitle: Text(news.publishTime),
leading: Image.network(news.coverImage, width: 60)
);
}
);
case RequestStatus.failed:
// 失败:居中显示错误提示和重试按钮
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_errorMsg, style: const TextStyle(color: Colors.red, fontSize: 16)),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _fetchNewsData, // 点击触发重新请求
child: const Text('重试'),
)
]
)
);
}
}二、错误处理:Dio异常的精准捕获与处理※
Dio是Flutter生态中最常用的网络请求库,支持拦截器、请求取消、超时设置等功能,其异常体系清晰,可针对性处理不同类型的错误。
1. 集成Dio插件※
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0 # 请使用最新稳定版本
connectivity_plus: ^4.0.1 # 辅助判断网络状态2. Dio核心异常类型解析※
Dio的异常主要分为五类,需要通过try-catch语句精准捕获并区分处理:
- DioExceptionType.connectionError:网络连接异常,如:无网络、WIFI断开、服务器无法连接等,属于客户端环境问题。
- DioExceptionType.receiveTimeout:请求超时,需提示用户“网络较慢,请稍后重试”。
- DioExceptionType.badResponse:接口返回错误码(4xx/5xx),如:404(资源不存在)、500(服务器内部错误),需解析响应信息给出的具体提示。
- DioExceptionType.badCertificate:证书错误,通常发生在HTTPS请求中,属于配置问题。
- 其他异常:如数据解析错误、请求被取消等,需做好兜底处理。
3. 异常捕获与处理实现※
封装网络请求方法,通过try-catch捕获Dio异常,并根据具体异常类型设置对应的错误提示,同时更新请求状态
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
// 新闻数据模型
class NewsModel {
final String title;
final String publishTime;
final String coverImage;
NewsModel({
required this.title,
required this.publishTime,
required this.coverImage,
});
// 从JSON数据解析模型
factory NewsModel.fromJson(Map<String, dynamic> json) {
return NewsModel(
title: json['title'] ?? "",
publishTime: json['publishTime'] ?? "",
coverImage: json['coverImage'] ?? "",
);
}
}
// 封装新闻请求方法
Future<void> _fetchNewsData() async {
// 请求前更新状态为加载中
setState(() => _status = RequestStatus.loading);
// 先判断网络状态
final connectivityResult = await (Connectivity().checkConnectivity());
if (connectivityResult == ConnectivityResult.none) {
setState(() {
_status = RequestStatus.failed;
_errorMsg = "无网络连接,请检查网络设置";
});
return;
}
const String apiUrl = "https://api.example.com/news"; // 替换为真实接口地址
final Dio dio = Dio();
try {
// 发起GET请求,设置超时时间为5秒
final response = await dio.get(
apiUrl,
options: Options(receiveTimeout: const Duration(seconds: 5)),
);
// 接口返回成功(200状态码)
if (response.statusCode == 200) {
List<dynamic> dataList = response.data['data'] ?? [];
// 解析数据为新闻模型列表
List<NewsModel> newsList = dataList.map((json) => NewsModel.fromJson(json)).toList();
setState(() {
_status = RequestStatus.success;
_newsList = newsList;
});
} else {
// 非200状态码处理
setState(() {
_status = RequestStatus.failed;
_errorMsg = "接口返回错误,状态码:${response.statusCode}";
});
}
} on DioException catch (e) {
// 捕获Dio异常并分类处理
String errorMsg = "请求失败,请重试";
if (e.type == DioExceptionType.connectionError) {
errorMsg = "网络连接失败,请检查服务器是否可用";
} else if (e.type == DioExceptionType.receiveTimeout) {
errorMsg = "请求超时,网络可能较慢";
} else if (e.type == DioExceptionType.badResponse) {
errorMsg = "接口报错:${e.response?.data['msg'] ?? '未知错误'}";
} else if (e.type == DioExceptionType.badCertificate) {
errorMsg = "证书验证失败,请求无法完成";
}
// 更新状态为失败并设置错误提示
setState(() {
_status = RequestStatus.failed;
_errorMsg = errorMsg;
});
} catch (e) {
// 捕获其他未知异常
setState(() {
_status = RequestStatus.failed;
_errorMsg = "发生未知错误:${e.toString()}";
});
}
}
// 在initState中初始化请求
@override
void initState() {
super.initState();
_fetchNewsData(); // 页面加载时发起首次请求
}4. 进阶优化:全局异常处理※
对于多页面的应用,可通过Dio拦截器实现全局异常捕获,减少重复代码,例如,创建错误处理拦截器:
class ErrorInterceptor extends Interceptor {
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
// 全局异常日志打印
print("网络请求异常:${err.type},消息:${err.message}");
// 可在这里统一处理登录失效等通用异常
if (err.response?.statusCode == 401) {
// 跳转到登录页逻辑
}
handler.next(err); // 继续传递异常,让页面自行处理具体提示
}
}
// 初始化Dio时添加拦截器
final Dio dio = Dio()..interceptors.add(ErrorInterceptor());三、综合实战※
- 任务需求
- 页面加载时自动发起新闻数据请求,显示加载中状态(CircularProgressIndicator)。
- 请求成功后,使用列表展示新闻标题、发布时间、封面图等信息。
- 请求失败时,显示对应错误提示和 “重试” 按钮,点击按钮重新发起请求。
- 适配不同异常场景,给出精准的错误反馈(如无网络、超时、接口报错)。
代码实现
import 'package:flutter/material.dart'; import 'package:dio/dio.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; // news_model.dart class NewsModel { final int id; final String title; final String summary; // 用 body 的前一部分作为摘要 NewsModel({ required this.id, required this.title, required this.summary, }); // 从 JSON 解析数据,适配 jsonplaceholder 的结构 factory NewsModel.fromJson(Map<String, dynamic> json) { String body = json['body'] ?? ""; // 截取 body 的前 100 个字符作为摘要,并在末尾添加省略号 String summary = body.length > 100 ? '${body.substring(0, 100)}...' : body; return NewsModel( id: json['id'] ?? 0, title: json['title'] ?? "无标题", summary: summary, ); } } // 网络请求状态枚举 enum RequestStatus { loading, success, failed } class NewsListPage extends StatefulWidget { const NewsListPage({super.key}); @override State<NewsListPage> createState() => _NewsListPageState(); } class _NewsListPageState extends State<NewsListPage> { RequestStatus _status = RequestStatus.loading; List<NewsModel> _newsList = []; String _errorMsg = "请求失败,请重试"; late Dio _dio; @override void initState() { super.initState(); // 初始化Dio并添加全局拦截器 _initDio(); // 页面加载时发起首次请求 _fetchNewsData(); } // 初始化Dio void _initDio() { _dio = Dio(); // 添加错误拦截器 _dio.interceptors.add(ErrorInterceptor()); // 设置基础配置 _dio.options = BaseOptions( baseUrl: "https://jsonplaceholder.typicode.com", receiveTimeout: const Duration(seconds: 5), connectTimeout: const Duration(seconds: 3), ); } // 新闻数据请求方法 Future<void> _fetchNewsData() async { setState(() => _status = RequestStatus.loading); // 检查网络状态 final connectivityResult = await (Connectivity().checkConnectivity()); if (connectivityResult == ConnectivityResult.none) { _updateFailedState("无网络连接,请检查WiFi或移动数据"); return; } try { const String path = "/posts"; // 接口路径 final response = await _dio.get(path); if (response.statusCode == 200) { List<dynamic> dataList = response.data ?? []; if (dataList.isEmpty) { // 数据为空时特殊处理 setState(() { _status = RequestStatus.success; _newsList = []; }); return; } // 解析数据为新闻模型列表 (NewsModel.fromJson 已适配新结构) List<NewsModel> newsList = dataList.map((json) => NewsModel.fromJson(json)).toList(); setState(() { _status = RequestStatus.success; _newsList = newsList; }); } else { _updateFailedState("接口异常,状态码:${response.statusCode}"); } } on DioException catch (e) { _handleDioError(e); } catch (e) { _updateFailedState("发生未知错误:${e.toString().substring(0, 20)}..."); } } // 处理Dio异常 void _handleDioError(DioException e) { String errorMsg = "请求失败,请重试"; switch (e.type) { case DioExceptionType.connectionError: errorMsg = "网络连接失败,无法访问服务器"; break; case DioExceptionType.receiveTimeout: errorMsg = "请求超时,建议检查网络后重试"; break; case DioExceptionType.badResponse: errorMsg = e.response?.data['msg'] ?? "接口返回错误"; break; case DioExceptionType.badCertificate: errorMsg = "证书验证失败,请求被拒绝"; break; case DioExceptionType.cancel: errorMsg = "请求已取消"; break; default: errorMsg = "请求失败,请重试"; } _updateFailedState(errorMsg); } // 更新失败状态 void _updateFailedState(String errorMsg) { setState(() { _status = RequestStatus.failed; _errorMsg = errorMsg; }); } // 根据状态构建主体UI Widget _buildBody() { switch (_status) { case RequestStatus.loading: return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: Colors.blue, strokeWidth: 3), SizedBox(height: 16), Text("正在加载新闻数据...", style: TextStyle(fontSize: 16)), ], ), ); case RequestStatus.success: return _newsList.isEmpty ? const Center(child: Text("暂无新闻数据", style: TextStyle(fontSize: 16, color: Colors.grey))) : ListView.separated( padding: const EdgeInsets.all(12), itemCount: _newsList.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final news = _newsList[index]; return ListTile( contentPadding: const EdgeInsets.symmetric(vertical: 10), leading: CircleAvatar( backgroundColor: Colors.blueAccent, foregroundColor: Colors.white, child: Text('${news.id}'), ), title: Text( news.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), child: Text( news.summary, style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ), onTap: () { // 点击新闻跳转到详情页逻辑 ScaffoldMessenger.of(context).hideCurrentSnackBar(); // 再显示新的 SnackBar ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('你点击了新闻 ${news.id} '), duration: const Duration(milliseconds: 1000), // 可选:缩短显示时间(默认2秒) behavior: SnackBarBehavior.floating ), ); }, ); }, ); case RequestStatus.failed: return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 30), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, color: Colors.red, size: 48), const SizedBox(height: 20), Text( _errorMsg, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red, fontSize: 16), ), const SizedBox(height: 30), ElevatedButton( onPressed: _fetchNewsData, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), textStyle: const TextStyle(fontSize: 16), ), child: const Text("重新加载"), ), ], ), ), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("热点新闻", style: TextStyle(fontSize: 18)), centerTitle: true, elevation: 2, ), body: _buildBody(), ); } } // 全局错误拦截器 class ErrorInterceptor extends Interceptor { @override Future<void> onError(DioException err, ErrorInterceptorHandler handler) async { // 打印异常日志,便于开发调试 print(""" 网络请求异常: 接口地址:${err.requestOptions.uri} 异常类型:${err.type} 状态码:${err.response?.statusCode} 错误信息:${err.message} """); handler.next(err); } } // 入口函数(用于单独运行该页面) void main() { runApp(MaterialApp( home: NewsListPage(), theme: ThemeData(primarySwatch: Colors.blue), )); }运行效果

- 说明
- 先关闭网络,此时可以观察到提示无网络连接
- 再打开网络,点击刷新,等网络连接正常,即可正常显示数据。
- 说明