在这里插入图片描述

一、引言:为什么选 dio 作为适配示例

在 Flutter 生态中,dio 是毫无争议的 HTTP 客户端一哥。它支持拦截器、FormData、文件上传下载、请求取消、Mock 测试、超时控制等企业级特性,国内绝大多数 Flutter 团队的网络层都选它。

dio 不是纯 Dart 包,它的底层调用了 dart:io 中的原生 HTTP 能力。这意味着 Flutter 官方只内置了 Android 和 iOS 两个平台的 Embedder 实现,OpenHarmony 平台需要额外适配。

好消息是:Flutter 鸿蒙社区(CPF-Flutter)已经完成了这一层适配工作,并且适配方案对上层 API 做到了完全兼容——你的现有 dio 代码几乎不需要任何修改,在鸿蒙上就能直接运行。

本文目标:以 dio v5.4.3 为例,完整演示:

  1. 如何在 Flutter 鸿蒙项目中正确集成 dio
  2. 单例封装 + 拦截器体系(AuthInterceptor、LogInterceptor)
  3. 文件上传(onSendProgress 进度回调)
  4. OpenHarmony 平台的 5 个特殊注意事项
  5. 完整生产级网络模块代码示例

二、环境准备:项目创建与依赖配置

2.1 环境要求

依赖项版本要求说明
Flutter SDK≥ 3.35.7(推荐 3.44.9)包含 OpenHarmony 平台支持
OpenHarmony SDK7.0.0(API 26)鸿蒙平台运行依赖
dio≥ 5.4.0(推荐 5.4.3)社区已完成鸿蒙适配
Dart≥ 3.4.0

2.2 创建支持鸿蒙的 Flutter 项目

# 检查 Flutter 版本
flutter --version
# Flutter 3.44.9 • channel stable

# 创建项目并指定支持的平台
flutter create --platforms=openharmony dio\_ohos\_demo

cd dio\_ohos\_demo

# 添加 dio 依赖
flutter pub add dio

# 查看 dio 版本
flutter pub deps | grep dio
# Should output: dio 5.4.3

2.3 pubspec.yaml 完整配置

name: dio\_ohos\_demo
description: Flutter dio 鸿蒙化适配演示项目

environment:
  sdk: '>=3.4.0 <4.0.0'
  flutter: '>=3.44.0'

dependencies:
  flutter:
    sdk: flutter

  # ★ 核心:HTTP 客户端(社区已适配 OpenHarmony)
  dio: ^5.4.3+1

  # 以下库由社区完成鸿蒙适配,可直接使用
  connectivity\_plus: ^6.0.5      # 网络状态监听
  path\_provider: ^2.1.4         # 文件路径获取
  flutter\_inappwebview: ^6.1.5+2 # WebView(部分功能已适配)

dev\_dependencies:
  flutter\_test:
    sdk: flutter
  flutter\_lints: ^4.0.0

flutter:
  uses-material-design: true

2.4 验证 dio 在鸿蒙平台是否可用

运行以下命令,确认 Flutter doctor 报告 OpenHarmony 平台正常:

flutter doctor
# ✓ OpenHarmony toolchain: Ready
# ✓ Flutter (OpenHarmony): Ready


请添加图片描述

三、核心功能验证:dio API 逐个测

3.1 单例封装:全局 Dio 客户端

在真实项目中,永远不要每次请求都 new Dio()。正确做法是封装一个全局单例客户端,集中管理 BaseURL、超时时间、拦截器等配置:

// lib/net/dio\_client.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

/// 鸿蒙化适配的 Dio 客户端单例
/// 配置与标准 Flutter dio 完全一致,社区适配层自动处理 OpenHarmony 差异
class DioClient {
  static DioClient? \_instance;
  late final Dio \_dio;

