返回文章列表
Flutter 网络请求最佳实践:Dio 封装与 API 架构设计
网络请求是每个 APP 的基础设施。Dio 是 Flutter 社区最强大的 HTTP 客户端,支持拦截器、转换器、取消、超时、重试等。但直接在 UI 层使用 Dio 调用接口会导致代码混乱、难以维护。这篇文章分享一套经过实战检验的 Dio 二次封装方案,涵盖错误处理、Token 刷新、缓存、文件上传下载等核心场景。
一、为什么需要二次封装?
直接用 Dio 的典型问题:
- 每个页面都要配置 baseUrl、headers、timeout
- 错误处理逻辑散落在各处,无法统一管理
- Token 过期后手动处理刷新逻辑繁琐
- 没有统一的加载状态和错误提示机制
解决方案:创建一个统一的 ApiService 层,将通用逻辑收口。
二、项目结构设计
lib/
├── services/
│ ├── api_service.dart # Dio 实例 + 拦截器
│ ├── api_exception.dart # 统一异常类
│ └── api_response.dart # 统一响应模型
├── models/
│ ├── user_model.dart # 用户模型
│ └── base_model.dart # 基础响应模型
├── providers/ # Provider 状态管理(可选)
│ └── auth_provider.dart # 认证状态
└── utils/
├── storage_util.dart # Token 存储
└── log_util.dart # 日志工具
三、统一异常处理
// lib/services/api_exception.dart
/// 统一异常类,覆盖所有 HTTP 和业务错误场景
class ApiException implements Exception {
final int? code;
final String message;
final dynamic data; // 原始数据,用于调试
const ApiException({this.code, required this.message, this.data});
factory ApiException.fromDioError(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
return ApiException(code: -1, message: '连接超时,请检查网络');
case DioExceptionType.sendTimeout:
return ApiException(code: -2, message: '请求发送超时');
case DioExceptionType.receiveTimeout:
return ApiException(code: -3, message: '服务器响应超时');
case DioExceptionType.cancel:
return const ApiException(code: -4, message: '请求已取消');
case DioExceptionType.connectionError:
return const ApiException(code: -5, message: '网络连接失败,请检查网络设置');
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode ?? 0;
String msg = _getHttpErrorMsg(statusCode);
try {
final body = e.response?.data;
if (body is Map) msg = body['message'] ?? msg;
} catch (_) {}
return ApiException(code: statusCode, message: msg, data: e.response?.data);
default:
return ApiException(message: e.message ?? '未知网络错误');
}
}
static String _getHttpErrorMsg(int code) {
switch (code) {
case 400: return '请求参数错误';
case 401: return '登录已过期,请重新登录';
case 403: return '没有权限访问此资源';
case 404: return '请求的资源不存在';
case 405: return '请求方法不被允许';
case 429: return '请求太频繁,请稍后再试';
case 500: return '服务器内部错误';
case 502: return '网关错误';
case 503: return '服务暂时不可用';
default: return 'HTTP $code 错误';
}
}
@override
String toString() => message;
}
四、Dio 核心封装 + 拦截器
// lib/services/api_service.dart
import 'package:dio/dio.dart';
import 'package:dio/io.dart' show PlatformOptions;
import 'api_exception.dart';
/// 核心 API 服务 —— 全局单例
class ApiService {
static late final Dio _dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
));
/// 私有构造,防止外部实例化
ApiService._();
/// 初始化(在 main() 中调用一次)
static void init() {
// 请求拦截:添加 Token、签名等公共参数
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// 从本地存储读取 Token
final token = await StorageUtil.getToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
// 可选:添加时间戳防重放
// options.queryParameters['t'] = DateTime.now().millisecondsSinceEpoch.toString();
return handler.next(options); // 继续
},
onResponse: (response, handler) {
return handler.next(response); // 正常响应直接通过
},
onError: (error, handler) async {
// Token 过期 → 自动刷新 → 重试请求
if (error is DioException &&
error.response?.statusCode == 401) {
final refreshed = await _refreshToken();
if (refreshed) {
// 用新 Token 重试原请求
final newOpts = error.requestOptions;
newOpts.headers['Authorization'] = 'Bearer ${await StorageUtil.getToken()}';
try {
final response = await _dio.fetch(newOpts);
return handler.resolve(response);
} catch (e) {
return handler.reject(e);
}
}
}
// 其他错误转为统一异常
return handler.reject(ApiException.fromDioError(error as DioException));
},
));
}
/// Token 刷新逻辑
static Future<bool> _refreshToken() async {
try {
final refreshToken = await StorageUtil.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) return false;
final response = await Dio().post('/auth/refresh', data: {'refresh_token': refreshToken});
if (response.statusCode == 200) {
await StorageUtil.saveTokens(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
);
return true;
}
return false;
} catch (e) {
// Refresh Token 也失效了 → 跳转登录页
await StorageUtil.clearTokens();
return false;
}
}
// ==================== GET / POST / PUT / DELETE ====================
static Future<Map<String, dynamic>> get(String path,
{Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers}) async {
try {
final response = await _dio.get(path,
queryParameters: queryParams,
options: Options(headers: headers),
);
return _handleResponse(response);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
static Future<Map<String, dynamic>> post(String path,
{dynamic data,
Map<String, dynamic>? queryParams,
Map<String, dynamic>? headers}) async {
try {
final response = await _dio.post(path,
data: data,
queryParameters: queryParams,
options: Options(headers: headers),
);
return _handleResponse(response);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
static Future<Map<String, dynamic>> put(String path,
{dynamic data, Map<String, dynamic>? headers}) async {
try {
final response = await _dio.put(path, data: data, options: Options(headers: headers));
return _handleResponse(response);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
static Future<void> delete(String path,
{Map<String, dynamic>? headers}) async {
try {
await _dio.delete(path, options: Options(headers: headers));
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
/// 统一响应处理
static Map<String, dynamic> _handleResponse(Response response) {
final data = response.data is Map ? response.data as Map : {};
// 假设后端返回格式: { code: 0, data: {...}, message: "ok" }
if (data['code'] == 0 || response.statusCode == 200) {
return {
'success': true,
'data': data['data'] ?? data,
'message': data['message'] ?? '操作成功',
};
}
throw ApiException(
code: data['code'] ?? response.statusCode,
message: data['message'] ?? '请求失败',
data: data,
);
}
// ==================== 文件上传 ====================
static Future<Map<String, dynamic>> uploadFile(
String path,
String filePath, {
String? field,
Map<String, dynamic>? data,
ProgressCallback? onSendProgress,
CancelToken? cancelToken,
}) async {
try {
final fileName = filePath.split('/').last;
final formData = FormData.fromMap({
field ?? 'file': [
MultipartFile.fromFileSync(filePath, filename: fileName),
],
...?data,
});
final response = await _dio.post(
path,
data: formData,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _handleResponse(response);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
// ==================== 文件下载 ====================
static Future<void> downloadFile(
String url,
String savePath, {
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
Function(double)? onDownloadComplete,
}) async {
try {
await _dio.download(
url,
savePath,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
onDownloadComplete?.call(1.0);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
}
五、实际业务调用示例
// ===== 用户相关 API =====
class UserApi {
/// 获取用户信息
static Future<UserModel> getUserProfile() async {
final result = await ApiService.get('/user/profile');
return UserModel.fromJson(result['data']);
}
/// 登录
static Future<LoginData> login(String phone, String password) async {
final result = await ApiService.post('/auth/login',
data: {'phone': phone, 'password': password},
);
// 保存 Token
await StorageUtil.saveTokens(
accessToken: result['data']['access_token'],
refreshToken: result['data']['refresh_token'],
);
return LoginData.fromJson(result['data']);
}
/// 更新个人资料
static Future<UserModel> updateProfile(Map<String, dynamic> params) async {
final result = await ApiService.put('/user/profile', data: params);
return UserModel.fromJson(result['data']);
}
}
// ===== 在 UI 中使用 =====
class ProfilePage extends StatefulWidget {
@override
_ProfilePageState createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
bool _isLoading = true;
UserModel? _user;
String? _errorMsg;
@override
void initState() {
super.initState();
_loadProfile();
}
Future<void> _loadProfile() async {
setState(() { _isLoading = true; _errorMsg = null; });
try {
final user = await UserApi.getUserProfile();
setState(() { _user = user; _isLoading = false; });
} on ApiException catch (e) {
setState(() { _errorMsg = e.message; _isLoading = false; });
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return Center(child: CircularProgressIndicator());
if (_errorMsg != null) return Center(child: Text(_errorMsg!));
return ListView(children: [/* 用户信息展示 */]);
}
}
六、请求缓存策略
// 使用 dio_cache_interceptor 实现内存 + 磁盘双级缓存
// pubspec.yaml
dependencies:
dio_cache_interceptor: ^3.4.0
final customCache = DioCacheConfig()
..defaultMaxAge = const Duration(hours: 2)
..defaultMaxStale = const Duration(days: 7)
..baseUrl = (await getTemporaryDirectory()).path;
_dio.interceptors.add(DioCacheInterceptor(options: CacheOptions(
store: CustomCacheStore(customCacheConfig: customCache),
policy: CachePolicy.forceCache(),
)));
// GET 请求会自动缓存,下次离线也能访问
// POST / PUT / DELETE 默认不缓存(符合 RESTful 规范)
七、关键注意事项
- Dio 实例应该是全局单例,不要每次请求都 new 一个 Dio
- 拦截器的执行顺序:Request 按注册顺序执行,Response/Error 按逆序
- Token 刷新要加锁,防止多个并发请求同时触发刷新导致 Token 冲突
- 大文件上传使用 FormData,避免 JSON 序列化导致的 OOM
- 开发环境开启日志,生产环境关闭或降级
- HTTPS 是必须的,尤其是涉及用户数据的接口
总结
好的网络层封装应该像空气一样存在——开发者几乎感觉不到它的存在,但所有请求都安全可靠地到达目的地。这套方案的核心理念是:统一入口、统一出口、中间件模式。Dio 的拦截器机制完美契合这一理念,让复杂的网络逻辑变得清晰可维护。