【鸿蒙心迹】网络请求封装实战:RCP 选型、拦截器与弱网预算

前言

权限篇解决了"能不能拍、能不能录",照片和待办数据要同步到云端,网络层就从"哪里调一下请求"变成要认真设计的工程问题。这篇记录网络层从散装到收敛的全过程——选题不新鲜,但这次的感受很深:网络层的所有问题,本质都是"口径"问题。超时几种口径、错误几种口径、token 几种口径,口径一多,任何一个小故障都会被放大成玄学。

先交代环境:DevEco Studio 5.x、HarmonyOS NEXT、API 14+,网络层最终落在 RCP(Remote Communication Kit)上。行为以这个版本实测为准,具体接口以官方文档为准。

三处 http,三种脾气:债是怎么欠下来的

先看现场。同步功能第一版上线时,网络请求在三个页面里各自为政,画成图大概是这样:

三处代码都是"能跑"的,但脾气完全不同:超时一档 5 秒、一档 30 秒、一档 15 秒——没有一档是想出来的,全是抄来抄去顺手填的;失败提示一处英文、一处直接 toast 异常对象、一处干脆 console.log 完转圈不停;token 一处记得塞、一处忘了塞(测试时一直用的免登录环境,上线第一天就 401)、一处塞了但键名跟别人不一样。

最讽刺的是第三处。传配图那页是我自己写的,写的时候刚看完列表页的代码,"顺手"抄了它的超时配置,但改请求体的时候不小心把 token 的头名从 Authorization 改成了 token——后端谁也没骗到,只骗到了我自己。排查那天我在三个页面之间来回跳了半小时,最后发现要修的不是任何一处代码,而是"没有网络层"这件事本身。

这就是我后来跟同事总结的口径漂移:每个页面单独看都没错,合在一起是一套互相矛盾的系统。它不是哪个人的粗心,是结构缺失的必然结果——只要"发请求"这个动作没有被收敛到一处,漂移就会在每一个新页面继续发生。

选型:不是换工具,是换形状

动手收敛之前先选型。HarmonyOS 上两条路:基础的 @ohos.net.http 模块,和 RCP。把两边的形状摆在一起看:

http 模块的形状是单请求维度的:每个请求 createHttp 一个对象、request、自己 destroy,超时和头都是这一次性的。它没有错,一两个请求的小工具用它最快。但它的形状决定了:会话级的东西——统一超时、统一 token、统一错误翻译——在它那里没有容身之处,你只能在每个调用点重复这些逻辑,也就是继续制造口径。

RCP 的形状是会话维度的:Session 一次创建长期复用,超时、证书、头都挂在会话上,拦截器链原生支持,请求全链路有回调可挂。应用要长出第二个、第三个调用方的那一天(这一天总会来),会话形状的价值就开始兑现。

我的决策句式后来固定成一句话:只有一两个请求,http 模块最快;会长出第二个调用方,第一天就上 RCP。工具没有高下,形状要对场景。这句和持久化篇里"别把 Preferences 当数据库"是同一个意思:错误不在工具,在把它用出了它的形状之外。

把网络层立起来:一个 Session,两层拦截

选型定了,骨架一次成型,后来再没动过结构:

import { rcp } from '@kit.RemoteCommunicationKit';

export const client = rcp.createSession({
  baseAddress: 'https://api.example.com',
  headers: { 'content-type': 'application/json' },
  requestConfiguration: {
    transfer: { timeout: { connectMs: 5000, transferMs: 15000 } },
  },
});

Session 只有一个,全局单例。第一层拦截器管鉴权:每个请求出发前自动带上 Authorization 头,业务代码从此不知道"token"这个词的存在;遇到 401 顺手触发刷新重放,界面全程无感:

