在 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 是接口级别的参数处理层,负责:
-
字段映射:业务层用
userId,后端要求uid - 参数校验:确保必填字段存在,否则抛异常
- 默认值处理:某些字段有默认值
- 参数转换:将业务参数转换为后端要求的格式
- 预留扩展点:未来接口参数规则变化时,只需修改这一处
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 实现开始,逐步演进到:
- 模块拆分:按业务模块管理接口
- 泛型约束:保证类型安全,统一 Client
- 统一 baseUrl:避免重复配置
- buildParams 的作用:接口级别的参数处理层
- 公共参数管理:统一添加,支持接口级别控制
最终实现了一个既优雅又易扩展的网络层架构,既保留了 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 # 房间模块
...