  DioClient.\_internal() {
    \_dio = Dio(
      BaseOptions(
        // API 基础地址(示例)
        baseUrl: 'https://api.example.com',
        // 连接超时:10 秒(网络不好时尽早报错)
        connectTimeout: const Duration(seconds: 10),
        // 接收超时:15 秒(等待服务器响应)
        receiveTimeout: const Duration(seconds: 15),
        // 发送超时:10 秒(上传文件时限制)
        sendTimeout: const Duration(seconds: 10),
        // 默认请求头
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      ),
    );

    // ── 拦截器 1:日志拦截器(仅 Debug 模式生效)──────────────
    \_dio.interceptors.add(
      LogInterceptor(
        requestBody: true,  // 打印请求体
        responseBody: true, // 打印响应体
        error: true,        // 打印错误
        logPrint: (obj) {
          // 统一通过 debugPrint 输出,生产环境不打印
          if (kDebugMode) {
            debugPrint('\[Dio] $obj');
          }
        },
      ),
    );

    // ── 拦截器 2:Token 自动刷新拦截器(见第 4 节)────────────
    \_dio.interceptors.add(AuthInterceptor());
  }

  static DioClient get instance {
    \_instance ??= DioClient.\_internal();
    return \_instance!;
  }

  Dio get client => \_dio;
}

**关键设计点**:全局单例保证所有请求共享同一个连接池(ConnectionPool),减少 TCP 握手开销。同时所有配置集中在一处,修改 BaseURL 或超时时间只需改一处代码。

3.2 GET 请求:带参数、错误处理、统一封装

// lib/net/article\_repository.dart

/// 文章列表实体
class Article {
  final String id;
  final String title;
  final String summary;
  final String author;
  final DateTime publishedAt;

  Article({
    required this.id,
    required this.title,
    required this.summary,
    required this.author,
    required this.publishedAt,
  });

  factory Article.fromJson(Map<String, dynamic> json) => Article(
    id: json\['id'] as String,
    title: json\['title'] as String,
    summary: json\['summary'] as String,
    author: json\['author'] as String,
    publishedAt: DateTime.parse(json\['publishedAt'] as String),
  );
}

/// 获取文章列表
Future<List<Article>> fetchArticles({
  int page = 1,
  int pageSize = 20,
}) async {
  try {
    final response = await DioClient.instance.client.get(
      '/articles',
      queryParameters: {
        'page': page,
        'pageSize': pageSize,
      },
    );

    if (response.statusCode == 200) {
      final data = response.data as Map<String, dynamic>;

      // 业务错误码判断(如后端返回 { code: 0, data: \[...] })
      if (data\['code'] == 0) {
        final list = data\['data']\['list'] as List;
        return list
            .map((item) => Article.fromJson(item as Map<String, dynamic>))
            .toList();
      }

      // 业务错误:抛出业务异常
      throw DioException(
        requestOptions: response.requestOptions,
        message: data\['msg'] ?? '请求失败',
        type: DioExceptionType.badResponse,
      );
    }

    // HTTP 非 200
    throw DioException(
      requestOptions: response.requestOptions,
      message: 'HTTP ${response.statusCode}',
      type: DioExceptionType.badResponse,
    );
  } on DioException catch (e) {
    // 统一错误转换:将底层异常转换为业务可理解的异常
    throw \_transformDioError(e);
  }
}

/// 统一错误转换:将 DioException 转换为业务异常 AppException
AppException \_transformDioError(DioException e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.sendTimeout:
    case DioExceptionType.receiveTimeout:
      return AppException('网络连接超时,请检查网络设置');
    case DioExceptionType.connectionError:
      return AppException('网络连接失败,请确认网络畅通');
    case DioExceptionType.cancel:
      return AppException('请求已取消');
    case DioExceptionType.badResponse:
      final statusCode = e.response?.statusCode;
      if (statusCode == 401) {
        return AppException('登录已过期,请重新登录', code: 401);
      } else if (statusCode == 403) {
        return AppException('无访问权限');
      } else if (statusCode == 404) {
        return AppException('请求资源不存在');
      } else if (statusCode != null \&\& statusCode >= 500) {
        return AppException('服务器异常,请稍后重试');
      }
      return AppException('请求失败 ($statusCode)');
    default:
      return AppException('未知错误:${e.message}');
  }
}

3.3 POST 请求:JSON Body 与 FormData

/// 用户登录
Future<LoginResult> login({
  required String username,
  required String password,
}) async {
  try {
    final response = await DioClient.instance.client.post(
      '/auth/login',
      data: {
        'username': username,
        'password': password, // 生产环境记得先做 MD5/SHA256 哈希
      },
    );

    if (response.statusCode == 200) {
      final data = response.data as Map<String, dynamic>;
      if (data\['code'] == 0) {
        return LoginResult.fromJson(data\['data'] as Map<String, dynamic>);
      }
      throw AppException(data\['msg'] ?? '登录失败');
    }
    throw AppException('HTTP ${response.statusCode}');
  } on DioException catch (e) {
    throw \_transformDioError(e);
  }
}

