☰
Flutter Dio封装实战:单例、动态baseUrl与拦截器设计
2026/10/15 10:35:03 网站建设 项目流程

1. 为什么必须封装Dio——从“能用”到“好用”的真实分水岭

刚接触Flutter网络层开发的朋友,大概率都经历过这样的阶段:在某个页面里直接new一个Dio实例,写几行dio.get()调用,接口通了,UI动了,心里一喜——“成了!”可等项目做到第5个页面、接入第3个后端服务、上线前压测时突然发现:baseUrl硬编码满天飞、登录态token漏传导致401暴增、某个接口超时却卡死整个列表、日志查不到请求原始参数、loading状态在Tab切换时错乱闪烁……这时候才意识到,没封装的Dio不是工具,是定时雷。

我带过的几个团队里,有位A同学就踩过这个坑。他负责一个医疗类App的挂号模块,初期用裸Dio快速交付了首页数据加载,结果上线后用户投诉“点预约按钮没反应”。排查三天才发现:他在两个不同页面分别创建了Dio实例,一个加了token拦截器,另一个没加;而用户恰好从没登录的页面跳转过去,请求直接被网关拦截,但UI层根本没收到错误反馈,loading一直转圈。这种问题不会出现在单测里,只会在真实用户手指划过屏幕的0.3秒内爆发。

所以“封装Dio”这件事,本质不是炫技,而是把网络层从不可控的变量变成可治理的基础设施。它要解决的五个核心痛点,恰恰对应标题里的关键词:

  • 单例→ 避免内存泄漏与状态割裂(多个Dio实例各自维护自己的拦截器栈和Cookie);
  • 动态baseUrl→ 支持测试/预发/生产多环境一键切换,且允许运行时热更新(比如灰度发布时临时切到新域名);
  • 拦截器→ 统一处理鉴权、重试、错误映射,而不是每个API调用都重复写headers['Authorization'] = 'Bearer $token';
  • 日志→ 不是简单print,而是结构化记录请求耗时、响应码、失败原因、重试次数,为线上问题提供“时间戳证据链”;
  • 请求loading→ 与UI生命周期强绑定,自动管理状态(比如页面销毁时取消请求、列表滚动中暂停非关键请求)。

这五点不是并列关系,而是有严格依赖顺序:没有单例,拦截器无法统一注册;没有动态baseUrl,多环境配置就得改代码重新打包;没有拦截器,日志和loading就只能零散地塞在业务逻辑里。所以本文的实操路径,会严格按这个技术依赖链展开——不讲虚的,每一步都给出可直接粘贴进项目的代码块,并说明为什么必须这么写,而不是“网上抄来的模板”。

2. 封装设计核心思路:分层解耦 + 生命周期可控

2.1 为什么拒绝“全局静态变量”,坚持用GetIt或Provider管理Dio实例?

很多教程教新手这样写:

class DioUtil { static final Dio _dio = Dio(BaseOptions()); static Dio get dio => _dio; }

看起来简洁,但埋下三个致命隐患:

  1. 测试地狱:单元测试时无法Mock Dio实例,每次都要真实发起网络请求;
  2. 初始化时机失控:_dio在类加载时就初始化,此时可能连SharedPreferences都没准备好,无法读取用户选择的环境配置;
  3. 状态污染:A页面修改了_dio.options.connectTimeout,B页面的请求也会受影响。

我见过最惨的案例是某电商App,在“我的订单”页为了应对弱网做了connectTimeout: 30000,结果导致首页商品搜索请求平均耗时飙升到30秒——因为首页复用了同一个Dio实例。

正确解法是依赖注入(DI)。我们选用get_it(轻量无侵入)而非provider(需Widget树配合),因为网络层属于纯逻辑层,不该和UI框架强耦合。关键设计原则有三条:

  • 延迟初始化:Dio实例不在应用启动时创建,而是在首次需要时(如登录成功后)才构建,此时所有配置项(token、baseUrl)均已就绪;
  • 作用域隔离:为不同业务场景创建独立Dio实例(如上传专用Dio用receiveTimeout: 600000,普通请求用30000),避免参数互相干扰;
  • 可替换性:通过抽象接口NetworkClient定义能力契约,未来可无缝切换到http包或自研协议栈。

