首页关于文章作品集工具联系
返回文章列表

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 规范)

七、关键注意事项

  1. Dio 实例应该是全局单例,不要每次请求都 new 一个 Dio
  2. 拦截器的执行顺序:Request 按注册顺序执行,Response/Error 按逆序
  3. Token 刷新要加锁,防止多个并发请求同时触发刷新导致 Token 冲突
  4. 大文件上传使用 FormData,避免 JSON 序列化导致的 OOM
  5. 开发环境开启日志,生产环境关闭或降级
  6. HTTPS 是必须的,尤其是涉及用户数据的接口

总结

好的网络层封装应该像空气一样存在——开发者几乎感觉不到它的存在,但所有请求都安全可靠地到达目的地。这套方案的核心理念是:统一入口、统一出口、中间件模式。Dio 的拦截器机制完美契合这一理念,让复杂的网络逻辑变得清晰可维护。