【Moya】实现Moya网络架构

在 Flutter 中借鉴 Moya 设计思路:从简单到可扩展的网络层架构

本文记录如何在 Flutter 中借鉴 iOS 网络库 Moya 的设计思路,构建一个既优雅又易扩展的网络层。文章从最简单的单 enum 实现开始,逐步演进到支持模块拆分、泛型约束、统一公共参数等工程化方案。


一、Moya 的核心思想

在 iOS 中,Moya 基于 Alamofire 做了一层封装,其核心思想是:

  • 用一个类型来"描述接口"(比如 enum MyService + TargetType 协议)
  • 将接口的所有配置集中定义:
    • baseURL
    • path
    • method
    • 参数
    • header
    • sampleData(用于测试)
  • 调用方只需写:provider.request(.login(account: "a", password: "b"))
  • 业务层完全不用关心 URL、method 等底层细节

在 Flutter 中,我们可以用 enum + extension + Dio 来实现类似的思路。


第一部分:最简单的一版——单 enum 描述所有接口

1.1 定义枚举

enum ApiEndpoint {
  login,
  getUserInfo,
}

1.2 用 extension 给枚举添加配置

extension ApiEndpointConfig on ApiEndpoint {
  String get baseUrl => 'https://api.example.com';

  String get path {
    switch (this) {
      case ApiEndpoint.login:
        return '/login';
      case ApiEndpoint.getUserInfo:
        return '/user/info';
    }
  }

  String get method {
    switch (this) {
      case ApiEndpoint.login:
        return 'POST';
      case ApiEndpoint.getUserInfo:
        return 'GET';
    }
  }

  Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
    switch (this) {
      case ApiEndpoint.login:
        return {
          'account': extra['account'],
          'password': extra['password'],
        };
      case ApiEndpoint.getUserInfo:
        return {
          'id': extra['id'],
        };
    }
  }
}

1.3 创建简单的 Client

import 'package:dio/dio.dart';

class ApiClient {
  final Dio _dio;

  ApiClient({Dio? dio}) : _dio = dio ?? Dio();

  Future<Response> request(
    ApiEndpoint endpoint, {
    Map<String, dynamic> params = const {},
  }) async {
    final url = endpoint.baseUrl + endpoint.path;
    final builtParams = endpoint.buildParams(params);

    switch (endpoint.method) {
      case 'GET':
        return _dio.get(url, queryParameters: builtParams);
      case 'POST':
        return _dio.post(url, data: builtParams);
      default:
        throw UnimplementedError('Unsupported method: ${endpoint.method}');
    }
  }
}

1.4 使用方式

final api = ApiClient();

// 登录
final loginResp = await api.request(
  ApiEndpoint.login,
  params: {'account': 'test', 'password': '123456'},
);

// 获取用户信息
final userResp = await api.request(
  ApiEndpoint.getUserInfo,
  params: {'id': 1001},
);

优点:

  • 结构简单清晰,易于理解
  • 所有接口集中管理,适合快速起步

缺点:

  • 当接口增多时,单个 enum 文件会变得臃肿
  • 不利于按业务模块管理接口

第二部分:模块拆分 + 泛型 Client + 统一公共参数

2.1 设计目标

  • 接口按业务模块拆分:每个模块维护自己的 enum
  • 只有一个统一的 Client:统一管理 Dio、拦截器、日志等
  • 统一处理公共参数:token、appVersion、设备信息等
  • 保持优雅的调用方式client.request(AuthApi.login, params: {...})

2.2 定义统一的接口协议

// api_target.dart
/// 统一的接口描述:类似 Moya 的 TargetType
abstract class ApiTarget {
  String get baseUrl;
  String get path;
  String get method; // 'GET' / 'POST' ...
  Map<String, dynamic> buildParams(Map<String, dynamic> extra);
}

2.3 各模块的 enum 实现 ApiTarget

2.3.1 登录模块:auth_api.dart

// auth_api.dart
import 'api_target.dart';

enum AuthApi implements ApiTarget {
  login,
  refreshToken,
}

extension AuthApiConfig on AuthApi {
  @override
  String get baseUrl => 'https://api.example.com';

  @override
  String get path {
    switch (this) {
      case AuthApi.login:
        return '/auth/login';
      case AuthApi.refreshToken:
        return '/auth/refresh';
    }
  }