提示:不要在main()函数里就调用GetIt.I.registerSingleton<Dio>(Dio(...))。正确的初始化时机是用户完成身份认证后,或首次进入需要网络的页面时。这样能确保BaseOptions中的headers、baseUrl等参数都是最新有效值。

2.2 动态baseUrl的三种实现层级,为什么选“拦截器+配置中心”方案?

动态baseUrl看似简单,实则暗藏玄机。常见方案有:

  • 方案A:编译期常量(const String baseUrl = kIsWeb ? 'https://web-api.com' : 'https://mobile-api.com';)
    ✅ 简单安全
    ❌ 无法运行时切换,灰度发布需发版

  • 方案B:SharedPreferences存储(await prefs.setString('api_base_url', 'https://beta-api.com');)
    ✅ 运行时可变
    ❌ 每次请求都要异步读取,IO阻塞主线程;且未考虑多进程场景(Android后台Service可能读到旧值)

  • 方案C:拦截器+内存缓存+事件通知(本文采用)
    ✅ 零IO开销、支持热更新、天然适配多线程
    ❌ 实现稍复杂,需处理并发安全

我们选择方案C,核心在于将baseUrl视为可变的网络上下文,而非静态配置。具体实现分三步:

  1. 创建BaseUrlManager单例,内部用AtomicReference<String>(Dart中用final _baseUrl = ValueNotifier<String>('');)保证线程安全;
  2. 在请求拦截器中,每次onRequest时从_baseUrl.value读取当前地址;
  3. 当需要切换环境时(如用户点击“切换测试环境”按钮),调用baseUrlManager.update('https://test-api.com'),触发ValueNotifier通知所有监听者——此时正在执行的请求不受影响,但后续新请求立即生效。

注意:不要在拦截器里做await prefs.getString()!Dio拦截器是同步回调,await会导致整个Dio实例阻塞。所有异步操作必须前置到拦截器外完成。

2.3 拦截器栈的设计哲学:为什么必须分“请求前”“响应后”“错误时”三层?

Dio的拦截器机制常被误用为“万能钩子”,但实际应遵循职责单一原则。我们定义三个标准拦截器:

  • AuthInterceptor:仅处理onRequest,负责注入token、刷新过期token(需配合refreshToken接口);
  • LoggingInterceptor:仅处理onResponse和onError,记录成功/失败详情;
  • LoadingInterceptor:仅处理onRequest和onResponse/onError,控制UI loading状态。

关键细节在于拦截器的注册顺序:

dio.interceptors.add(AuthInterceptor()); // 最先执行,确保token已注入 dio.interceptors.add(LoadingInterceptor()); // 其次,避免loading状态被auth逻辑干扰 dio.interceptors.add(LoggingInterceptor()); // 最后,记录最终发出的请求

如果把LoggingInterceptor放在最前,它会记录到未添加token的原始请求,导致日志失真;如果LoadingInterceptor在AuthInterceptor之后,当token刷新需要重试请求时,loading状态可能被重复触发。这种顺序依赖,正是封装的价值所在——把隐式规则变成显式代码。

3. 核心模块逐行实现:从零构建可落地的网络层

3.1 单例管理器:GetIt注册与Dio工厂方法

首先安装依赖(pubspec.yaml):

dependencies: flutter: sdk: flutter dio: ^4.0.6 get_it: ^7.6.4 shared_preferences: ^2.2.2 logger: ^1.4.0 dev_dependencies: flutter_test: sdk: flutter

创建lib/core/network/dio_manager.dart,这是整个网络层的入口:

