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);
}
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方法一定要是同步的,或者在调用前确保初始化完成。
好,这一节就到这里。下一节我们开始封装具体的请求方法,到时候你会看到,有了这个骨架,加功能就像搭积木一样简单。