  @override
  String get method {
    switch (this) {
      case AuthApi.login:
        return 'POST';
      case AuthApi.refreshToken:
        return 'POST';
    }
  }

  @override
  Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
    switch (this) {
      case AuthApi.login:
        return {
          'account': extra['account'],
          'password': extra['password'],
        };
      case AuthApi.refreshToken:
        return {
          'refreshToken': extra['refreshToken'],
        };
    }
  }
}

2.3.2 用户模块:user_api.dart

// user_api.dart
import 'api_target.dart';

enum UserApi implements ApiTarget {
  profile,
  updateAvatar,
}

extension UserApiConfig on UserApi {
  @override
  String get baseUrl => 'https://api.example.com';

  @override
  String get path {
    switch (this) {
      case UserApi.profile:
        return '/user/profile';
      case UserApi.updateAvatar:
        return '/user/avatar';
    }
  }

  @override
  String get method {
    switch (this) {
      case UserApi.profile:
        return 'GET';
      case UserApi.updateAvatar:
        return 'POST';
    }
  }

  @override
  Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
    switch (this) {
      case UserApi.profile:
        return {'userId': extra['userId']};
      case UserApi.updateAvatar:
        return {'fileId': extra['fileId']};
    }
  }
}

2.4 使用泛型创建统一的 AppApiClient

// app_api_client.dart
import 'package:dio/dio.dart';
import 'api_target.dart';

class AppApiClient {
  final Dio _dio;

  AppApiClient({Dio? dio})
      : _dio = dio ?? Dio() {
    // 统一配置:超时、拦截器等
    _dio.options
      ..connectTimeout = const Duration(seconds: 10)
      ..receiveTimeout = const Duration(seconds: 10);
  }

  /// 泛型方法:T 必须实现 ApiTarget
  Future<Response> request<T extends ApiTarget>(
    T api, {
    Map<String, dynamic> params = const {},
  }) async {
    final url = api.baseUrl + api.path;

    // 先由具体的枚举组装接口自己的参数
    final builtParams = api.buildParams(params);

    // 然后合并公共参数
    final finalParams = _mergeCommonParams(builtParams);

    switch (api.method) {
      case 'GET':
        return _dio.get(url, queryParameters: finalParams);
      case 'POST':
        return _dio.post(url, data: finalParams);
      default:
        throw UnimplementedError('Unsupported method: ${api.method}');
    }
  }

  /// 统一合并公共参数
  Map<String, dynamic> _mergeCommonParams(Map<String, dynamic> origin) {
    final common = <String, dynamic>{
      'appVersion': '1.0.0',
      'platform': 'flutter',
      // 可以从单例获取 token
      // 'token': UserManager.ins.token,
    };

    // 合并:业务参数覆盖同名公共参数
    return {
      ...common,
      ...origin,
    };
  }
}

2.5 使用方式

final client = AppApiClient();

// 登录(Auth 模块)
final loginResp = await client.request(
  AuthApi.login,
  params: {
    'account': 'test',
    'password': '123456',
  },
);

// 获取用户信息(User 模块)
final profileResp = await client.request(
  UserApi.profile,
  params: {
    'userId': 1001,
  },
);

优点:

  • 接口按模块拆分,易于维护
  • 统一的 Client,便于统一处理日志、错误、拦截器等
  • 类型安全:泛型约束保证只能传入实现了 ApiTarget 的类型
  • 扩展性强:新增模块只需添加新的 enum,无需修改 Client

第三部分:统一管理 baseUrl(解决重复问题)

如果所有接口都使用同一个域名,每个 enum 都写一遍 baseUrl 会很冗余。解决方案如下:

3.1 在 ApiTarget 接口中提供默认实现

// api_target.dart
abstract class ApiTarget {
  /// 默认 baseUrl,从全局配置读取
  /// 如果某个接口需要不同的 baseUrl,可以 override
  String get baseUrl => ApiConfig.baseUrl;

  String get path;
  String get method;
  Map<String, dynamic> buildParams(Map<String, dynamic> extra);
}

/// 全局配置类
class ApiConfig {
  static String baseUrl = 'https://api.example.com';

  /// 如果需要动态切换环境(开发/测试/生产)
  static void setBaseUrl(String url) {
    baseUrl = url;
  }
}

3.2 各模块 enum 不再需要写 baseUrl

// auth_api.dart
enum AuthApi implements ApiTarget {
  login,
  refreshToken,
}

extension AuthApiConfig on AuthApi {
  // baseUrl 不用写了!直接使用默认值