import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'base_options_builder.dart'; import 'interceptors/auth_interceptor.dart'; import 'interceptors/loading_interceptor.dart'; import 'interceptors/logging_interceptor.dart'; final GetIt getIt = GetIt.instance; /// 网络层初始化入口,应在用户登录成功后调用 Future<void> initNetwork() async { final prefs = await SharedPreferences.getInstance(); final baseUrl = prefs.getString('api_base_url') ?? 'https://prod-api.com'; // 构建Dio实例(注意:此处不直接new,而是调用工厂方法) final dio = DioFactory.createDio( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), ); // 注册拦截器(顺序至关重要!) dio.interceptors.add(AuthInterceptor()); dio.interceptors.add(LoadingInterceptor()); dio.interceptors.add(LoggingInterceptor()); // 注册为单例,但使用LazySingleton确保首次调用时才初始化 getIt.registerLazySingleton<Dio>(() => dio); } /// Dio工厂类,集中管理实例创建逻辑 class DioFactory { /// 创建Dio实例,所有参数均可动态配置 static Dio createDio({ required String baseUrl, required Duration connectTimeout, required Duration receiveTimeout, }) { final baseOptions = BaseOptionsBuilder() .setBaseUrl(baseUrl) .setConnectTimeout(connectTimeout) .setReceiveTimeout(receiveTimeout) .build(); return Dio(baseOptions) ..options.headers['User-Agent'] = 'FlutterApp/1.0.0'; } }

这里的关键设计点:

  • initNetwork()是唯一初始化入口,业务方只需在登录成功后调用一次,无需关心Dio如何构建;
  • 使用registerLazySingleton而非registerSingleton,确保Dio实例在真正需要时(如第一次调用getIt<Dio>())才创建,避免启动时初始化失败;
  • BaseOptionsBuilder是自定义构建器(见下一节),将BaseOptions的构造逻辑封装起来,避免参数散落在各处。

实操心得:不要在initState()里调用getIt<Dio>()!因为此时initNetwork()可能尚未执行。正确做法是在登录页的_loginSuccess()方法末尾调用initNetwork(),然后导航到主页面。这样能保证Dio实例一定可用。

3.2 动态baseUrl管理器:支持热更新的内存缓存

创建lib/core/network/base_url_manager.dart:

import 'package:flutter/foundation.dart'; /// 基础URL管理器,支持运行时热更新 class BaseUrlManager extends ChangeNotifier { final ValueNotifier<String> _baseUrl = ValueNotifier<String>(''); String get value => _baseUrl.value; /// 更新基础URL,触发所有监听者刷新 void update(String newUrl) { if (_baseUrl.value != newUrl) { _baseUrl.value = newUrl; notifyListeners(); // 通知UI层更新(如环境切换按钮状态) } } /// 监听URL变化,用于在拦截器中获取最新值 Stream<String> get stream => _baseUrl.stream; /// 销毁资源 @override void dispose() { _baseUrl.dispose(); super.dispose(); } } /// 全局单例实例 final baseUrlManager = BaseUrlManager();

再创建lib/core/network/base_options_builder.dart,将baseUrl注入逻辑解耦:

import 'package:dio/dio.dart'; import 'base_url_manager.dart'; /// BaseOptions构建器,确保每次请求都使用最新baseUrl class BaseOptionsBuilder { String? _baseUrl; Duration? _connectTimeout; Duration? _receiveTimeout; BaseOptionsBuilder setBaseUrl(String url) { _baseUrl = url; return this; } BaseOptionsBuilder setConnectTimeout(Duration timeout) { _connectTimeout = timeout; return this; } BaseOptionsBuilder setReceiveTimeout(Duration timeout) { _receiveTimeout = timeout; return this; } BaseOptions build() { return BaseOptions( baseUrl: _baseUrl ?? 'https://default.com', connectTimeout: _connectTimeout ?? const Duration(seconds: 10), receiveTimeout: _receiveTimeout ?? const Duration(seconds: 30), // 关键:禁用Dio默认的Cookie管理,改用自定义逻辑 followRedirects: false, validateStatus: (status) => status! >= 200 && status < 400, ); } }

现在,拦截器可以安全地读取最新baseUrl了。创建lib/core/network/interceptors/base_url_interceptor.dart:

import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../../base_url_manager.dart'; /// 基础URL拦截器,确保每次请求使用最新配置 class BaseUrlInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 每次请求前,强制更新baseUrl(即使没变也执行,确保一致性) options.baseUrl = baseUrlManager.value; handler.next(options); } }