四、文件上传实战:带进度回调的上传功能

文件上传是网络层的常见需求,dio 提供了 onSendProgress 回调来实时获取上传进度。以下是完整实现:

4.1 基础文件上传

/// 上传图片文件到服务器(带进度回调)
Future<String> uploadImage(File imageFile) async {
  // 1. 构建 FormData
  final formData = FormData.fromMap({
    'file': await MultipartFile.fromFile(
      imageFile.path,
      // ⚠️ filename 建议使用英文,中文文件名在某些服务端可能导致乱码
      filename: imageFile.path.split('/').last,
    ),
    'type': 'avatar', // 额外参数:文件用途标识
  });

  try {
    // 2. 发起请求,通过 onSendProgress 获取上传进度
    final response = await DioClient.instance.client.post(
      '/upload',
      data: formData,
      onSendProgress: (int sent, int total) {
        // sent: 已发送字节数, total: 总字节数
        final progress = (sent / total \* 100).toStringAsFixed(1);
        debugPrint('\[上传进度] $progress% ($sent / $total)');

        // 进度回调通常需要通知 UI 层(如更新进度条)
        // 这里可以通过 Provider/Riverpod/StateNotifier 推送
        // UploadProgressNotifier.update(progress);
      },
    );

    // 3. 解析响应
    if (response.statusCode == 200) {
      final data = response.data as Map<String, dynamic>;
      if (data\['code'] == 0) {
        return data\['data']\['url'] as String; // 返回上传后的文件 URL
      }
      throw AppException(data\['msg'] ?? '文件上传失败');
    }
    throw AppException('上传失败: HTTP ${response.statusCode}');
  } on DioException catch (e) {
    throw \_transformDioError(e);
  }
}

4.2 UI 端:完整上传页面实现

以下是一个可以直接嵌入项目的 Flutter 上传页面,包含进度条、拦截器日志面板和底部技术栈标签:
请添加图片描述

请添加图片描述
请添加图片描述

五、AuthInterceptor:Token 自动刷新拦截器

企业级应用中,用户登录后拿到的 AccessToken 通常有有效期(常见 1~2 小时)。Token 过期后,服务器返回 401,传统的做法是跳转登录页让用户重新登录,体验极差。

正确做法是:在 401 发生时,自动用 RefreshToken 获取新 AccessToken,再重试原请求。这个逻辑统一封装在 AuthInterceptor 中,对业务代码完全透明。

5.1 拦截器实现

// lib/net/auth\_interceptor.dart
import 'dart:async';
import 'package:dio/dio.dart';

/// AuthInterceptor:自动处理 Token 刷新
///
/// 工作流程:
/// 1. 请求返回 401(Token 过期)
/// 2. 用 RefreshToken 向 /auth/refresh 发请求,换取新 AccessToken
/// 3. 将新 Token 写入本地存储
/// 4. 用新 Token 重试原请求
/// 5. 若 RefreshToken 也过期,清除登录态,通知上层跳转登录页
///
/// 注意:多个请求同时收到 401 时,只有一个去刷新 Token,其余排队等待
class AuthInterceptor extends Interceptor {
  static const \_refreshTokenKey = 'refresh\_token';
  static const \_accessTokenKey = 'access\_token';

  bool \_isRefreshing = false; // 防止并发刷新
  final List<\_QueuedRequest> \_pendingRequests = \[]; // 等待 Token 的请求队列