  @override
  String get path {
    switch (this) {
      case AuthApi.login:
        return '/auth/login';
      case AuthApi.refreshToken:
        return '/auth/refresh';
    }
  }

  @override
  String get method {
    switch (this) {
      case AuthApi.login:
        return 'POST';
      case AuthApi.refreshToken:
        return 'POST';
    }
  }

  @override
  Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
    // ...
  }
}

3.3 特殊接口可以单独 override baseUrl

如果某个接口需要使用不同的域名(比如第三方服务),可以单独 override:

// third_party_api.dart
enum ThirdPartyApi implements ApiTarget {
  uploadImage,
}

extension ThirdPartyApiConfig on ThirdPartyApi {
  @override
  String get baseUrl => 'https://cdn.example.com'; // 特殊接口 override

  @override
  String get path => '/upload';

  @override
  String get method => 'POST';

  @override
  Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
    return {'fileId': extra['fileId']};
  }
}

3.4 使用方式

// 初始化时统一配置 baseUrl(可选)
ApiConfig.setBaseUrl('https://api.production.com');

final client = AppApiClient();

// 普通接口使用默认 baseUrl
await client.request(AuthApi.login, params: {...});

// 特殊接口使用自己的 baseUrl
await client.request(ThirdPartyApi.uploadImage, params: {...});

优点:

  • 避免在每个 enum 中重复写 baseUrl
  • 统一管理,方便切换环境
  • 特殊接口仍可灵活 override

第四部分:buildParams 方法的作用

4.1 为什么需要 buildParams?

调用时已经传入了 params,为什么还要在 buildParams 里再处理一遍?

核心原因:buildParams 是接口级别的参数处理层,负责:

  1. 字段映射:业务层用 userId,后端要求 uid
  2. 参数校验:确保必填字段存在,否则抛异常
  3. 默认值处理:某些字段有默认值
  4. 参数转换:将业务参数转换为后端要求的格式
  5. 预留扩展点:未来接口参数规则变化时,只需修改这一处

4.2 不同复杂度的实现方式

4.2.1 简单项目:直接透传

@override
Map<String, dynamic> buildParams(Map<String, dynamic> extra) => extra;

这样 buildParams 只是一个"保留扩展点",未来需要时再添加逻辑。

4.2.2 中等复杂度:字段映射 + 校验

@override
Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
  switch (this) {
    case AuthApi.login:
      final account = extra['account'] as String?;
      final password = extra['password'] as String?;

      if (account == null || password == null) {
        throw ArgumentError('login 需要 account 和 password');
      }

      return {
        'acc': account, // 后端字段名是 acc
        'pwd': password, // 后端字段名是 pwd
      };

    case AuthApi.refreshToken:
      return {
        'refreshToken': extra['refreshToken'],
      };
  }
}

这样业务层完全不用关心"后端字段名是什么",也不用到处写校验逻辑。

4.2.3 高级用法:参数加密、签名等

@override
Map<String, dynamic> buildParams(Map<String, dynamic> extra) {
  switch (this) {
    case AuthApi.login:
      final params = {
        'account': extra['account'],
        'password': extra['password'],
      };

      // 添加签名
      params['sign'] = _generateSign(params);

      return params;
  }
}

4.3 如果不想用 buildParams 怎么办?

如果项目暂时不需要这些能力,可以简化:

abstract class ApiTarget {
  String get baseUrl => ApiConfig.baseUrl;
  String get path;
  String get method;
  // 不定义 buildParams,直接让 client 使用传入的 params
}

然后在 AppApiClient 中:

Future<Response> request<T extends ApiTarget>(
  T api, {
  Map<String, dynamic> params = const {},
}) async {
  final url = api.baseUrl + api.path;
  final finalParams = _mergeCommonParams(params); // 直接使用传入的 params

  switch (api.method) {
    case 'GET':
      return _dio.get(url, queryParameters: finalParams);
    case 'POST':
      return _dio.post(url, data: finalParams);
    default:
      throw UnimplementedError();
  }
}

建议: 即使现在不需要,也保留 buildParams 作为透传,这样未来扩展时不需要全局重构。


第五部分:如何添加公共参数

5.1 基础方案:在 AppApiClient 中统一合并

class AppApiClient {
  // ...