注意:baseUrlManager.value是同步读取,毫秒级无感。不要试图在这里加await,Dio拦截器不支持异步回调。

3.3 拦截器实战:Auth、Loading、Logging三剑客

AuthInterceptor:Token自动续期防抖设计

创建lib/core/network/interceptors/auth_interceptor.dart:

import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../utils/storage_util.dart'; /// 认证拦截器,处理token注入与自动刷新 class AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await StorageUtil.getToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override void onError(DioError err, ErrorInterceptorHandler handler) async { // 捕获401错误,尝试刷新token if (err.response?.statusCode == 401) { final refreshToken = await StorageUtil.getRefreshToken(); if (refreshToken != null) { try { // 调用刷新接口(注意:此处不能用同一个Dio实例,避免循环拦截) final refreshDio = Dio(); final response = await refreshDio.post( 'https://auth-server.com/refresh', data: {'refresh_token': refreshToken}, ); if (response.statusCode == 200) { final newToken = response.data['access_token']; await StorageUtil.saveToken(newToken); // 重试原请求(关键:必须克隆options,避免修改原始对象) final newOptions = Options( method: err.requestOptions.method, headers: {...err.requestOptions.headers}, contentType: err.requestOptions.contentType, ); handler.resolve(await Dio().request( err.requestOptions.path, options: newOptions, data: err.requestOptions.data, )); return; } } catch (e) { // 刷新失败,清除本地凭证,跳转登录页 await StorageUtil.clearAuth(); } } } handler.next(err); } }

实操心得:Token刷新必须用独立Dio实例!如果在AuthInterceptor里直接调用getIt<Dio>().post(),会再次触发该拦截器,造成无限递归。这是新手最常踩的坑。

LoadingInterceptor:与UI生命周期深度绑定

创建lib/core/network/interceptors/loading_interceptor.dart:

import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../../utils/loading_manager.dart'; /// 加载状态拦截器,自动管理UI loading class LoadingInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 标记请求开始(key为url+method,避免同URL不同method冲突) final key = '${options.method}_${options.path}'; LoadingManager.show(key); handler.next(options); } @override void onResponse(Response response, ResponseInterceptorHandler handler) { final key = '${response.requestOptions.method}_${response.requestOptions.path}'; LoadingManager.hide(key); handler.next(response); } @override void onError(DioError err, ErrorInterceptorHandler handler) { final key = '${err.requestOptions.method}_${err.requestOptions.path}'; LoadingManager.hide(key); handler.next(err); } }

配套的lib/utils/loading_manager.dart:

import 'package:flutter/material.dart'; /// 全局loading管理器,支持多请求叠加 class LoadingManager { static final Map<String, int> _loadingCount = {}; /// 显示loading,计数+1 static void show(String key) { _loadingCount[key] = (_loadingCount[key] ?? 0) + 1; // 通知全局状态(如使用Provider时) _notifyGlobalChange(); } /// 隐藏loading,计数-1,为0时真正隐藏 static void hide(String key) { _loadingCount[key] = (_loadingCount[key] ?? 0) - 1; if (_loadingCount[key] <= 0) { _loadingCount.remove(key); } _notifyGlobalChange(); } /// 获取当前是否有任意loading static bool get isLoading => _loadingCount.isNotEmpty; static void _notifyGlobalChange() { // 此处可集成Provider或GetX的状态通知 } }

关键技巧:用Map<String, int>记录每个请求的加载次数,支持同一页面多次请求(如下拉刷新+上拉加载)时loading状态精准控制。避免简单用bool标志位导致状态错乱。

LoggingInterceptor:结构化日志与性能监控

创建lib/core/network/interceptors/logging_interceptor.dart:

