30天计划第14天-加载状态与错误处理

-
-
2025-11-19 13:53
  • 学习内容

    1. 网络请求状态:加载中(CircularProgressIndicator)、成功、失败

    2. 错误处理:捕获 Dio 请求异常(如网络错误、接口报错)

  • 实践任务

    1. 改造新闻列表页:请求数据时显示 “加载中”,成功则显示列表,失败则显示 “请求失败,请重试”

    2. 加一个 “重试按钮”:失败时点击重新请求数据

一、网络请求状态:从加载到反馈的完整链路

网络请求是异步操作,在请求发起至结果返回的过程中,需通过不同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),
      ));
    }
  • 运行效果

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


目录