YAOTU INSIGHTS

HarmonyOS网络请求封装实战:基于Rcp构建标准化Request工具类

HarmonyOS网络请求封装实战:基于Rcp构建标准化Request工具类
在 HarmonyOS 开发里网络请求这块看着简单实际上最容易写出“能跑但没法维护”的代码。早期项目直接用ohos.net.http逐页请求时超时时间、错误提示、Token 刷新这些逻辑经常被复制粘贴得七零八落。后来接触了 Rcpkit.NetworkKit中提供的远程通信协议客户端才意识到把网络请求统一封装成标准化 Request 工具类不只是省几行代码而是把整个工程的网络行为收拢到一条可控的主干上。这篇内容适合正在做 HarmonyOS 应用、想摆脱“每个页面各写一套网络逻辑”的同学也适合准备把网络层模块化、沉淀成独立 SDK 的团队。我已经把这套封装用在一个正式项目里跑了两轮版本迭代踩过不少坑下面直接聊能落地的方案和代码。1. 为什么选择 Rcp 承担底层请求能力1.1 Rcp 与旧版 Http 模块的本质差异在很多 HarmonyOS 老项目里网络请求都是基于ohos.net.http写的。http模块的定位更接近“单个请求的发起器”每次请求都要自己创建HttpRequestOptions自己管理超时连接复用的能力也比较有限。Rcp 不一样它的定位是“会话级”的网络客户端。你可以把rcp.createSession()创建的 Session 理解成 OkHttp 里那个带连接池的 Client连接复用、Cookie、TLS 配置、协议版本这些都是挂在 Session 上的而不是每次请求临时拼出来。从实际开发角度讲这个差异带来几个直接好处连接复用同一个 Session 内部会维护连接池频繁请求同一个域名时会降低握手开销。实测对一个小程序风格的列表页首屏 12 个接口串行请求Rcp 的耗时比每次新建httpRequest的方式稳定缩短 20% 到 35%。流式响应Rcp 的 Response 支持readBodyAsString、readBodyAsArrayBuffer、流式读取分块内容。对于下载大文件和实时日志这种场景不用等整个 body 落地再处理。取消任务Session 持有 Request 的生命周期可以在页面销毁时按需取消不像ohos.net.http的destroy那样粗糙。统一超时策略Session 可以设置全局连接超时和读取超时业务代码里没单独配超时的地方都会走这套默认值。1.2 标准化封装要解决的五个痛点就算底层用了 Rcp如果业务页面直接到处写rcp.createSession()后面一样会乱。我在项目里做 Request 工具类本质上不是要把 API 再包一层而是要把下面这五个问题一次性解决超时与重试逻辑分散登录接口要 10 秒超时列表接口要 15 秒这些数字散落在各个页面后期没人敢改。公共 Header 重复维护Token、设备 ID、渠道号、签名每个请求都要带漏一个后端就开始报错。错误处理口径不一致有人用try/catch有人直接抛异常有人返回null前端提示乱套。业务状态码与 HTTP 状态码混淆HTTP 200 不代表业务成功后端返回的code字段才是真正要判断的。没有统一映射页面就要重复写判断。日志缺失线上出问题根本不知道实际请求参数和返回内容本地调试靠断点非常低效。标准化 Request 工具类不是一个很大的类它更接近一套“约定”。把上面五点约定成代码里的统一逻辑业务层调用的复杂度就降下来了。1.3 设计目标与最终效果我的目标很简单业务侧写请求代码时只关心三个东西——URL、参数、回调。公共逻辑全部由工具类接管。设计完成后一个典型的登录接口调用是这样const result await AmdRequest.postUserInfo(/auth/login, { account: test, password: 123456 }); if (result.isOk()) { // 业务页面只需要处理成功分支 }调用方不需要关心 Session 怎么创建、Header 怎么拼、Token 过期怎么重试因为这些都已经在 Request 工具类内部消化了。后面所有章节都是围绕“如何让上面这段代码稳定跑起来”展开的。2. Request 工具类的整体设计2.1 模块划分与目录结构封装网络请求不是塞一个大类进去就完事我更推荐拆成几个职责单一的文件。下面是我项目里的实际目录结构你可以按团队习惯调整network/ ├── core/ │ ├── AmdRequest.ts // 对外入口静态方法 │ ├── SessionManager.ts // 管理 Rcp Session 生命周期 │ └── RequestExecutor.ts // 真正执行请求的封装 ├── model/ │ ├── RequestConfig.ts // 请求配置模型 │ ├── ResponseData.ts // 统一响应模型 │ └── BusinessException.ts // 业务异常定义 ├── interceptor/ │ ├── HeaderInterceptor.ts // 公共 Header 注入 │ ├── TokenInterceptor.ts // Token 刷新重试 │ └── LogInterceptor.ts // 日志输出 ├── utils/ │ └── UrlBuilder.ts // URL 拼接与参数处理 └── index.ts // 统一导出这样拆分的好处是加了新能力比如“全局加密签名”你只需要新增一个 Interceptor 或者改RequestConfig不用动入口类的代码回归范围能控制住。2.2 核心类职责划分AmdRequest提供get/post/put/delete静态方法内部调用RequestExecutor。这个类不关心底层协议细节是纯门面。SessionManager持有 Rcp Session 单例。所有配置项比如连接超时、读取超时、TLS 配置都在这里初始化一次。RequestExecutor接收业务参数后组合UrlBuilder、拦截器链执行 Rcp 请求并把底层异常包装成BusinessException。RequestConfig定义url、method、params、headers、timeout、retryCount、needAuth等字段。ResponseData统一封装返回结构包含code、message、data三个字段。2.3 设计原则把共识变成代码在网络层做封装我最看重的三个原则是单一职责。每个类只干一类事。日志类不用管业务 Code 判断拦截器不用管 URL 拼接。这样排查问题时你能很快定位到具体文件。开闭原则。新增一种请求能力比如“下载文件到沙箱目录”优先考虑扩展RequestConfig和新增一个执行器方法而不是去改已有的get/post逻辑。最小惊讶。调用规则应该符合直觉。比如AmdRequest.get一定走 GET请求参数自动拼到 query 上AmdRequest.post默认把对象序列化为 JSON。不要让调用方记一堆特殊情况。这套设计做完后面接新业务功能时写的代码量会明显减少而且每个页面的网络逻辑看起来都差不多新人接手也不会觉得乱。3. 核心代码实现与关键环节解析3.1 定义请求配置模型先定义RequestConfig它是所有请求参数的载体export class RequestConfig { url: string ; method: GET | POST | PUT | DELETE GET; params?: object; headers?: Recordstring, string; timeout?: number; // 单请求超时单位毫秒 retryCount?: number; // 失败重试次数 needAuth?: boolean; // 是否自动带 Token formData?: Recordstring, string | ArrayBuffer; // 表单场景使用 }这里有个容易被忽略的设计点timeout和retryCount如果为空应该走 Session 的默认值。所以我在RequestExecutor里做了默认值合并避免undefined一路穿透到 Rcp。3.2 实现通用请求方法入口类AmdRequest的代码可以写得很薄export class AmdRequest { static getT(url: string, params?: object, config?: PartialRequestConfig): PromiseResponseDataT { return RequestExecutor.executeT({ ...config, url, params, method: GET }); } static postT(url: string, params?: object, config?: PartialRequestConfig): PromiseResponseDataT { return RequestExecutor.executeT({ ...config, url, params, method: POST }); } }真正的关键逻辑在RequestExecutor.executestatic async executeT(config: RequestConfig): PromiseResponseDataT { const session SessionManager.getSession(); const mergedConfig this.mergeDefault(config); const finalUrl UrlBuilder.build(mergedConfig.url, mergedConfig.params); const headers HeaderInterceptor.process(mergedConfig); const request new rcp.Request(finalUrl, { method: this.toRcpMethod(mergedConfig.method), headers, body: mergedConfig.formData ?? JSON.stringify(mergedConfig.params ?? {}), }); const response await session.execute(request); if (response.statusCode 200 || response.statusCode 300) { throw new BusinessException(response.statusCode, HTTP 状态码异常); } const text await response.readBodyAsString(); return this.parseResponseT(text); }3.3 超时与取消机制网络请求最容易出现的问题就是“卡住”所以超时策略要做两层。第一层是 Session 级别的默认超时。在SessionManager初始化时设置const config new rcp.Configuration(); config.connectTimeout 10_000; config.readTimeout 15_000; // 如果支持 idleTimeout也可以按场景设置 SessionManager.init(config);第二层是单请求覆盖。业务接口如果确实需要更长的等待时间可以在RequestConfig.timeout里指定。但要注意超时时间不是越大越好。长超时会拖慢错误反馈用户会以为页面死了太短又容易在弱网场景误伤。我通常建议连接超时不超过 10 秒读取超时不超过 20 秒。取消请求方面我维护了一个Mapurl, rcp.Request的注册表。页面aboutToDisappear时调用AmdRequest.cancelByUrl(url)内部把对应 request cancel 掉。用 URL 做 key 比用 requestId 更直观调试定位也方便。3.4 公共 Header 注入与 Token 刷新拦截器HeaderInterceptor做的事很简单把公共参数并进去static process(config: RequestConfig): Recordstring, string { const baseHeaders: Recordstring, string { Content-Type: application/json, X-Device-Id: DeviceUtil.getDeviceId(), X-App-Version: AppUtil.getVersionName(), }; if (config.needAuth ! false) { const token StorageUtil.getToken(); if (token) { baseHeaders[Authorization] Bearer ${token}; } } return { ...baseHeaders, ...config.headers }; }Token 过期重试是网络层里比较难处理的部分。我的做法是在TokenInterceptor里做一个“状态码感知”的重试第一次请求返回 401先不把错误抛给业务层。调用刷新 Token 的接口拿到新 Token。用新 Token 重新执行原请求。如果刷新失败再抛出登录失效异常。这里有个容易写错的细节重试时必须克隆原始 Request不能复用同一个实例因为 Rcp 的 Request 一旦执行完成重复执行不会达到预期效果。所以我在RequestExecutor里单独抽了一个executeInner方法方便拦截器持有一个可以重新调用的执行函数。3.5 统一响应模型与业务错误码后端接口一般会有自己的业务码格式我这边统一约定为{ code: 0, message: success, data: {} }code为 0 表示业务成功非 0 表示业务失败。这个模型用ResponseDataT表达export class ResponseDataT { code: number 0; message: string ; data?: T; raw?: string; isOk(): boolean { return this.code 0; } }业务层全都通过result.isOk()判断而不是直接看 HTTP 状态码。这样即使后端的业务码规则调整也只改工具类里的常量不用全局搜索页面判断逻辑。3.6 日志与调试输出没有日志的网络层排查问题就像盲人摸象。我在LogInterceptor里输出三块内容请求概要时间、方法、URL、耗时。请求头去掉 Authorization 的真实值避免日志泄露 Token。响应摘要状态码、业务码、数据前 200 个字符。console.info([AmdRequest] ${DateUtil.now()} ${config.method} ${finalUrl} 耗时 ${cost}ms); console.info([AmdRequest] 响应 code${data.code} message${data.message});线上环境我建议把日志级别提到warn以上只在 debug 包里面输出完整日志。否则海量日志会拖慢真机性能。4. 实战接入与参数调优4.1 应用启动时初始化工具类不推荐在每次调用时隐式初始化最好在入口文件EntryAbility的onCreate里显式调用AmdRequest.init({ baseUrl: https://api.example.com, connectTimeout: 10_000, readTimeout: 15_000, maxConcurrent: 8, enableLog: true, });这样做的目的是让网络层从启动开始就处于已知状态。baseUrl在 init 里定好之后业务代码里只用传路径比如/user/info。这样切测试环境和生产环境的时候改一个地方就够了。4.2 对接登录接口与表单提交登录接口是典型的表单提交场景。我直接用RequestConfig.formDataconst result await AmdRequest.postLoginResult(/auth/login, { account: this.account, password: md5(this.password), });如果后端要求application/x-www-form-urlencoded在 post 方法里判断config.formData是否存在存在就把Content-Type改成对应类型body 用URLSearchParams拼接。Rcp 的 Request body 支持string和ArrayBuffer表单场景直接传字符串即可。4.3 文件上传的大坑文件上传最容易踩的坑是把整个文件读成ArrayBuffer再塞进 body小文件没事大文件会直接内存溢出。Rcp 支持流式 body我更推荐分段读取const fileBuffer await FileUtil.readAsArrayBuffer(filePath); const request new rcp.Request(fileUrl, { method: rcp.RequestMethod.POST, headers: { Content-Type: application/octet-stream }, body: fileBuffer, });如果文件实在太大必须考虑分片上传。分片上传的核心是“服务端要支持断点续传”否则你分片传过去服务端拼不起来。这块要提前和后端对齐协议别前端自己嗨。4.4 并发控制与连接池调优默认 Session 的连接池配多大取决于你的业务场景。列表类应用并发量不大4 到 8 个就够图片上传类应用需要更高的并发。但并发不是越高越好设备性能和后端承受能力都是瓶颈。我给连接池配置经验值场景建议连接数说明纯接口请求4~6页面请求足够混合上传/下载8~12流式传输占用连接时间长后台数据同步2~3避免抢占前台请求资源控制并发还可以在业务层用信号量。我在工具类里维护了一个Semaphore超过阈值时排队的请求会等待避免一瞬间把系统资源打满。4.5 DevEco Studio 真机调试要点真机调试和模拟器差异很大尤其是网络请求。我总结几个必须注意的坑真机访问局域网 IP 时需要在module.json5里配置网络权限和明文流量声明否则http://地址会被拦。抓包工具装好后设备上要安装对应证书否则 HTTPS 的包看不到内容。Rcp 走的是系统级的 TLS 配置抓包工具能不能解开证书要看它的兼容性。路由器和防火墙不要开启 AP 隔离否则设备之间互相访问不通。5. 常见问题与排查技巧实录5.1 高频错误速查表我在项目过程中把遇到的问题整理成了表格团队排查时照着看能省不少时间现象可能原因解决思路请求一直卡住直到超时Session 未初始化或连接池占满检查AmdRequest.init是否在启动时调用HTTP 200 但业务异常没有做业务码判断统一走ResponseData.isOk()Token 刷新后请求仍 401重试时复用了旧 Request克隆一份新 Request 再执行大文件上传内存暴涨整个文件读进 ArrayBuffer改流式上传或分片日志里看不到请求enableLog没开或日志级别被过滤检查 init 参数和 console 过滤级别返回数据解析失败后端 Content-Type 不是 JSON先用response.readBodyAsString()打原始文本5.2 排查日志应该怎么看网络层日志最忌讳一锅炖。我推荐按请求维度打日志每次请求生成一个递增的 requestId前后端联调时把这个 id 报给后端日志检索立刻变简单。日志节点固定在四个开始请求、收到响应、解析完成、异常抛出。每个节点带同样的 requestId用grep就能拉出一条完整的链路。5.3 几个容易踩的隐性坑第一个坑Response body 只能读一次。Rcp 的 Response 属于流式资源第一次调用readBodyAsString()之后流已经消耗完了。如果你在拦截器里先读了一次用于日志后续业务层再读就会拿到空内容。解决方法是把所有读取动作收敛到RequestExecutor里面由它决定谁先读、怎么读不让拦截器直接接触流。第二个坑不要在拦截器里吞掉异常。拦截器的职责是增强不是兜底。如果拦截器把异常 catch 住但没抛出去业务层会收到一个看似成功的空响应整个错误链路就断了。我的习惯是拦截器里只记录日志异常全部重新抛出。第三个坑子线程解析 JSON。ArkTS 的单线程模型下await session.execute()本身是异步的不会阻塞主线程。但如果你在拿回 text 之后没有把解析大对象这件事放到子线程而是直接JSON.parse一个 5 MB 的字符串仍然会造成可感知的卡顿。可以用TaskPool做解析然后再把结果传回主线程。第四个坑不要为了省事把 Session 改成多例。Session 是有状态的连接池、Cookie、TLS 都挂在 Session 上。你每次请求创建新 Session等于把连接池和复用机制全部废掉性能会明显退化。如果一定要多域名隔离一般两到三个 Session 就够按域名类型分而不是按页面分。6. 用 Rcp 做标准化的长期收益我实际体验下来把网络请求收口成标准 Request 工具类之后最大的感受是跨页面复制粘贴的代码变少了出问题时定位变快了。以前是每个页面自己 try/catch自己拼 Header现在排查一条线上问题拿到日志里的 requestId从发起请求到异常抛出整条链路都能看到效率能提升不止一倍。如果你准备在团队里推广这套封装我建议再往前走两步一是把网络配置抽成独立模块不同环境用不同配置发布时直接切换二是给 Request 工具类补单元测试重点覆盖超时重试、Token 刷新、表单序列化这几个核心分支。网络层是每个请求都要经过的地方这部分的稳定性直接决定应用口碑值得多花点精力打牢。最后留个小技巧可以在工具类里增加一个全局的“弱网模拟”开关方便测试人员在真机上验证列表页的 loading 和错误占位。一个简单的延时函数就能让所有请求在 Debug 模式下被限速这个功能在联调阶段很好用。