import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; import '../../utils/time_util.dart'; final logger = Logger(); /// 日志拦截器,记录请求全生命周期 class LoggingInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 记录请求开始时间 options.extra['startTime'] = DateTime.now().millisecondsSinceEpoch; logger.d('▶️ ${options.method} ${options.path}', tag: 'Network', time: DateTime.now()); handler.next(options); } @override void onResponse(Response response, ResponseInterceptorHandler handler) { final startTime = response.requestOptions.extra['startTime'] as int?; final duration = startTime != null ? DateTime.now().millisecondsSinceEpoch - startTime : 0; final statusCode = response.statusCode ?? 0; final success = statusCode >= 200 && statusCode < 300; logger.i( '✅ ${response.requestOptions.method} ${response.requestOptions.path} ' '(${duration}ms) [$statusCode]', tag: 'Network', time: DateTime.now(), error: success ? null : response.statusMessage, stackTrace: success ? null : response.data, ); handler.next(response); } @override void onError(DioError err, ErrorInterceptorHandler handler) { final startTime = err.requestOptions.extra['startTime'] as int?; final duration = startTime != null ? DateTime.now().millisecondsSinceEpoch - startTime : 0; logger.e( '❌ ${err.requestOptions.method} ${err.requestOptions.path} ' '(${duration}ms) [${err.type}]', tag: 'Network', time: DateTime.now(), error: err.message, stackTrace: err.stackTrace, ); handler.next(err); } }

实操心得:日志必须包含毫秒级耗时和完整URL路径。线上问题定位时,“耗时2300ms的/user/profile接口失败”比“网络错误”有用100倍。建议将日志输出到本地文件(用file_logger包),方便用户反馈问题时导出日志。

4. 完整实操流程:从初始化到业务调用的端到端演示

4.1 初始化网络层:三步走策略

假设你的App入口是main.dart,完整初始化流程如下:

// lib/main.dart import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'core/network/dio_manager.dart'; import 'app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // 1. 初始化GetIt getIt.init(); // 2. 启动时加载基础配置(非网络相关) await _loadAppConfig(); // 3. 启动App runApp(const MyApp()); } Future<void> _loadAppConfig() async { // 此处可加载主题、语言等配置 // 注意:不要在此处调用initNetwork()! } // lib/app.dart import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'core/network/dio_manager.dart'; import 'pages/login_page.dart'; import 'pages/home_page.dart'; class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const LoginPage(), // 首页为登录页 // 关键:全局路由监听,捕获登录成功事件 onGenerateRoute: (settings) { if (settings.name == '/home') { // 登录成功后,初始化网络层 initNetwork(); // 此处才真正创建Dio实例 } return null; }, ); } }

为什么不在main()里初始化?因为initNetwork()需要读取SharedPreferences(用户选择的环境),而SharedPreferences.getInstance()必须在WidgetsFlutterBinding.ensureInitialized()之后调用。但更重要的是——网络层应该随业务需求而生,而非随App启动而生。未登录用户不需要访问API,提前初始化纯属浪费资源。

4.2 业务页面调用:一行代码发起请求

创建lib/pages/home_page.dart,展示如何在业务中使用封装后的Dio:

import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; import '../core/network/dio_manager.dart'; import '../models/user_model.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { final logger = Logger(); late Dio _dio; @override void initState() { super.initState(); // 从GetIt获取Dio实例(此时initNetwork()已执行) _dio = getIt<Dio>(); } Future<void> _fetchUserProfile() async { try { // 1. 发起请求(无需关心baseUrl、token、loading) final response = await _dio.get('/user/profile'); // 2. 解析数据(Dio已自动处理JSON解析) final user = UserModel.fromJson(response.data); // 3. 更新UI setState(() { _user = user; }); } on DioError catch (e) { // 4. 错误处理(日志已由拦截器记录) logger.e('获取用户信息失败: ${e.message}'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('请求失败:${e.message}')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('首页')), body: Center( child: ElevatedButton( onPressed: _fetchUserProfile, child: const Text('获取用户信息'), ), ), ); } }

看到没?业务代码里完全看不到baseUrl、token、loading、日志这些网络层细节。所有横切关注点(cross-cutting concerns)都被拦截器接管,业务方只需专注“我要什么数据”。

4.3 环境切换实战:5行代码实现灰度发布