  // ── 请求拦截:注入 AccessToken ───────────────────────────
  
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    // 从本地存储读取 Token
    final accessToken = await \_getStoredToken(\_accessTokenKey);
    if (accessToken != null) {
      options.headers\['Authorization'] = 'Bearer $accessToken';
    }
    handler.next(options);
  }

  // ── 错误拦截:处理 401 ──────────────────────────────────
  
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    // 仅拦截 401 错误,其他错误透传
    if (err.response?.statusCode != 401) {
      return handler.next(err);
    }

    // ── 场景:多个请求同时 401 ───────────────────────────────
    // 只让第一个请求触发 Token 刷新,其余请求排队
    if (\_isRefreshing) {
      final completer = Completer<Response<dynamic>>();
      \_pendingRequests.add(\_QueuedRequest(err.requestOptions, completer));
      try {
        final result = await completer.future;
        handler.resolve(result);
      } catch (e) {
        handler.next(err);
      }
      return;
    }

    \_isRefreshing = true;

    try {
      // ── 步骤 1:用 RefreshToken 换取新 AccessToken ─────────
      final refreshToken = await \_getStoredToken(\_refreshTokenKey);
      if (refreshToken == null) {
        throw Exception('RefreshToken 不存在,请重新登录');
      }

      // ★ 创建独立的 Dio 实例(禁止复用主实例,防止死锁!)
      final refreshDio = Dio();
      final refreshResp = await refreshDio.post(
        '${DioClient.instance.client.options.baseUrl}/auth/refresh',
        data: {'refreshToken': refreshToken},
      );

      final newAccessToken = refreshResp.data\['data']\['accessToken'] as String;
      final newRefreshToken = refreshResp.data\['data']\['refreshToken'] as String;

      // ── 步骤 2:保存新 Token ────────────────────────────────
      await \_saveToken(\_accessTokenKey, newAccessToken);
      await \_saveToken(\_refreshTokenKey, newRefreshToken);

      // ── 步骤 3:重试排队的所有请求 ─────────────────────────
      for (final req in \_pendingRequests) {
        req.requestOptions.headers\['Authorization'] = 'Bearer $newAccessToken';
        try {
          final result = await DioClient.instance.client.fetch(
            req.requestOptions,
          );
          req.completer.resolve(result);
        } catch (e) {
          req.completer.reject(e);
        }
      }
      \_pendingRequests.clear();

      // ── 步骤 4:重试当前 401 的请求 ────────────────────────
      err.requestOptions.headers\['Authorization'] = 'Bearer $newAccessToken';
      final result = await DioClient.instance.client.fetch(err.requestOptions);
      \_isRefreshing = false;
      handler.resolve(result);

    } catch (e) {
      // ── RefreshToken 也过期:清除登录态 ───────────────────
      await \_clearAllTokens();
      \_pendingRequests.clear();
      \_isRefreshing = false;

      // 通知上层(通过 AppException 的 code=401 区分)
      handler.next(DioException(
        requestOptions: err.requestOptions,
        message: '登录已过期,请重新登录',
        type: DioExceptionType.badResponse,
        response: err.response?.copyWith(statusCode: 401),
      ));
    }
  }

  // ── Token 存储(示例,实际建议用 flutter\_secure\_storage)──
  Future<String?> \_getStoredToken(String key) async {
    // 简化实现:使用 shared\_preferences
    // 建议生产环境使用 flutter\_secure\_storage(敏感 Token 加密存储)
    // import 'package:shared\_preferences/shared\_preferences.dart';
    // final sp = await SharedPreferences.getInstance();
    // return sp.getString(key);
    return null; // placeholder
  }

  Future<void> \_saveToken(String key, String value) async {
    // final sp = await SharedPreferences.getInstance();
    // await sp.setString(key, value);
  }

  Future<void> \_clearAllTokens() async {
    // final sp = await SharedPreferences.getInstance();
    // await sp.remove(\_accessTokenKey);
    // await sp.remove(\_refreshTokenKey);
  }
}

class \_QueuedRequest {
  final RequestOptions requestOptions;
  final Completer<Response<dynamic>> completer;

  \_QueuedRequest(this.requestOptions, this.completer);
}

5.2 业务层如何使用

业务代码完全不感知 Token 刷新过程,像正常请求一样写即可:

// 用户资料页
Future<UserProfile> fetchUserProfile() async {
  final resp = await DioClient.instance.client.get('/user/profile');
  return UserProfile.fromJson(resp.data\['data']);
}

// 如果 Token 过期,上面的代码会自动完成刷新+重试
// 业务层不需要任何额外处理

六、OpenHarmony 平台特殊注意事项

6.1 HTTPS 证书验证

OpenHarmony 默认严格校验服务器证书链,与 Android/iOS 行为有差异。开发阶段如遇 certificate verify failed,可在 onHttpClientCreate 中临时覆盖:

\_dio.httpClientAdapter = HttpClientAdapter();

/// 注意:仅开发环境使用,生产环境必须配置有效 CA 证书
/// 参考:OpenHarmony 支持的自定义 TrustManager 配置