const authInterceptor: rcp.Interceptor = {
  intercept: async (context) => {
    context.request.headers['authorization'] = `Bearer ${tokenStore.get()}`;
    const resp = await context.next(context.request);
    if (resp.statusCode === 401) {
      await tokenStore.refresh();
      // 刷新后由重放逻辑补发,不让 401 漏到界面
    }
    return resp;
  },
};

第二层拦截器管归一化,这是整个网络层里性价比最高的三十行代码。它把两种"成功"和三种"失败"翻译成界面唯一认识的三件套 {ok, data, message}:

  • HTTP 非 2xx → ok:false,message 按种类翻译成人话:断网、超时、服务端忙;
  • HTTP 200 但业务 code≠0 → ok:false,message 取服务端提示——HTTP 200 不代表业务成功,这句话每个后端都懂,但不去收敛它,每个界面都要自己踩一遍;
  • HTTP 401 → 归鉴权层管,不漏到界面。

收敛完之后我搜了一遍全工程,statusCode 只出现在拦截器里。十几个页面的网络代码长成一个模样:if (res.ok) 用 res.data else toast(res.message)。那一刻的整洁感,是这篇文章想传递的最重要的体验。

弱网预算:超时和重试的账

网络层立住了,接下来是弱网。弱网处理说穿了就是两个预算怎么花:超时预算和重试预算。

先说超时。连接超时和传输超时必须分开,因为它们是两种故障:

连接超时管"通不通"——DNS、TCP、TLS 走完,5 秒足够,连不上就是连不上,用户该去检查网络了;传输超时管"传不传得完"——连接好好的但数据迟迟不到,15 秒封顶。两者混成一个 30 秒的大超时,等于让用户对着转圈发呆半分钟再告诉他失败,这半分钟里用户对应用的信任已经扣完了。

提示文案也要分开说。连不上就说"网络连不上",传输慢就说"传输太慢"——用户能据此做出正确的动作(换 Wi-Fi 还是稍后再试)。归一化层顺手就做了这件事,因为失败种类在拦截器里分得清清楚楚。

再说重试。重试的决策核心是幂等:

GET 拉列表,重试一百次也无害,网络层自动重试,指数退避,最多两次,对界面透明;POST 提交待办,绝不静默重发——重复提交待办是小事故,放在电商场景就是重复下单的大事故。非幂等的失败,把"重试"做成界面上一个诚实的按钮:失败态写得明明白白,按钮交给用户点,用户点了才重发。

慢半拍,但换来了两样东西:不会重复下单,用户有掌控感。和长列表篇的 cachedCount 是同一种权衡——不是越激进越好,是把用户最在乎的东西保住。

还有一类特殊预算:配图上传。大文件和 JSON 请求不是一个物种,body 大、耗时长、需要进度条。全局的 15 秒传输超时对它太苛刻,单独一个会话放宽传输超时、挂进度回调、失败可重传。上传走的是独立的会话配置,但鉴权和归一化两个拦截器照挂——口径依然只有一份。

排查"玄学慢"的一天:没有埋点的网络层是黑盒

网络层收敛之后的第一周,测试提了一个单子:详情页"有时候特别慢,转圈四五秒,但最后能出来"。有时候——这两个字是排查最大的敌人。我在自己环境里点了二十几次,一次都没复现。

这正好是我观察过的一类问题:没有埋点的网络层,每次排查都是重演历史。好在归一化拦截器给了我一个天然的挂点,那次排查顺手把耗时打点加了进去——就在 context.next 前后各记一个时间戳,请求结束把"总耗时、连接耗时、是否重试"三个数字写进本地日志:

const begin = Date.now();
const resp = await context.next(context.request);
const cost = Date.now() - begin;
logger.info('net', `${context.request.url} cost=${cost}ms status=${resp.statusCode}`);
return resp;

第二天测试又碰到了,这次有日志可查:同一个接口,大多数请求 300 毫秒出尾,偶发的慢请求 4000 多毫秒——而且慢的那几条,总耗时几乎等于连接段耗时,传输段一切正常。连接段偶发地慢,传输段永远正常,指向的不是服务端,是建连过程本身:DNS 或者 TLS。查下来是当时测试网的 DNS 对新域名偶发解析慢,切了稳定的解析配置就好了。

