AtomGit Flutter鸿蒙客户端:API客户端与网络层 架构设计网络层是客户端应用最关键的底层基础设施。它的设计目标有三个封装 HTTP 通信细节使上层代码简洁、处理 API 特有的响应格式信封解包、错误码映射、追踪频率限制信息供 UI 展示和限流预警。项目由三个核心类组成类职责所在文件AtomGitApiClientHTTP 请求封装、Header 管理、响应处理、分页加载core/network/api_client.dartApiResponse标准化响应模型data rate limit 信息core/network/api_client.dartApiException类型化异常含错误类型枚举和中文消息core/network/api_client.dartAtomGitApiClient 核心设计依赖注入ApiClient 通过 Provider 注入而非直接 new 或单例// app.dart 中的全局注入ProviderAtomGitApiClient(create:(_)AtomGitApiClient(),)// 各页面通过 context.read 获取finalapiClientcontext.readAtomGitApiClient();通过 Provider 注入的好处是可以轻松替换为 Mock 实现进行测试。生产代码使用真实的 HTTP 客户端测试代码可以注入返回固定数据的 Mock。核心属性classAtomGitApiClient{finalhttp.Client_httpClient;String?_accessToken;int rateLimitRemainingApiConstants.rateLimitUnauthenticated;DateTime?rateLimitReset;AtomGitApiClient({http.Client?httpClient}):_httpClienthttpClient??http.Client();}_httpClient支持可选注入——生产环境使用http.Client()测试环境可以注入自定义 Client。rateLimitRemaining默认值设为未认证用户的限制60 次/小时登录后会逐步更新为认证用户的配额5000 次/小时。Header 构建MapString,Stringget_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-21AtomGit 特定的 API 版本标识。这是访问 AtomGit API 的必需 Header不携带会返回错误。版本号是日期格式表示该版本 API 规范的发布日期。Authorization: Bearer token条件性 Header。if (_accessToken ! null)是 Dart 的集合条件语法——仅在 Token 非空时添加。未登录状态下的请求不携带此 Header只能访问公开端点。GET 请求FutureApiResponseget(Stringpath,{MapString,String?queryParams,})async{finaluri_buildUri(path,queryParams);finalresponseawait_httpClient.get(uri,headers:_headers);return_processResponse(response);}POST 请求FutureApiResponsepost(Stringpath,{MapString,dynamic?body,})async{finaluri_buildUri(path);finalresponseawait_httpClient.post(uri,headers:_headers,body:body!null?jsonEncode(body):null,);return_processResponse(response);}URI 构建Uri_buildUri(Stringpath,MapString,String?queryParams){finalbaseUrlApiConstants.baseUrl;// https://api.atomgit.com/api/v5finaluriUri.parse($baseUrl$path);if(queryParams!nullqueryParams.isNotEmpty){returnuri.replace(queryParameters:queryParams);}returnuri;}使用Uri.parsereplace(queryParameters:)而非手动拼接字符串。Uri类自动处理特殊字符的编码避免手动Uri.encodeComponent的遗漏。响应处理信封解包AtomGit API 使用统一信封格式包裹响应数据{data:{id:1,name:example},code:200,message:success}_unwrapEnvelope方法自动识别并解包dynamic_unwrapEnvelope(dynamicbody){if(bodyisMapString,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.statusCode200response.statusCode300){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){finalremainingresponse.headers[x-ratelimit-remaining];finalresetresponse.headers[x-ratelimit-reset];if(remaining!null){rateLimitRemainingint.tryParse(remaining)??rateLimitRemaining;}if(reset!null){finaltsint.tryParse(reset);if(ts!null){rateLimitResetDateTime.fromMillisecondsSinceEpoch(ts*1000);}}}每次 API 响应后自动更新限流状态。rateLimitRemaining可在设置页面展示给用户也可用于内部限流预警在达到阈值前主动降低请求频率。AtomGit 的限流机制未认证用户60 次/小时基于 IP认证用户5000 次/小时基于 Token超限后 API 返回 403x-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(rateLimitRemaining0){returnApiException.rateLimited(请求频率超限请稍后重试);}returnApiException.forbidden(msg??无权限访问该资源);case404:returnApiException.notFound(msg??资源不存在);case422:returnApiException.validationError(msg??请求参数错误);default:if(response.statusCode500){returnApiException.serverError(msg??服务器错误请稍后重试);}returnApiException.unknown(msg??未知错误 (${response.statusCode}));}}错误消息的优先级错误消息来源按优先级依次为API 返回的error_messageAtomGit 特定的详细错误描述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对于需要获取全部数据的场景封装了自动翻页方法FutureListMapString,dynamicgetAllPages(Stringpath,{int perPage30,int maxPages10,MapString,String?extraParams,})async{finalallItemsMapString,dynamic[];varpage1;while(pagemaxPages){finalparams{page:page.toString(),per_page:perPage.toString(),...?extraParams,};finalresponseawaitget(path,queryParams:params);if(response.dataisList){finalitems(response.dataasList).castMapString,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;Futurevoidload()async{try{finalresponseawait_apiClient.get(/some/endpoint,queryParams:{sort:updated},);// 安全提取数据finaldataparseListdynamic(response.data)??[];_itemsdata.whereTypeMapString,dynamic().map(Item.fromJson).toList();}onApiExceptioncatch(e){// API 层已翻译为用户友好的消息_errore.message;}catch(e){// 网络层未捕获的异常如 jsonDecode 失败_error加载失败请检查网络连接;}finally{_isLoadingfalse;notifyListeners();}}}为什么外层还需要 try-catchApiClient可能抛出非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 或 ChopperDio 和 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// 测试代码finalmockClientMockHttpClient();finalapiClientAtomGitApiClient(httpClient:mockClient);如果使用http.get()静态方法无法在测试中替换为 mock因为静态方法调用在编译器就绑定了。