6.2 中文文件名上传

// ❌ 中文文件名可能导致服务端收到乱码
'file': await MultipartFile.fromFile(path, filename: '我的图片.jpg')

// ✅ 方案一:URL 编码
'file': await MultipartFile.fromFile(
  path,
  filename: Uri.encodeComponent('我的图片.jpg'),
)

// ✅ 方案二:上传前重命名为 UUID,图片内容不变
'file': await MultipartFile.fromFile(
  path,
  filename: '${Uuid().v4()}.jpg', // 生成随机文件名
)

6.3 超时时间配置差异

超时类型设置建议说明
connectTimeout10s网络不好时尽早报错
receiveTimeout15s普通 API 响应
sendTimeout30s上传大文件时放宽
下载大文件单独设 CancelToken + UI 层兜底receiveTimeout 不能控制下载总时间

6.4 拦截器死锁问题

⚠️ **最常见陷阱**:在拦截器中调用 await DioClient.instance.client.get(...) 刷新 Token 时,如果复用了同一个 Dio 实例,会造成死锁——请求在等 Token,Token 刷新请求也在等连接池。

解法:永远为 Token 刷新请求创建独立的 Dio 实例(参见上方 refreshDio 的用法)。

6.5 请求取消的 UI 联动

用户快速切换 Tab 时,之前 pending 的请求需要主动取消:

// 页面 State 中持有 CancelToken
class \_ArticleListState extends State<ArticleList> {
  final CancelToken \_cancelToken = CancelToken();

  
  void dispose() {
    // 页面销毁时取消所有 pending 请求
    \_cancelToken.cancel('页面已销毁');
    super.dispose();
  }

  Future<void> loadArticles() async {
    await DioClient.instance.client.get(
      '/articles',
      cancelToken: \_cancelToken, // 传入 CancelToken
    );
  }
}

七、Flutter 鸿蒙适配的核心原理

Flutter 的跨平台能力来自三层架构:

Flutter SDK(Framework)
    ↓ Dart 代码
Dart Runtime(Engine)
    ↓ 平台无关 API
Platform Embedder(Android / iOS / OpenHarmony)
    ↓ 平台实现
原生系统能力(HTTP、文件、传感器等)

dio 的 dart:io HTTP 调用在 Android/iOS 平台由 Flutter SDK 自带的 Embedder 处理。OpenHarmony 平台的适配工作,由 Flutter 鸿蒙社区(CPF-Flutter)完成——他们提供了 OpenHarmony 平台的 Embedder 实现,将 dart:io 的网络调用映射到 OpenHarmony 的 HTTP API。

对 Flutter 开发者而言:这一层完全透明。dio 的 API 写法在 Android、iOS、OpenHarmony 三个平台上完全一致。

八、避坑清单总结

#坑描述症状解法
1安装了标准版 dio(非 Flutter 渠道包)编译失败使用 flutter pub add dio 而非直接 dart pub add
2HTTPS 证书验证失败certificate verify failed开发阶段临时覆盖 TrustManager,生产必须配 CA
3中文文件名上传乱码后台收到 ????URL 编码或改用 UUID 文件名
4响应超时不生效(下载场景)请求一直等待receiveTimeout 只控制响应头超时,下载需另设 CancelToken 兜底
5拦截器中复用主 Dio 实例死锁,请求永远 pendingToken 刷新必须用独立的 new Dio() 实例
6页面销毁时未取消请求页面销毁后回调更新已卸载的 Widget页面 dispose 中调用 cancelToken.cancel()

总结

  1. 零额外适配:Flutter 鸿蒙社区已完成 dio 的 OpenHarmony 桥接,现有代码直接可用,无需修改。
  2. 单例封装是基础:全局一个 Dio 实例,集中管理配置和拦截器,是企业级网络层的标准架构。
  3. 拦截器是灵魂:AuthInterceptor 自动处理 Token 刷新,让业务代码完全不用关心登录态过期。
  4. onSendProgress 是上传体验的保障:实时反馈上传进度,用户知道"还有多久",体验大幅提升。
  5. OpenHarmony 差异需关注:证书验证、中文文件名、超时配置、拦截器死锁、请求取消。

以上
——这 5 个点我觉得是在鸿蒙上跑稳 dio 的关键!

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