
做鸿蒙应用开发只要业务里有登录态就一定会碰到网络请求的三大难题所有请求怎么统一带上鉴权信息、接口报错怎么统一处理、Token 失效了怎么让用户无感续期。我在开发鸿蒙应用时直接把网络层基于 Axios 做了一套完整封装把登录态自动维护、Token 静默刷新、失败请求自动重放全部收敛到拦截器里业务代码只负责调用和拿结果。这篇东西把我踩过的坑和最终沉淀下来的方案完整记下来希望能帮到正在搭鸿蒙网络层的开发者。如果你正准备给鸿蒙应用写 HTTP 客户端或者正被“用户操作到一半突然弹回登录页”的问题折磨这篇文章应该能给你一套可以直接抄作业的解决方案。注意文中代码基于 ArkTS 和 ohos/axios 编写不同 SDK 版本的 API 可能有细微差异但核心设计思路通用。1. 为什么在鸿蒙上封装网络层我首选 Axios1.1 原生模块能力不弱但业务层用起来太累鸿蒙系统自带 ohos.net.http 原生模块能力并不差能发 GET/POST可以设置 Header、超时、Cookie甚至支持流式上传下载。但问题在于它的编程风格偏向回调跟 Web 端、Android 端熟悉的 Promise 风格差距很大。你要是在中大型项目里直接用它很快会发现登录态注入、错误上报、统一 loading、业务提示这些横切逻辑没地方放只能散落在各个页面的业务代码里。我见过不少鸿蒙项目每个页面请求接口前都先手动拼 Header失败后在 catch 里弹 toast。这样写不是不能用只是代码会越来越难维护。尤其是当后端决定把 Token 从“长期有效”改成“短期过期 刷新”策略时所有页面都要动一遍这种滋味谁经历谁知道。1.2 Axios 鸿蒙版到底比原生强在哪后来我切换到了 ohos/axios它是 axios 在鸿蒙上的适配版本API 风格跟 Web 端 axios 对齐团队迁移成本极低。核心价值是两件事Promise 风格和拦截器。先看 Promise 风格配合 async/await 之后代码是顺序的、可读的调试时也容易找问题。再看拦截器它把“在请求发出前统一做什么”和“在响应回来后统一做什么”这两件事抽离出来这正是网络层封装的基石。没有拦截器Token 自动注入和 401 自动刷新都无从谈起。对比项ohos.net.httpohos/axios编程风格回调Promise / async-await请求/响应拦截器无有超时控制有有请求取消支持但繁琐支持 AbortController上传进度回调需自行处理有 onUploadProgress团队熟悉度较低高1.3 二次封装不只是包一层还要定义“请求边界”很多人理解的二次封装就是包一层函数比如request.get(url)然后内部调用 axios。这只是起步。真正的封装要做四件事统一 baseURL 和超时时间、统一 Content-Type、自动注入 Token、统一处理 401 与业务错误码。边界划清楚了业务代码只关心两个状态——成功拿到数据、失败提示用户。鉴权、刷新、重放这些脏活全部下沉。封装完成后页面里再也不应该出现“判断 error.code 是不是 401”“手动调 refreshToken”之类的代码。如果你发现某个页面还在处理这些说明封装的边界不对复杂度又漏出去了。2. 自动刷新 Token核心设计思路与难点2.1 Access Token Refresh Token 是怎么配合的现在大部分业务系统都用 JWT 做登录态。JWT 本身是一段带签名的 JSON包含用户信息和过期时间。出于安全考虑Access Token 有效期很短常见的从 15 分钟到 2 小时不等Refresh Token 有效期较长可能是一周甚至一个月。Access Token 用来正常访问业务接口Refresh Token 只用来在 Access Token 过期后换新的。这个机制你可以理解成“工牌”和“续签申请单”的关系。工牌挂在胸前丢了影响范围有限续签申请单才是长期凭证一般不轻易拿出来。两者分离即使业务 Token 泄露攻击者能操作的时间窗口也短。理解了这套机制你就知道客户端的核心任务不是“延长 Access Token 有效期”而是“在它失效后用 Refresh Token 快速换一个新的让用户无感”。2.2 主动刷新和被动刷新我为什么选被动市面上常见的刷新方案有两种。主动刷新是前端读取 JWT 中的 exp 字段在过期前几秒主动调用刷新接口。听起来很美好但落地时问题很多本地时间不准会导致提前或延后刷新服务端因为账号被踢、权限变更等原因提前作废 Token 时前端根本不知道。我见过团队按这个思路做结果就是“明明刚刷新过下个请求还是 401”最后还是绕回被动刷新。被动刷新是请求发出后收到 401客户端先不把错误抛给业务而是拦截下来调用刷新接口拿到新 Token 后把原来的请求重放一次。这种方式不依赖本地时间一切以服务端响应为准逻辑最贴近真实情况。我最终采用的就是它。方案优 势劣 势适用场景主动刷新用户体感好请求命中率高依赖本地时间服务端作废情况处理不了内部系统、Token 有效期较长被动刷新后端驱动逻辑可靠用户首个请求会多一次 401 交互大多数对外业务系统混合模式启动时静默校验过期前续签实现复杂性价比一般对体验要求极高的 C 端应用2.3 最难啃的骨头并发请求只允许一次刷新被动刷新的难点不在“刷新”本身而在并发控制。假设用户打开一个页面同时发出 5 个请求Access Token 恰好失效。如果代码写得不够严谨这 5 个请求会各自触发一次刷新刷新接口被并发调用 5 次。服务端压力大只是其中一个问题更危险的是很多后端会把旧的 Refresh Token 标记为“已使用”第一次刷新成功后后面四次刷新全部失败最终这 5 个请求还是会一起失败。正确的姿势是并发期间只允许存在一个刷新任务其余请求进入等待队列。刷新成功后队列里的请求统一拿到新 Token 并重放。这个设计我用一个“单例 Promise 等待队列”来实现后面第 3 章会给出完整代码照抄基本能跑。3. 完整实现一个支持 Token 自动续签的 HTTP 客户端3.1 初始化实例时就要定好这些规矩先用 ohpm 安装依赖ohpm install ohos/axios。然后创建实例baseURL 我建议不要写死在代码里而是从配置模块读取方便以后分割测试环境、生产环境。import axios from ohos/axios; import { BusinessError } from kit.BasicServicesKit; const http axios.create({ baseURL: https://api.example.com, timeout: 15000, });超时时间 15 秒是个比较均衡的值。太短了弱网环境下很容易误杀太长了用户会一直盯着转圈。这里有个经验常规 JSON 接口用全局默认超时文件上传接口单独放宽到 60 秒千万不要一个配置走天下。还需要考虑环境切换。我在项目里习惯维护一个config.ts里面按构建模式区分 dev、test、prod 三个 baseURL用 DevEco Studio 的构建参数动态切换。这样联调和上线都不用手动改代码。3.2 请求拦截器里只做一件事请求拦截器的职责很单一有 Token 就加到 Authorization 头。Token 的存储我推荐用 Preferences它是鸿蒙提供键值对持久化方案适合存这种小字符串。function getAccessToken(): string { return AppStorage.getstring(accessToken) ?? ; } http.interceptors.request.use((config) { const token getAccessToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; });有一个细节容易踩坑不要在请求拦截器里判断 Token 是否过期。本地判断不可靠时钟偏差、服务端主动作废都可能导致误判而且判断逻辑写多了还会拖慢请求发起速度。是否过期让服务端用 401 告诉我们最准确。3.3 响应拦截器才是整套方案的心脏响应拦截器要处理的核心逻辑是收到 401 → 判断是否已有刷新任务 → 没有则发起刷新有则排队 → 刷新成功后重放所有等待请求 → 刷新失败则清空队列并退出登录。先定义两个全局变量一个用来缓存刷新任务的 Promise一个用来装等待重放的请求队列。let refreshPromise: Promisestring | null null; let pendingQueue: Array{ resolve: (value: unknown) void; reject: (reason?: unknown) void; config: InternalAxiosRequestConfig; } [];刷新函数必须做防重入处理。如果refreshPromise已经存在说明已经有刷新任务在进行中直接复用同一个 Promise而不是再开一个新的。async function refreshToken(): Promisestring { if (refreshPromise) { return refreshPromise; } const oldRefreshToken getRefreshToken(); refreshPromise http.post(/auth/refresh, { refreshToken: oldRefreshToken, }).then((res: any) { const newAccessToken res.data.accessToken; const newRefreshToken res.data.refreshToken; setAccessToken(newAccessToken); setRefreshToken(newRefreshToken); return newAccessToken; }).finally(() { refreshPromise null; }); return refreshPromise; }刷新成功后要统一处理等待队列里的请求给它们换上新的 Token 再重新发送。function flushQueue(newToken: string) { pendingQueue.forEach(({ resolve, reject, config }) { config.headers.Authorization Bearer ${newToken}; resolve(http(config)); }); pendingQueue []; }然后是响应拦截器本体。这里有三条分支逻辑要理清楚。http.interceptors.response.use( (response) response.data, async (error: BusinessError) { const status error.code; const config error.config; // 1. 非 401 错误直接抛出 if (status ! 401 || !config) { return Promise.reject(error); } // 2. 防止刷新接口自身返回 401 后进入死循环 if (config.url?.includes(/auth/refresh)) { logout(); return Promise.reject(error); } // 3. 重放过一次仍然 401说明新 Token 也有问题退出登录 if ((config as any)._retry) { logout(); return Promise.reject(error); } try { if (refreshPromise) { // 已有刷新任务当前请求进入等待队列 return new Promise((resolve, reject) { pendingQueue.push({ resolve: (newToken: unknown) { config.headers.Authorization Bearer ${newToken as string}; resolve(http(config)); }, reject, config, }); }); } // 没有刷新任务当前请求负责发起刷新 const newToken await refreshToken(); (config as any)._retry true; flushQueue(newToken); config.headers.Authorization Bearer ${newToken}; return http(config); } catch (refreshError) { // 刷新失败清空队列并退出登录 pendingQueue.forEach(({ reject }) reject(refreshError)); pendingQueue []; logout(); return Promise.reject(refreshError); } }, );这段代码着重解释三点。一是_retry标记我用它来判断这个请求是否已经重放过一次第二次还是 401 就直接退出登录避免两个 Token 都失效后无限循环。二是refreshPromise在 finally 里置空这个很重要否则刷新成功后 Promise 会被缓存后续所有请求都拿到旧的 Promise 结果。三是排队请求的reject也要被正确触发整个页面才不会出现“请求永远挂起”的白屏问题。3.4 文件上传、表单提交和请求取消接口封装不能只看 JSON。头像上传这类场景要支持 multipart/form-data在 ohos/axios 里这样写const formData new FormData(); formData.append(file, { uri: file://com.example.app/files/pic/avatar.png, name: avatar.png, type: image/png, }); const res await http.post(/upload, formData, { headers: { Content-Type: multipart/form-data }, timeout: 60000, // 上传单独放宽超时 onUploadProgress: (progress) { console.log(upload: ${progress.loaded}/${progress.total}); }, });普通表单提交也常见很多登录接口要求application/x-www-form-urlencoded。可以封装一个postForm方法把对象转成 URLSearchParams并自动设置 Content-Type业务侧就不用每次手动处理了。请求取消同样要支持。ohos/axios 支持 AbortController页面销毁时要能中断还没返回的请求避免回调操作已经销毁的页面对象导致报错。const controller new AbortController(); http.get(/long-task, { signal: controller.signal }); // 页面 onPageHide 或 onPageDestroy 时调用 controller.abort();3.5 封装成 HttpManager 后的调用方式把上面的逻辑组合成一个类对外暴露 get、post、upload 等方法。export class HttpManager { static getT(url: string, params?: Recordstring, unknown): PromiseT { return http.get(url, { params }); } static postT(url: string, data?: unknown): PromiseT { return http.post(url, data); } static uploadT(url: string, formData: FormData): PromiseT { return http.post(url, formData, { headers: { Content-Type: multipart/form-data }, timeout: 60000, }); } }业务方调用就非常简单了一行请求然后 await 拿结果。Token 刷新、失败重放全都由拦截器兜底页面代码完全无感。const userInfo await HttpManager.getUserInfo(/user/info);这就是封装的价值把复杂度关在笼子里让业务代码尽量干净。4. 实战排坑这些坑我替你们踩过了4.1 刷新接口自己返回 401差点把服务端打爆这是我第一次实现时犯的错误。刷新接口也挂在同一个客户端实例上请求拦截器自动把过期的 Access Token 塞进了 Authorization 头。服务端一看旧 Token 无效返回 401响应拦截器又触发新一轮刷新新一轮刷新又被拦截器注入了 Token……无限套娃日志里刷新接口被反复调用。解决方法是加一个判断当config.url命中/auth/refresh时直接抛出错误不走刷新逻辑。也可以用更彻底的方案给刷新接口单独开一个干净的 axios 实例不挂任何业务拦截器。两种方案二选一第二种更安全但代码多一点我用的第一种写起来简单收益足够。4.2 重放请求导致订单重复提交怎么办重放机制本身有个隐患如果某个 POST 请求在 Token 失效时发出拦截器拿到新 Token 后会重新发送一次服务端可能执行两次。用户端的表现就是点了一下“提交订单”结果后台生成两笔订单。这个问题客户端只能缓解不能根除。我的做法是双管齐下前端对提交类按钮做点击防抖防止用户手抖连点同时要求后端对关键写接口做幂等校验客户端在请求 Header 里带一个Idempotency-Key值是由时间戳和 UUID 拼出来的指纹后端收到相同指纹直接返回第一次的处理结果。特别是支付、下单、转账这类接口幂等校验是必须的否则重放机制就像一把双刃剑。4.3 Charles 抓不到鸿蒙的 HTTPS 包鸿蒙开发调试时很多人想用 Charles 抓包看接口报文结果发现只看到 CONNECT 请求看不到实际内容。原因基本是系统不信任 Charles 的 CA 证书或者应用没有开启对用户证书的信任。解决思路分三步第一步把 Charles 的证书导出并安装到鸿蒙系统的“系统信任凭据”中注意不是“用户凭据”是系统凭据这个区别很关键第二步在应用配置里为调试模式开启网络安全信任允许 HTTPS 明文调试第三步检查 Charles 的 SSL Proxying 设置确认你要调试的域名在白名单里。这套配置只应该在 debug 包上开启生产包保持严格配置不然等于给攻击者留后门。4.4 别在本地解析 JWT时间同步问题很坑有些方案会在客户端解析 JWT 里的 exp 字段提前判断 Token 是否过期。这听起来高端实际坑很多。手机本地时间不准会导致误判用户改了时区也可能出问题。更麻烦的是服务端签发 JWT 时会带 iat签发时间如果本地时间和服务器时间差太多请求发过去会被服务端判定为“非法的失效时间窗口”直接拒绝。所以我的建议很坚决客户端不解析 JWT不在本地判断过期把这件事完全交给服务端。服务端返回 401客户端才动手刷新。这样逻辑单一不容易出边界问题还把“提前判断”的代码从客户端彻底删掉了。4.5 状态码分级401 刷新403 别刷我见过一些团队把 401 和 403 混在一起处理响应拦截器里只要状态码不是 2xx 就统一触发刷新。这是不对的。401 表示“身份无效”可以刷新重试403 表示“身份有效但没有权限”比如普通用户访问了会员接口、学生账号访问了教师接口。这时候刷新多少次都没有用还会产生大量无效流量。我的处理策略是401 触发自动刷新403 直接进入业务错误分支提示无权限。另外对接外部认证平台时还可能遇到token exchange failed返回 403原因五花八门但客户端能做的就是状态码分级把“认证失败”和“权限不足”分开处理。状态码含义客户端策略401Token 无效/过期自动刷新并重放最多重试一次403无权限不刷新提示权限不足404接口不存在提示资源不存在408请求超时允许用户手动重试429请求过于频繁退避后重试5xx服务端异常统一提示服务异常4.6 怎么验证封装是否可靠封装写完了要验证并发控制是否真的有效。我推荐一个简单的自测方法写一个测试页面进入时同时发 5 个需要鉴权的请求然后把本地存储里的 Access Token 改成乱码再次进入页面。观察日志刷新接口是否只被调用了一次5 个请求是否全部成功返回。如果刷新接口被调用 5 次说明并发控制有 bug如果 5 个请求大部分失败说明队列排队逻辑有问题。这两种现象都是判定封装可靠性的核心指标。我还建议把刷新成功的日志打上 Token 前三位方便排障时快速确认新 Token 是否真的换成功了。5. 我在几次迭代中沉淀下来的经验最后说几个容易被忽略的细节。Token 存储一定要选对方案Preferences 适合存小字符串但别拿来当业务缓存用。日志里不要打印完整 Token打印前三位和后三位就够定位问题了否则你随手截图发到群里等于把登录态交给了别人。刷新失败后的退出登录流程要做得清晰不要弹一个“请求失败”的通用提示用户会以为是自己网络不好最好跳转一个专门的登录失效页面说明原因。还有一点是关于“重放安全”。每次重放请求前除了换新 Token最好重新检查一下请求体还是不是完整的。我在调试时遇到过 config.data 被异步改写的问题重放出去的表单缺了字段排查半天才发现是对象引用被污染了。建议重放前对 config 做一次浅拷贝隔离外部修改。这套封装在我的鸿蒙应用里已经稳定跑了几个版本最大的收益是登录态维护从每个页面抽离到了网络层页面上再也没出现过零散的“请重新登录”弹窗逻辑。如果你也在做鸿蒙网络请求层建议先按这套思路搭一个最小版本跑通并发刷新的自测用例再逐步加上埋点、网络监听这些附加能力。网络层这种基础设施值得多花点时间打磨。