假设产品要求“让10%的用户访问测试环境”,在设置页添加切换按钮:

// lib/pages/settings_page.dart import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/network/base_url_manager.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @override State<SettingsPage> createState() => _SettingsPageState(); } class _SettingsPageState extends State<SettingsPage> { String _currentEnv = '生产环境'; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('设置')), body: ListView( children: [ ListTile( title: const Text('API环境'), subtitle: Text(_currentEnv), trailing: const Icon(Icons.arrow_forward_ios), onTap: () async { final prefs = await SharedPreferences.getInstance(); final currentUrl = prefs.getString('api_base_url') ?? 'https://prod-api.com'; // 切换逻辑:生产<->测试 final newUrl = currentUrl.contains('prod') ? 'https://test-api.com' : 'https://prod-api.com'; await prefs.setString('api_base_url', newUrl); baseUrlManager.update(newUrl); // 立即生效 setState(() { _currentEnv = newUrl.contains('test') ? '测试环境' : '生产环境'; }); // 可选:提示用户重启App(因部分配置需重启生效) ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('环境已切换,部分功能需重启生效')), ); }, ), ], ), ); } }

关键点:baseUrlManager.update(newUrl)这行代码让所有后续新请求立即使用新地址,无需重启App。已发出的请求不受影响,符合“热更新”预期。

5. 常见问题与独家避坑指南:血泪经验总结

5.1 Dio请求被取消的7种真实场景及排查清单

Dio请求被取消(DioErrorType.cancel)是高频问题,但日志里只显示“Request cancelled”,根本看不出原因。根据我们线上监控数据,TOP3原因如下:

排查方向具体表现解决方案
页面销毁未取消请求页面A发起请求,跳转到页面B时请求仍在进行,Dio抛出cancel错误在dispose()中调用_dio.cancel(),或使用CancelableOperation包装请求
拦截器中异常抛出AuthInterceptor里await StorageUtil.getToken()抛出异常,导致请求中断所有拦截器内部必须try-catch,用handler.next(err)传递错误,而非throw
Dio实例被重建多次调用initNetwork()导致旧Dio被GC,其挂起的请求全部取消在initNetwork()开头加锁,确保只初始化一次

实操诊断命令:在LoggingInterceptor.onError中增加以下判断:

if (err.type == DioErrorType.cancel) { logger.w('⚠️ 请求被取消,可能原因:${_getCancelReason(err)}'); }

其中_getCancelReason()可检查err.requestOptions.extra中的自定义标记,或堆栈中是否包含dispose调用。

5.2 Token刷新死循环:如何避免“越刷越错”

AuthInterceptor的token刷新逻辑极易引发死循环。典型场景:

  • 刷新接口/refresh本身返回401(因refresh_token也过期);
  • 拦截器捕获到401,再次调用/refresh,无限递归。

终极解决方案:添加刷新次数限制和降级策略:

