4、基础封装实践:创建HttpManager单例、配置BaseUrl与超时、统一错误处理

好,咱们开始动手写代码了。

前面聊了那么多理论,说白了都是在为这一刻做准备。我个人习惯是,不管项目大小,先把网络层的基础骨架搭好。骨架稳了,后面加功能才不慌。

这一节,我们就来创建整个网络层的核心——HttpManager。它要干三件事:

  • 保证全局只有一个实例(单例模式)
  • 配置好BaseUrl和超时时间
  • 把各种乱七八糟的错误,统一成我们自己的格式

嗯,咱们一步步来。

4.1 为什么非得是单例?

你想想看,一个App里,网络请求的配置是不是全局统一的?BaseUrl不会变,超时时间也不会变。如果每个页面都new一个HttpClient,那配置就得重复写,万一哪天要改超时时间,你得翻遍整个项目。

我在项目中遇到过这种情况:有个同事把超时时间写死在了某个页面的请求里,结果线上用户反馈加载慢,我们排查了半天才发现是那个页面的超时设得太短了。从那以后,我就坚持用单例来管理网络层。

单例的好处很明显:

  • 配置集中:所有网络相关的配置,都在一个地方改
  • 资源复用:HttpClient本身是重量级对象,重复创建浪费资源
  • 状态统一:比如统一的请求头、统一的拦截器,都在单例里维护

4.2 创建HttpManager单例

在Flutter里,实现单例的方式有很多。我个人偏爱用工厂构造函数的方式,代码干净,语义也清晰。

来看代码:

import 'package:dio/dio.dart';

class HttpManager {
  // 私有构造函数,外部无法直接实例化
  HttpManager._internal();

  // 静态私有实例
  static final HttpManager _instance = HttpManager._internal();

  // 工厂构造函数,返回同一个实例
  factory HttpManager() {
    return _instance;
  }

  // Dio实例,核心网络请求对象
  late Dio _dio;
}

这里有几个关键点:

  • _internal()是私有命名构造函数,外部代码调不了
  • _instance是静态变量,类加载时就创建好了
  • factory关键字保证了每次调用HttpManager(),拿到的都是同一个对象

说白了,这就是一个「懒加载」的单例——第一次访问时创建,之后一直复用。

小提示: 你也可以用static HttpManager get instance => _instance;的方式暴露实例。两种写法都可以,看团队习惯。我个人更喜欢用factory,因为调用方不需要知道内部实现,直接HttpManager()就行,看起来像普通构造函数。

4.3 配置BaseUrl与超时时间

单例有了,接下来要给它「喂」配置。BaseUrl和超时时间,是网络请求最基础的两个参数。

BaseUrl决定了你的请求发到哪里去。超时时间呢?说白了就是「我等多久,等不到就算了」。我在项目中见过把超时设成60秒的,结果用户等得直接杀进程。一般建议:

  • 连接超时:10-15秒,网络差的时候给点缓冲
  • 读取超时:15-30秒,根据业务接口的响应速度来

我们把这些配置放在一个初始化方法里:

class HttpManager {
  // ... 上面的单例代码 ...

  // 初始化方法,传入配置
  void init({
    required String baseUrl,
    int connectTimeout = 15000,   // 默认15秒
    int receiveTimeout = 20000,   // 默认20秒
  }) {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: Duration(milliseconds: connectTimeout),
      receiveTimeout: Duration(milliseconds: receiveTimeout),
      // 还可以加一些通用请求头
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    ));
  }
}

为什么要把init单独拿出来?因为BaseUrl在开发环境和生产环境可能不一样。你可以在App启动时,根据环境变量传入不同的BaseUrl。比如:

void main() {
  // 开发环境
  HttpManager().init(baseUrl: 'https://dev-api.example.com');
  // 生产环境
  // HttpManager().init(baseUrl: 'https://api.example.com');
  
  runApp(MyApp());
}
注意: init方法只调用一次!如果多次调用,后面的会覆盖前面的配置。我曾经在项目里犯过这个错——在某个页面又调了一次init,结果BaseUrl被改成了别的地址,所有请求都404了。排查了半天才发现是初始化顺序的问题。

4.4 统一错误处理

网络请求不可能永远成功。断网、超时、服务器500、数据格式不对……这些错误如果不统一处理,每个页面都得写一堆try-catch,代码会变得又臭又长。

我的做法是:把Dio抛出的各种异常,统一转换成我们自己的业务异常。这样上层代码只需要关心「成功」和「失败」两种状态,失败时拿到一个结构化的错误对象。

