AtomGit Flutter鸿蒙客户端:API客户端与网络层
网络层是客户端应用最关键的底层基础设施。它的设计目标有三个:封装 HTTP 通信细节使上层代码简洁、处理 API 特有的响应格式(信封解包、错误码映射)、追踪频率限制信息供 UI 展示和限流预警。

架构设计
网络层是客户端应用最关键的底层基础设施。它的设计目标有三个:封装 HTTP 通信细节使上层代码简洁、处理 API 特有的响应格式(信封解包、错误码映射)、追踪频率限制信息供 UI 展示和限流预警。
项目由三个核心类组成:
| 类 | 职责 | 所在文件 |
|---|---|---|
AtomGitApiClient |
HTTP 请求封装、Header 管理、响应处理、分页加载 | core/network/api_client.dart |
ApiResponse |
标准化响应模型(data + rate limit 信息) | core/network/api_client.dart |
ApiException |
类型化异常,含错误类型枚举和中文消息 | core/network/api_client.dart |
AtomGitApiClient 核心设计
依赖注入
ApiClient 通过 Provider 注入,而非直接 new 或单例:
// app.dart 中的全局注入
Provider<AtomGitApiClient>(
create: (_) => AtomGitApiClient(),
)
// 各页面通过 context.read 获取
final apiClient = context.read<AtomGitApiClient>();
通过 Provider 注入的好处是可以轻松替换为 Mock 实现进行测试。生产代码使用真实的 HTTP 客户端,测试代码可以注入返回固定数据的 Mock。
核心属性