这件事给我的启发比问题本身多:拦截器链不只是给业务收敛口径用的,它同时是网络层唯一的观测点。在那一层打点,所有请求自动被覆盖,一个 if 都不用加。后来我固定了三个必打字段——总耗时、状态、重试次数——凑成最简陋但够用的 P95 数据,超时预算该不该调、调多少,从"感觉慢"变成了看数说话。可观测性那篇讲过"记形状不记内容",网络打点正好是它的标准应用:记 URL 和耗时,不记 body,不留隐私尾巴。

上传单独走:大文件是另一个物种

配图上传值得单开一节,因为它踩的坑和 JSON 请求完全不同类。第一版上传我偷懒复用了主会话,两个问题当场暴露:一是传输超时,15 秒对十几兆的照片太苛刻,弱网下必失败;二是没有进度,用户对着一个转圈按钮等十秒,心理上已经判了失败。

所以上传最终长成一个独立会话:传输超时放宽到 60 秒,挂进度回调把百分比画到按钮上,失败后重传整段——照片压缩到一两兆之后,整段重传的成本可以接受,断点续传在这个体量下不值得引入的复杂度。但鉴权和归一化两个拦截器照挂,token、错误口径跟主会话一模一样。会话可以分,口径不能分,这句话是这一节的全部。

进度回调还有个反直觉的细节:它回来的频率远超 UI 刷新需要,直接 setState 会把主线程打满——这在 TaskPool 那篇还会再遇到。收个尾的方案是节流:回调里只在百分比整数变化时才更新状态,一行代码,卡顿消失。

踩坑三条

  1. http 对象忘 destroy,fd 悄悄漏:散装时代留下的债。每个 httpRequest 用完要 destroy,泄漏的表现是应用用久了网络请求莫名失败,重启就好,特别像玄学。迁到 RCP 后会话统一管理,这类问题整体消失——但调试那两天重启了不下五次模拟器才对上号;
  2. 明文 HTTP 默认不通:测试环境图省事起了个裸 http 的服务,客户端怎么连都失败,报错还不直白。平台默认策略不允许明文传输,生产老老实实 HTTPS,测试环境要么上自签证书走正常配置,要么按网络安全配置文档显式放行并自担风险——别跟默认策略较劲,它是对的;
  3. 别用"跳过证书校验"图省事:自签证书的环境,正确做法是在 RCP 会话配置里挂证书。关校验等于把中间人攻击的门敞开,内网测试也一样——养成习惯比事后补救便宜得多。

收个尾

到这里,网络层从三处散装收敛成了一个单例 Session 加两层拦截:token 与错误翻译各归其位,超时按故障种类分档,重试按幂等性分流,上传单独成会话但共享同一套口径。回头看,这次改造没有引入任何"高级技术",全是组织方式的调整——网络层的质量,九成取决于口径有几份。

临发布前照例整理一份自查清单:

  • 全工程还有没有第二处直接 createHttp / createSession?
  • statusCode 还有没有出现在拦截器之外的任何地方?
  • 连接超时和传输超时是不是分开设的?文案会不会说人话?
  • POST 失败会不会被自动重试?重试按钮给用户了吗?
  • 大文件有没有单独的会话和进度反馈?

下一篇回到主线程。网络通了,图片压缩和批量同步一上来,列表立刻卡给你看——TaskPool 与 Worker 怎么分工,怎么把耗时任务搬离主线程,下篇见。

*本文环境:DevEco Studio 5.x / HarmonyOS NEXT(API 14+)。RCP 配置项与拦截器签名以官方《Remote Communication Kit》文档为准。系列上一篇:《【鸿蒙心迹】权限申请实战》,下一篇:TaskPool 与 Worker 多线程实战。*

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