先定义一个统一的错误模型:

class AppException implements Exception {
  final int code;       // 错误码
  final String message; // 错误描述

  AppException(this.code, this.message);

  @override
  String toString() => 'AppException(code: $code, message: $message)';
}

然后在HttpManager里加一个错误处理方法:

class HttpManager {
  // ... 前面的代码 ...

  /// 统一处理Dio异常,返回AppException
  AppException _handleError(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
        return AppException(-1, '连接超时,请检查网络');
      case DioExceptionType.receiveTimeout:
        return AppException(-2, '响应超时,请稍后重试');
      case DioExceptionType.connectionError:
        return AppException(-3, '网络连接失败,请检查网络设置');
      case DioExceptionType.badResponse:
        // 服务器返回了错误状态码
        final statusCode = error.response?.statusCode ?? 0;
        final message = _getHttpErrorMessage(statusCode);
        return AppException(statusCode, message);
      default:
        return AppException(-99, '未知网络错误');
    }
  }

  /// 根据HTTP状态码返回错误信息
  String _getHttpErrorMessage(int statusCode) {
    switch (statusCode) {
      case 400: return '请求参数错误';
      case 401: return '未授权,请重新登录';
      case 403: return '禁止访问';
      case 404: return '请求的资源不存在';
      case 500: return '服务器内部错误';
      case 502: return '网关错误';
      case 503: return '服务不可用,请稍后重试';
      default: return '请求失败($statusCode)';
    }
  }
}

这样,当你在业务层发起请求时,只需要捕获AppException,就能拿到清晰的错误码和提示信息。比如:

try {
  final response = await HttpManager().get('/user/info');
  // 处理成功数据
} on AppException catch (e) {
  // 统一处理错误
  showToast(e.message);
}
核心思想: 把网络层的「脏活累活」都封装在HttpManager里。上层代码永远只跟AppException打交道,不用关心底层是超时还是断网。这样代码的维护性会好很多。

4.5 完整的HttpManager骨架

把上面几块拼起来,就是一个完整的HttpManager了。我习惯把初始化、错误处理、以及后续要加的拦截器,都放在这个类里。来看最终的样子:

import 'package:dio/dio.dart';

class HttpManager {
  HttpManager._internal();

  static final HttpManager _instance = HttpManager._internal();

  factory HttpManager() => _instance;

  late Dio _dio;

  void init({
    required String baseUrl,
    int connectTimeout = 15000,
    int receiveTimeout = 20000,
  }) {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: Duration(milliseconds: connectTimeout),
      receiveTimeout: Duration(milliseconds: receiveTimeout),
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    ));
  }

  AppException _handleError(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
        return AppException(-1, '连接超时,请检查网络');
      case DioExceptionType.receiveTimeout:
        return AppException(-2, '响应超时,请稍后重试');
      case DioExceptionType.connectionError:
        return AppException(-3, '网络连接失败,请检查网络设置');
      case DioExceptionType.badResponse:
        final statusCode = error.response?.statusCode ?? 0;
        final message = _getHttpErrorMessage(statusCode);
        return AppException(statusCode, message);
      default:
        return AppException(-99, '未知网络错误');
    }
  }

  String _getHttpErrorMessage(int statusCode) {
    switch (statusCode) {
      case 400: return '请求参数错误';
      case 401: return '未授权,请重新登录';
      case 403: return '禁止访问';
      case 404: return '请求的资源不存在';
      case 500: return '服务器内部错误';
      case 502: return '网关错误';
      case 503: return '服务不可用,请稍后重试';
      default: return '请求失败($statusCode)';
    }
  }
}

class AppException implements Exception {
  final int code;
  final String message;

  AppException(this.code, this.message);

  @override
  String toString() => 'AppException(code: $code, message: $message)';
}

嗯,到这里,基础骨架就搭好了。你可能会问:「怎么没有GET、POST方法?」别急,下一节我们就来封装具体的请求方法,还会加上拦截器来处理Token刷新、日志打印这些实用功能。

这一节的核心,就是让你理解:单例保证唯一、配置集中管理、错误统一兜底。这三板斧砍下去,网络层的基础就稳了。

避坑指南: 我曾经在项目里把init方法写成了异步的,结果在main函数里调用时忘了await,导致第一个请求发出去时Dio还没初始化好。所以,init方法一定要是同步的,或者在调用前确保初始化完成。

好,这一节就到这里。下一节我们开始封装具体的请求方法,到时候你会看到,有了这个骨架,加功能就像搭积木一样简单。