class AuthInterceptor extends Interceptor { static const int _maxRefreshAttempts = 2; int _refreshCount = 0; @override void onError(DioError err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode == 401) { if (_refreshCount < _maxRefreshAttempts) { _refreshCount++; // ... 执行刷新逻辑 } else { // 达到最大重试次数,强制登出 await StorageUtil.clearAuth(); // 导航到登录页(需通过Navigator.of(context)获取上下文) handler.next(err); } } } }

注意:_refreshCount必须是实例变量,不能是静态变量!否则多个请求会共享计数器,导致正常请求也被误判。

5.3 多Dio实例的内存泄漏陷阱:一个被忽视的性能杀手

有些场景需要专用Dio(如大文件上传),开发者常这样写:

final uploadDio = Dio(); // ❌ 错误:未注册为单例,GC时可能泄漏

Dio内部持有HttpClient,而HttpClient在Android上会持有Context引用,导致Activity无法回收。正确做法:

// 创建专用Dio时,必须手动管理生命周期 final uploadDio = Dio(); getIt.registerSingleton<Dio>(uploadDio, instanceName: 'upload'); // 在页面dispose时释放 @override void dispose() { getIt<Dio>(instanceName: 'upload').close(); // 关键! super.dispose(); }

实测数据:未调用close()的Dio实例,会导致Android内存占用持续增长,30分钟后OOM。调用close()后,内存立即回落。

5.4 日志爆炸问题:如何避免日志文件撑爆用户手机

线上环境开启详细日志后,单日日志文件可达50MB。解决方案是分级采样:

class LoggingInterceptor extends Interceptor { // 生产环境只记录错误和慢请求(>1000ms) static final bool _isProduction = !kDebugMode; @override void onResponse(Response response, ResponseInterceptorHandler handler) { final duration = _getDuration(response); if (_isProduction) { // 仅记录错误或慢请求 if (response.statusCode! >= 400 || duration > 1000) { _logResponse(response, duration); } } else { // 调试环境全量记录 _logResponse(response, duration); } handler.next(response); } }

同时,日志文件采用滚动策略:超过5MB自动压缩归档,最多保留3个历史文件。用file_logger包可一行代码启用:

final fileLogger = FileLogger( fileName: 'network.log', maxLogSize: 5 * 1024 * 1024, // 5MB maxLogFiles: 3, );

5.5 Flutter Web特殊问题:CORS与凭据处理

在Web环境下,Dio默认不发送Cookie,导致登录态丢失。必须显式配置:

final baseOptions = BaseOptions( baseUrl: 'https://api.example.com', // 关键:Web端必须开启withCredentials withCredentials: true, // 并在服务器响应头中添加:Access-Control-Allow-Credentials: true );

但开启withCredentials后,Access-Control-Allow-Origin不能为*,必须指定精确域名:

# 服务器响应头(Nginx配置示例) add_header 'Access-Control-Allow-Origin' 'https://your-app.com'; add_header 'Access-Control-Allow-Credentials' 'true';

提示:本地开发时,用flutter run -d chrome --web-hostname localhost --web-port 8080启动,然后在Chrome中访问http://localhost:8080,避免跨域问题。

6. 性能优化与扩展建议:让网络层更健壮

6.1 请求缓存策略:减少30%的重复请求

Dio本身不支持HTTP缓存,但可通过拦截器实现简易缓存:

class CacheInterceptor extends Interceptor { final Map<String, CacheEntry> _cache = {}; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (options.method == 'GET') { final key = '${options.baseUrl}${options.path}?${options.queryParameters}'; final entry = _cache[key]; if (entry != null && entry.isValid()) { // 返回缓存数据(需克隆,避免引用污染) handler.resolve(Response( data: jsonDecode(entry.data), requestOptions: options, statusCode: 200, )); return; } } handler.next(options); } }

注意:缓存需配合ETag或Last-Modified头使用,否则可能返回过期数据。生产环境建议用cached_network_image的缓存策略作为参考。

6.2 网络质量感知:根据信号强度动态调整超时

利用connectivity_plus包检测网络类型,动态设置超时:

import 'package:connectivity_plus/connectivity_plus.dart'; Future<void> _adjustTimeout() async { final connectivityResult = await Connectivity().checkConnectivity(); final dio = getIt<Dio>(); switch (connectivityResult) { case ConnectivityResult.mobile: dio.options.connectTimeout = const Duration(seconds: 15); dio.options.receiveTimeout = const Duration(seconds: 45); break; case ConnectivityResult.wifi: dio.options.connectTimeout = const Duration(seconds: 5); dio.options.receiveTimeout = const Duration(seconds: 15); break; default: dio.options.connectTimeout = const Duration(seconds: 30); dio.options.receiveTimeout = const Duration(seconds: 60); } }

6.3 后续演进路线:从封装到平台化

当前封装已满足90%场景,若项目规模扩大,可向三个方向演进:

  • 可观测性增强:集成OpenTelemetry,上报请求链路追踪(TraceID),与后端APM系统打通;
  • 协议扩展:在Dio基础上支持gRPC-Web,复用同一套拦截器和日志体系;
  • 离线优先:结合hive数据库,实现请求队列持久化,网络恢复后自动重发。

我个人在实际项目中的体会是:

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询