做鸿蒙应用开发,只要业务里有登录态,就一定会碰到网络请求的三大难题:所有请求怎么统一带上鉴权信息、接口报错怎么统一处理、Token 失效了怎么让用户无感续期。我在开发鸿蒙应用时,直接把网络层基于 Axios 做了一套完整封装,把登录态自动维护、Token 静默刷新、失败请求自动重放全部收敛到拦截器里,业务代码只负责调用和拿结果。这篇东西把我踩过的坑和最终沉淀下来的方案完整记下来,希望能帮到正在搭鸿蒙网络层的开发者。如果你正准备给鸿蒙应用写 HTTP 客户端,或者正被“用户操作到一半突然弹回登录页”的问题折磨,这篇文章应该能给你一套可以直接抄作业的解决方案。
注意:文中代码基于 ArkTS 和 @ohos/axios 编写,不同 SDK 版本的 API 可能有细微差异,但核心设计思路通用。
1. 为什么在鸿蒙上封装网络层,我首选 Axios
1.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.http | @ohos/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.get<string>('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: Promise<string> | null = null; let pendingQueue: Array<{ resolve: (value: unknown) => void; reject: (reason?: unknown) => void; config: InternalAxiosRequestConfig; }> = [];刷新函数必须做防重入处理。如果refreshPromise已经存在,说明已经有刷新任务在进行中,直接复用同一个 Promise,而不是再开一个新的。
async function refreshToken(): Promise<string> { 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 get<T>(url: string, params?: Record<string, unknown>): Promise<T> { return http.get(url, { params }); } static post<T>(url: string, data?: unknown): Promise<T> { return http.post(url, data); } static upload<T>(url: string, formData: FormData): Promise<T> { return http.post(url, formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000, }); } }业务方调用就非常简单了,一行请求,然后 await 拿结果。Token 刷新、失败重放全都由拦截器兜底,页面代码完全无感。
const userInfo = await HttpManager.get<UserInfo>('/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,原因五花八门,但客户端能做的就是状态码分级,把“认证失败”和“权限不足”分开处理。
| 状态码 | 含义 | 客户端策略 |
|---|---|---|
| 401 | Token 无效/过期 | 自动刷新并重放,最多重试一次 |
| 403 | 无权限 | 不刷新,提示权限不足 |
| 404 | 接口不存在 | 提示资源不存在 |
| 408 | 请求超时 | 允许用户手动重试 |
| 429 | 请求过于频繁 | 退避后重试 |
| 5xx | 服务端异常 | 统一提示服务异常 |
4.6 怎么验证封装是否可靠
封装写完了,要验证并发控制是否真的有效。我推荐一个简单的自测方法:写一个测试页面,进入时同时发 5 个需要鉴权的请求,然后把本地存储里的 Access Token 改成乱码,再次进入页面。观察日志,刷新接口是否只被调用了一次,5 个请求是否全部成功返回。
如果刷新接口被调用 5 次,说明并发控制有 bug;如果 5 个请求大部分失败,说明队列排队逻辑有问题。这两种现象都是判定封装可靠性的核心指标。我还建议把刷新成功的日志打上 Token 前三位,方便排障时快速确认新 Token 是否真的换成功了。
5. 我在几次迭代中沉淀下来的经验
最后说几个容易被忽略的细节。Token 存储一定要选对方案,Preferences 适合存小字符串,但别拿来当业务缓存用。日志里不要打印完整 Token,打印前三位和后三位就够定位问题了,否则你随手截图发到群里,等于把登录态交给了别人。刷新失败后的退出登录流程要做得清晰,不要弹一个“请求失败”的通用提示,用户会以为是自己网络不好,最好跳转一个专门的登录失效页面,说明原因。
还有一点是关于“重放安全”。每次重放请求前,除了换新 Token,最好重新检查一下请求体还是不是完整的。我在调试时遇到过 config.data 被异步改写的问题,重放出去的表单缺了字段,排查半天才发现是对象引用被污染了。建议重放前对 config 做一次浅拷贝,隔离外部修改。
这套封装在我的鸿蒙应用里已经稳定跑了几个版本,最大的收益是登录态维护从每个页面抽离到了网络层,页面上再也没出现过零散的“请重新登录”弹窗逻辑。如果你也在做鸿蒙网络请求层,建议先按这套思路搭一个最小版本,跑通并发刷新的自测用例,再逐步加上埋点、网络监听这些附加能力。网络层这种基础设施,值得多花点时间打磨。