AtomGit Flutter鸿蒙客户端:API客户端与网络层
架构设计
网络层是客户端应用最关键的底层基础设施。它的设计目标有三个:封装 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 获取finalapiClient=context.read<AtomGitApiClient>();通过 Provider 注入的好处是可以轻松替换为 Mock 实现进行测试。生产代码使用真实的 HTTP 客户端,测试代码可以注入返回固定数据的 Mock。
核心属性
classAtomGitApiClient{finalhttp.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(Stringpath,{Map<String,String>?queryParams,})async{finaluri=_buildUri(path,queryParams);finalresponse=await_httpClient.get(uri,headers:_headers);return_processResponse(response);}POST 请求
Future<ApiResponse>post(Stringpath,{Map<String,dynamic>?body,})async{finaluri=_buildUri(path);finalresponse=await_httpClient.post(uri,headers:_headers,body:body!=null?jsonEncode(body):null,);return_processResponse(response);}URI 构建
Uri_buildUri(Stringpath,Map<String,String>?queryParams){finalbaseUrl=ApiConstants.baseUrl;// https://api.atomgit.com/api/v5finaluri=Uri.parse('$baseUrl$path');if(queryParams!=null&&queryParams.isNotEmpty){returnuri.replace(queryParameters:queryParams);}returnuri;}使用Uri.parse+replace(queryParameters:)而非手动拼接字符串。Uri类自动处理特殊字符的编码,避免手动Uri.encodeComponent的遗漏。
响应处理
信封解包
AtomGit API 使用统一信封格式包裹响应数据:
{"data":{"id":1,"name":"example"},"code":200,"message":"success"}_unwrapEnvelope方法自动识别并解包:
dynamic_unwrapEnvelope(dynamicbody){if(bodyisMap<String,dynamic>){if(body.containsKey('data')&&(body.containsKey('code')||body.containsKey('message'))){returnbody['data'];}}returnbody;// 不是信封格式,原样返回}解包条件:body 是 Map、包含data键、包含code或message键。三个条件都满足时,提取data字段内容。否则原样返回。
注意判断逻辑的演变。早期版本要求data、code、message三个键全部存在才解包。但 AtomGit API 的实际返回中,code和message不一定同时出现。放宽条件为code或message任一存在即可,提高了兼容性。
完整响应处理流程
ApiResponse_processResponse(http.Responseresponse){_updateRateLimit(response);if(response.statusCode>=200&&response.statusCode<300){finalbody=_tryParseBody(response.body);returnApiResponse(data:_unwrapEnvelope(body),statusCode:response.statusCode,rateLimitRemaining:rateLimitRemaining,rateLimitReset:rateLimitReset,);}throw_mapError(response);}成功(2xx)→ 解析 body → 解包信封 → 返回 ApiResponse。
失败(4xx/5xx)→ 映射为类型化 ApiException → 抛出异常。
JSON 解析防护
dynamic_tryParseBody(Stringbody){try{returnjsonDecode(body);}catch(_){returnbody;// 解析失败时返回原始字符串}}如果服务器返回的不是合法 JSON(例如 HTML 错误页面),jsonDecode会抛异常。_tryParseBody捕获异常并返回原始字符串,避免整个请求因解析失败而崩溃。
频率限制追踪
void_updateRateLimit(http.Responseresponse){finalremaining=response.headers['x-ratelimit-remaining'];finalreset=response.headers['x-ratelimit-reset'];if(remaining!=null){rateLimitRemaining=int.tryParse(remaining)??rateLimitRemaining;}if(reset!=null){finalts=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.Responseresponse){finalbody=_tryParseBody(response.body);String?msg;if(bodyisMap){msg=(body['error_message']??body['message'])?.toString();}switch(response.statusCode){case401:returnApiException.unauthorized(msg??'认证失效,请重新登录');case403:if(rateLimitRemaining<=0){returnApiException.rateLimited('请求频率超限,请稍后重试');}returnApiException.forbidden(msg??'无权限访问该资源');case404:returnApiException.notFound(msg??'资源不存在');case422:returnApiException.validationError(msg??'请求参数错误');default:if(response.statusCode>=500){returnApiException.serverError(msg??'服务器错误,请稍后重试');}returnApiException.unknown(msg??'未知错误 (${response.statusCode})');}}错误消息的优先级
错误消息来源按优先级依次为:
- API 返回的
error_message:AtomGit 特定的详细错误描述 - API 返回的
message:通用错误消息 - 默认中文描述:兜底消息,确保用户始终看到可读的中文提示
error_message优先于message,因为 AtomGit 的error_message通常包含更具体的原因(如"Token 已过期"而非通用的"认证失败")。
403 的特殊处理
403 有两种可能原因,通过rateLimitRemaining区分:
- 限流触发(
rateLimitRemaining <= 0):提示"请求频率超限" - 权限不足:提示"无权限访问该资源"
这个区分很重要——如果用户被限流,提示应引导用户等待而非重复请求。
ApiException 类型体系
enumApiErrorType{unauthorized,// 401 — Token 失效或未提供forbidden,// 403 — 权限不足notFound,// 404 — 资源不存在rateLimited,// 429 — 被限流serverError,// 5xx — 服务器内部错误validationError,// 422 — 请求参数校验失败networkError,// 网络连接失败(客户端侧)unknown,// 其他未分类错误}classApiExceptionimplementsException{finalStringmessage;finalApiErrorTypetype;finalint?statusCode;constApiException({requiredthis.message,requiredthis.type,this.statusCode,});// 工厂构造函数,简化创建factoryApiException.unauthorized(Stringmsg)=>ApiException(message:msg,type:ApiErrorType.unauthorized,statusCode:401);factoryApiException.notFound(Stringmsg)=>ApiException(message:msg,type:ApiErrorType.notFound,statusCode:404);// ... 其他工厂构造函数@overrideStringtoString()=>message;}使用工厂构造函数为每种错误类型提供标准创建方式。Provider 层可以根据type做差异化处理(例如,401 错误触发自动登出,429 错误展示"等待 X 分钟后重试")。
分页加载(getAllPages)
对于需要获取全部数据的场景,封装了自动翻页方法:
Future<List<Map<String,dynamic>>>getAllPages(Stringpath,{int perPage=30,int maxPages=10,Map<String,String>?extraParams,})async{finalallItems=<Map<String,dynamic>>[];varpage=1;while(page<=maxPages){finalparams={'page':page.toString(),'per_page':perPage.toString(),...?extraParams,};finalresponse=awaitget(path,queryParams:params);if(response.dataisList){finalitems=(response.dataasList).cast<Map<String,dynamic>>();if(items.isEmpty)break;// 无更多数据,停止allItems.addAll(items);}else{break;// 非列表数据,停止(可能已经是最后一页)}page++;}returnallItems;}安全机制:
maxPages(默认 10):防止无限循环。即使 API 持续返回数据,最多请求 10 页(300 条),保护客户端和服务器items.isEmpty检查:数据为空时停止翻页response.data is List检查:响应格式异常时停止
在 Provider 中的典型使用
Provider 是 ApiClient 的主要消费者,典型的使用模式:
classSomeProviderextendsChangeNotifier{finalAtomGitApiClient_apiClient;Future<void>load()async{try{finalresponse=await_apiClient.get('/some/endpoint',queryParams:{'sort':'updated'},);// 安全提取数据finaldata=parseList<dynamic>(response.data)??[];_items=data.whereType<Map<String,dynamic>>().map(Item.fromJson).toList();}onApiExceptioncatch(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:
// 测试代码finalmockClient=MockHttpClient();finalapiClient=AtomGitApiClient(httpClient:mockClient);如果使用http.get()静态方法,无法在测试中替换为 mock,因为静态方法调用在编译器就绑定了。