class AtomGitApiClient {
final http.Client _httpClient;
String? _accessToken;
int rateLimitRemaining = ApiConstants.rateLimitUnauthenticated;
DateTime? rateLimitReset;
AtomGitApiClient({http.Client? httpClient})
: _httpClient = httpClient ?? http.Client();
}
_httpClient 支持可选注入——生产环境使用 http.Client(),测试环境可以注入自定义 Client。rateLimitRemaining 默认值设为未认证用户的限制(60 次/小时),登录后会逐步更新为认证用户的配额(5000 次/小时)。
Header 构建
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Api-Version': '2023-02-21',
if (_accessToken != null)
'Authorization': 'Bearer $_accessToken',
};
四个 Header 的设计考量:
Accept: application/json:告知服务器客户端期望 JSON 格式。AtomGit API 默认返回 JSON,此 Header 是明确声明而非必需。
Content-Type: application/json:告知 POST/PUT 请求的 Body 类型。GET 请求携带此 Header 无影响。
X-Api-Version: 2023-02-21:AtomGit 特定的 API 版本标识。这是访问 AtomGit API 的必需 Header,不携带会返回错误。版本号是日期格式,表示该版本 API 规范的发布日期。
Authorization: Bearer <token>:条件性 Header。if (_accessToken != null) 是 Dart 的集合条件语法——仅在 Token 非空时添加。未登录状态下的请求不携带此 Header,只能访问公开端点。
GET 请求
Future<ApiResponse> get(
String path, {
Map<String, String>? queryParams,
}) async {
final uri = _buildUri(path, queryParams);
final response = await _httpClient.get(uri, headers: _headers);
return _processResponse(response);
}
POST 请求
Future<ApiResponse> post(
String path, {
Map<String, dynamic>? body,
}) async {
final uri = _buildUri(path);
final response = await _httpClient.post(
uri,
headers: _headers,
body: body != null ? jsonEncode(body) : null,
);
return _processResponse(response);
}
URI 构建
Uri _buildUri(String path, Map<String, String>? queryParams) {
final baseUrl = ApiConstants.baseUrl; // https://api.atomgit.com/api/v5
final uri = Uri.parse('$baseUrl$path');
if (queryParams != null && queryParams.isNotEmpty) {
return uri.replace(queryParameters: queryParams);
}
return uri;
}
使用 Uri.parse + replace(queryParameters:) 而非手动拼接字符串。Uri 类自动处理特殊字符的编码,避免手动 Uri.encodeComponent 的遗漏。
响应处理
信封解包
AtomGit API 使用统一信封格式包裹响应数据:
{
"data": { "id": 1, "name": "example" },
"code": 200,
"message": "success"
}
_unwrapEnvelope 方法自动识别并解包:
dynamic _unwrapEnvelope(dynamic body) {
if (body is Map<String, dynamic>) {
if (body.containsKey('data') &&
(body.containsKey('code') ||
body.containsKey('message'))) {
return body['data'];
}
}
return body; // 不是信封格式,原样返回
}
解包条件:body 是 Map、包含 data 键、包含 code 或 message 键。三个条件都满足时,提取 data 字段内容。否则原样返回。
注意判断逻辑的演变。早期版本要求 data、code、message 三个键全部存在才解包。但 AtomGit API 的实际返回中,code 和 message 不一定同时出现。放宽条件为 code 或 message 任一存在即可,提高了兼容性。
完整响应处理流程
ApiResponse _processResponse(http.Response response) {
_updateRateLimit(response);
if (response.statusCode >= 200 && response.statusCode < 300) {
final body = _tryParseBody(response.body);
return ApiResponse(
data: _unwrapEnvelope(body),
statusCode: response.statusCode,
rateLimitRemaining: rateLimitRemaining,
rateLimitReset: rateLimitReset,
);
}
throw _mapError(response);
}
成功(2xx)→ 解析 body → 解包信封 → 返回 ApiResponse。
失败(4xx/5xx)→ 映射为类型化 ApiException → 抛出异常。
JSON 解析防护
dynamic _tryParseBody(String body) {
try {
return jsonDecode(body);
} catch (_) {
return body; // 解析失败时返回原始字符串
}
}
如果服务器返回的不是合法 JSON(例如 HTML 错误页面),jsonDecode 会抛异常。_tryParseBody 捕获异常并返回原始字符串,避免整个请求因解析失败而崩溃。
频率限制追踪
void _updateRateLimit(http.Response response) {
final remaining =
response.headers['x-ratelimit-remaining'];
final reset =
response.headers['x-ratelimit-reset'];
if (remaining != null) {
rateLimitRemaining =
int.tryParse(remaining) ?? rateLimitRemaining;
}
if (reset != null) {
final ts = int.tryParse(reset);
if (ts != null) {
rateLimitReset =
DateTime.fromMillisecondsSinceEpoch(ts * 1000);
}
}
}
每次 API 响应后自动更新限流状态。rateLimitRemaining 可在设置页面展示给用户,也可用于内部限流预警(在达到阈值前主动降低请求频率)。
AtomGit 的限流机制:
- 未认证用户:60 次/小时(基于 IP)
- 认证用户:5000 次/小时(基于 Token)
- 超限后 API 返回 403,
x-ratelimit-remaining为 0
重置时间:x-ratelimit-reset 是一个 Unix 时间戳(秒级),表示当前限流窗口重置的时间。前端可以根据这个时间展示"X 分钟后恢复"。
错误映射
ApiException _mapError(http.Response response) {
final body = _tryParseBody(response.body);
String? msg;
if (body is Map) {
msg = (body['error_message'] ??
body['message'])
?.toString();
}
switch (response.statusCode) {
case 401:
return ApiException.unauthorized(
msg ?? '认证失效,请重新登录');
case 403:
if (rateLimitRemaining <= 0) {
return ApiException.rateLimited(
'请求频率超限,请稍后重试');
}
return ApiException.forbidden(
msg ?? '无权限访问该资源');
case 404:
return ApiException.notFound(
msg ?? '资源不存在');
case 422:
return ApiException.validationError(
msg ?? '请求参数错误');
default:
if (response.statusCode >= 500) {
return ApiException.serverError(
msg ?? '服务器错误,请稍后重试');
}
return ApiException.unknown(
msg ?? '未知错误 (${response.statusCode})');
}
}
错误消息的优先级
错误消息来源按优先级依次为:
- API 返回的
error_message:AtomGit 特定的详细错误描述 - API 返回的
message:通用错误消息 - 默认中文描述:兜底消息,确保用户始终看到可读的中文提示
error_message 优先于 message,因为 AtomGit 的 error_message 通常包含更具体的原因(如"Token 已过期"而非通用的"认证失败")。
403 的特殊处理
403 有两种可能原因,通过 rateLimitRemaining 区分:
- 限流触发(
rateLimitRemaining <= 0):提示"请求频率超限" - 权限不足:提示"无权限访问该资源"
这个区分很重要——如果用户被限流,提示应引导用户等待而非重复请求。
ApiException 类型体系
enum ApiErrorType {
unauthorized, // 401 — Token 失效或未提供
forbidden, // 403 — 权限不足
notFound, // 404 — 资源不存在
rateLimited, // 429 — 被限流
serverError, // 5xx — 服务器内部错误
validationError, // 422 — 请求参数校验失败
networkError, // 网络连接失败(客户端侧)
unknown, // 其他未分类错误
}
class ApiException implements Exception {
final String message;
final ApiErrorType type;
final int? statusCode;
const ApiException({
required this.message,
required this.type,
this.statusCode,
});
// 工厂构造函数,简化创建
factory ApiException.unauthorized(String msg) =>
ApiException(
message: msg,
type: ApiErrorType.unauthorized,
statusCode: 401);
factory ApiException.notFound(String msg) =>
ApiException(
message: msg,
type: ApiErrorType.notFound,
statusCode: 404);
// ... 其他工厂构造函数
String toString() => message;
}
使用工厂构造函数为每种错误类型提供标准创建方式。Provider 层可以根据 type 做差异化处理(例如,401 错误触发自动登出,429 错误展示"等待 X 分钟后重试")。
分页加载(getAllPages)
对于需要获取全部数据的场景,封装了自动翻页方法:
Future<List<Map<String, dynamic>>> getAllPages(
String path, {
int perPage = 30,
int maxPages = 10,
Map<String, String>? extraParams,
}) async {
final allItems = <Map<String, dynamic>>[];
var page = 1;
while (page <= maxPages) {
final params = {
'page': page.toString(),
'per_page': perPage.toString(),
...?extraParams,
};
final response = await get(path, queryParams: params);
if (response.data is List) {
final items =
(response.data as List).cast<Map<String, dynamic>>();
if (items.isEmpty) break; // 无更多数据,停止
allItems.addAll(items);
} else {
break; // 非列表数据,停止(可能已经是最后一页)
}
page++;
}
return allItems;
}
安全机制:
maxPages(默认 10):防止无限循环。即使 API 持续返回数据,最多请求 10 页(300 条),保护客户端和服务器items.isEmpty检查:数据为空时停止翻页response.data is List检查:响应格式异常时停止
在 Provider 中的典型使用
Provider 是 ApiClient 的主要消费者,典型的使用模式:
class SomeProvider extends ChangeNotifier {
final AtomGitApiClient _apiClient;
Future<void> load() async {
try {
final response = await _apiClient.get(
'/some/endpoint',
queryParams: {'sort': 'updated'},
);
// 安全提取数据
final data = parseList<dynamic>(response.data) ?? [];
_items = data
.whereType<Map<String, dynamic>>()
.map(Item.fromJson)
.toList();
} on ApiException catch (e) {
// API 层已翻译为用户友好的消息
_error = e.message;
} catch (e) {
// 网络层未捕获的异常(如 jsonDecode 失败)
_error = '加载失败,请检查网络连接';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
为什么外层还需要 try-catch?ApiClient 可能抛出非 ApiException 的异常——例如 DNS 解析失败时的 SocketException、请求超时的 TimeoutException。这些异常不是 HTTP 错误(没有 statusCode),不能被 _mapError 捕捉。外层 catch 确保这些异常也不会让应用崩溃。
请求/响应的完整链路
Provider.load()
↓
ApiClient.get('/repos/owner/name')
↓ _buildUri() — 拼接 baseUrl + path + queryParams
↓ _headers — 添加 Accept, Content-Type, X-Api-Version, Authorization
↓ _httpClient.get(uri, headers: _headers)
↓ HTTP 请求发送到 https://api.atomgit.com/api/v5/repos/owner/name
↓ DNS 解析 → TCP 连接 → TLS 握手 → 发送 HTTP 请求
↓
服务器处理
↓
HTTP 响应(状态码 + Header + Body)
↓ _updateRateLimit() — 从 Header 读取限流信息
↓ _processResponse()
↓ statusCode 2xx?
│ ├─ YES → _tryParseBody → _unwrapEnvelope → return ApiResponse
│ └─ NO → _mapError → throw ApiException
↓
Provider 收到 ApiResponse
↓ parseList / parseMap 提取数据
↓ Model.fromJson 转换
↓ notifyListeners() 通知 UI
设计决策
为什么不用 Dio 或 Chopper?
Dio 和 Chopper 是 Dart 社区流行的 HTTP 库,提供拦截器、请求重试、文件上传等高级功能。但本项目选择了最基础的 http 包,原因:
-
需求简单。应用只做 GET 和 POST,不需要文件上传、下载进度、WebSocket 等高级功能。
http包完全满足需求。 -
保持轻量。dart:http 是 Dart 内置库,零额外依赖。Dio 和 Chopper 各自有复杂的依赖树,增加应用体积和潜在兼容性问题。
-
完全控制。信封解包、错误映射、限流追踪都是 AtomGit 特有的需求。使用 Dio 的拦截器机制同样需要自定义代码,与手写的复杂度相当。
-
HarmonyOS 兼容。HarmonyOS 的 Flutter 引擎对原生网络 API 的支持最稳定,而 Dio 的底层平台通道可能在 HarmonyOS 上存在未测试的边缘情况。
为什么使用 http.Client 而非静态方法?
注入 http.Client 实例(而非使用全局静态方法)使得测试时可以注入 Mock Client:
// 测试代码
final mockClient = MockHttpClient();
final apiClient = AtomGitApiClient(httpClient: mockClient);
如果使用 http.get() 静态方法,无法在测试中替换为 mock,因为静态方法调用在编译器就绑定了。
更多推荐


所有评论(0)