  Map<String, dynamic> _mergeCommonParams(Map<String, dynamic> origin) {
    final common = <String, dynamic>{
      'appVersion': '1.0.0',
      'platform': 'flutter',
      'deviceId': _getDeviceId(),
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      // 从单例获取 token
      // 'token': UserManager.ins.token,
    };

    // 合并:业务参数覆盖同名公共参数
    return {
      ...common,
      ...origin,
    };
  }

  String _getDeviceId() {
    // 获取设备 ID 的逻辑
    return 'device_123';
  }
}

5.2 高级方案:支持接口级别的控制

如果某些接口不需要某些公共参数,可以扩展 ApiTarget

abstract class ApiTarget {
  String get baseUrl => ApiConfig.baseUrl;
  String get path;
  String get method;
  Map<String, dynamic> buildParams(Map<String, dynamic> extra);

  /// 是否需要 token(默认需要)
  bool get needToken => true;

  /// 是否需要设备信息(默认需要)
  bool get needDeviceInfo => true;

  /// 额外的公共参数(可选)
  Map<String, dynamic> get extraCommonParams => const {};
}

然后在 AppApiClient 中根据这些标志决定是否添加:

Map<String, dynamic> _mergeCommonParams(
  Map<String, dynamic> origin,
  ApiTarget api,
) {
  final common = <String, dynamic>{
    'appVersion': '1.0.0',
    'platform': 'flutter',
  };

  // 根据接口配置决定是否添加
  if (api.needToken) {
    common['token'] = UserManager.ins.token;
  }

  if (api.needDeviceInfo) {
    common['deviceId'] = _getDeviceId();
  }

  // 合并接口自己的额外公共参数
  common.addAll(api.extraCommonParams);

  // 最后合并业务参数(业务参数优先级最高)
  return {
    ...common,
    ...origin,
  };
}

5.3 使用示例

// 普通接口:自动添加所有公共参数
await client.request(AuthApi.login, params: {...});

// 特殊接口:不需要 token
enum PublicApi implements ApiTarget {
  getConfig,
}

extension PublicApiConfig on PublicApi {
  @override
  bool get needToken => false; // 不需要 token

  @override
  String get path => '/public/config';
  // ...
}

5.4 动态公共参数(从单例/全局状态获取)

class AppApiClient {
  // ...

  Map<String, dynamic> _mergeCommonParams(
    Map<String, dynamic> origin,
    ApiTarget api,
  ) {
    final common = <String, dynamic>{
      'appVersion': AppInfo.version,
      'platform': 'flutter',
      'timestamp': DateTime.now().millisecondsSinceEpoch,
    };

    // 从全局状态获取
    if (api.needToken) {
      final token = UserManager.ins.token;
      if (token != null) {
        common['token'] = token;
      }
    }

    // 从设备信息获取
    if (api.needDeviceInfo) {
      common['deviceId'] = DeviceInfo.ins.deviceId;
      common['osVersion'] = DeviceInfo.ins.osVersion;
    }

    // 合并接口自己的额外公共参数
    common.addAll(api.extraCommonParams);

    // 业务参数覆盖公共参数
    return {
      ...common,
      ...origin,
    };
  }
}

5.5 公共参数的处理时机

公共参数的合并发生在 AppApiClient.request 方法中,流程如下:

1. 业务层调用:client.request(AuthApi.login, params: {...})
2. 接口自己的 buildParams 处理业务参数
3. Client 的 _mergeCommonParams 合并公共参数
4. 发送请求

这样保证了:

  • 接口级别的参数处理:在 buildParams 中完成
  • 全局级别的公共参数:在 Client 中统一添加
  • 清晰的职责分离:接口管接口的事,Client 管全局的事

总结

本文从最简单的单 enum 实现开始,逐步演进到:

  1. 模块拆分:按业务模块管理接口
  2. 泛型约束:保证类型安全,统一 Client
  3. 统一 baseUrl:避免重复配置
  4. buildParams 的作用:接口级别的参数处理层
  5. 公共参数管理:统一添加,支持接口级别控制

最终实现了一个既优雅又易扩展的网络层架构,既保留了 Moya 风格的调用方式,又兼顾了工程化的可维护性。


完整代码结构示例

lib/
  network/
    api_target.dart          # 统一接口协议
    api_config.dart          # 全局配置(baseUrl 等)
    app_api_client.dart      # 统一的 Client
    api/
      auth_api.dart          # 登录模块
      user_api.dart          # 用户模块
      room_api.dart          # 房间模块
      ...
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容