ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

HarmonyOS网络层封装:基于Axios的请求工具类实战指南

HarmonyOS网络层封装:基于Axios的请求工具类实战指南 我一开始其实没打算单独写这篇因为在很多ArkTS的项目里开发同学更习惯直接在页面里调ohos.net.http毕竟官方文档里的例子就这么写的。直到有一次帮同事排查线上问题翻代码时发现整个项目里有十几处重复的请求逻辑token注入、错误提示、超时处理各有各的写法光是让所有接口统一返回结构就花了一个晚上。从那以后我养成了一个习惯任何HarmonyOS项目动工之前先把Axios请求工具类搭好。这篇就把我这套封装方案完整拿出来包括完整代码、设计思路以及几个只有踩过坑才会注意到的细节。适合刚接触HarmonyOS网络层的初学者也适合已经在写但觉得现有代码不够灵活的同学参考。1. 页面手写请求代码的痛点以及我为什么选Axios而不是ohos.net.http1.1 页面级请求代码的三个常见病根先说你最熟悉的那种写法在页面里直接new一个ohos.net.http.HttpRequest然后request、on(headersReceive)、on(dataReceive)再手动拼JSON解析。小Demo没问题但项目一旦超过十个接口问题就非常明显。第一个病根是重复劳动。每个请求都要写超时设置、Header拼接、错误码判断、loading开合这段逻辑散落在各个页面改一个公共Header字段就要全局搜索替换。第二个病根是错误处理不统一。同一个“网络不可用”有的页面弹Toast有的页面静默失败有的页面直接把error.message显示给用户体验完全不可控。第三个病根是鉴权信息很难集中管理。登录Token通常存在Preferences或AppStorage里手写请求时每个调用点都要自己取、自己塞进Header写着写着就容易漏。这三点堆在一起线上定位问题会非常痛苦。你看到一个“网络请求错误”根本分不清是超时、是DNS解析失败、还是服务端返回了5xx因为日志里只有一句笼统的话。1.2 ohos.net.http 和 Axios 的直观对比ohos.net.http是HarmonyOS提供的原生HTTP能力但不代表它适合直接面向业务。原生API更偏底层它的设计目标是让你能控制请求的每个环节代价是你得自己处理很多琐碎逻辑。能力维度ohos.net.http 手写ohos/axios超时统一处理每次都得单独配置和捕获创建实例时配置一次全局生效请求/响应拦截器无需要自己包一层内置拦截器机制取消请求手动调用destroy颗粒度粗CancelToken/AbortController 支持上传下载进度通过dataReceive/receive事件手动攒数据onUploadProgress / onDownloadProgress 直接回调请求方法封装GET/POST要自己判断直接 .get/.post/.put/.delete前后端分离项目的经验迁移基本用不上Web生态经验axios用法和Web端几乎一致我并不是说原生API不好而是说在业务项目里axios帮你把“请求生命周期管理”这层已经做得足够成熟了没必要在业务代码里重复造轮子。而且HarmonyOS版Axios由社区维护跟进API版本适配做得不错实测在API 9、API 10、API 11上跑都没问题。1.3 封装完成后业务代码长什么样这是我最想让你关注的部分。封装不是炫技而是让业务代码真正瘦身。最终效果是这样const userService new UserService(); userService.getUserInfo().then((user: UserInfo) { this.userName user.name; }).catch((e: ApiException) { this.showError(e.message); });你发现没有调用方完全看不到URL拼接、Header注入、错误码翻译这些事。登录失效了拦截器统一处理跳转登录页。网络超时了工具类统一翻译成友好文案。这才是通用工具类该有的样子。2. 动手前的工程准备权限、网络安全配置与ArkTS约束2.1 安装 ohos/axios在DevEco Studio的Terminal里执行ohpm install ohos/axios如果你网络源比较慢也可以直接在oh-package.json5里添加依赖后Syncdependencies: { ohos/axios: ^2.2.10 }装完之后确认一下oh_modules目录里能看到ohos/axios再顺手看一眼它的README.md里面会标注当前版本要求的API Level。我用过的2.x版本基本兼容API 9以上但如果你的工程minCompatibleSdkVersion设得太低可能会遇到方法找不到的问题。2.2 module.json5里必须配好的两个位置INTERNET权限在entry/src/main/module.json5的requestPermissions数组里加上requestPermissions: [ { name: ohos.permission.INTERNET } ]容易踩坑的是HarmonyOS工程模板不一定默认带这条权限如果忘了加最终的报错五花八门有时是“网络无法连接”有时是“超时”不仔细看根本想不到是权限问题。明文流量配置如果你的后端接口是http://开头而不是https://在API 10及以上系统版本默认会被安全策略拦下来。这个问题的现象很诡异模拟器上可能正常真机上请求直接失败还伴随一个很笼统的网络错误。处理方法是在module.json5的module节点下加metadatametadata: [ { name: ohos.rawfile.network_security_config, resource: $profile:network_security_config } ]然后在resources/base/profile/目录下新建network_security_config.json{ network-security-config: { base-config: { cleartext-traffic-permitted: true } } }注意生产环境建议用domain-config只放行特定域名不要全量放开明文流量否则应用上架审核和网络安全测试环节会被重点关照。2.3 ArkTS严格模式给axios封装带来的三个限制HarmonyOS的ArkTS编译器对TypeScript做了严格约束这点和Web开发差异很大写封装时尤其容易碰到。第一个是不能用any。axios内部类型定义很复杂你在拦截器里处理config.headers时类型得写清楚否则编译直接报错。第二个是对象字面量必须有明确的类型。你不能写const obj {}然后动态往里加属性必须一次性定义完整结构。第三个是解构赋值受限。我在Web端写惯了const { data } response在ArkTS里得改成const data response.data。这些限制在封装网络层时其实是个好事逼着你把类型定义清晰省得后面维护的时候抓瞎。我下面给的代码都考虑到了ArkTS的约束可以直接跑。3. 通用请求工具类完整实现稳扎稳打的五块拼图3.1 先定义返回结构和错误码复用和排查的前提我习惯先把基础设施定义好。首先是一个通用响应体注意这里用了泛型ArkTS要求泛型必须有默认约束export interface ApiResponseT object { code: number; message: string; data: T; }这个接口对应你后端统一返回的JSON结构。如果你的项目没有统一结构而是直接用data字段那这里的判断逻辑要做相应调整。然后是错误码枚举。我在这套封装里规划了一套内部错误码只服务于网络层自身和业务错误码分开export enum ErrorCode { Success 0, Unknown 1000, NetworkUnavailable 1001, Timeout 1002, Canceled 1003, HttpError 1004, ParseError 1005, }为什么要单独定义一层因为axios抛出的原生错误里error.message在不同系统版本上措辞不一样直接甩给用户不友好也不利于排查。有了统一错误码上报到日志系统后可以按码聚合一眼就知道当前占比最高的是超时还是断网还是服务端5xx。3.2 创建实例单例、超时、baseURL与拦截器注册整个工具类我建议用单例模式。因为axios实例本身维护了默认配置和拦截器链没有必要在每个页面里重复创建。单例既能保证拦截器只注册一次也方便在任意位置HttpManager.getInstance()直接拿到同一个实例。import axios from ohos/axios; import { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig, AxiosError, AxiosProgressEvent } from ohos/axios; import { BusinessError } from ohos.base; import connection from ohos.net.connection; import deviceInfo from ohos.deviceInfo; export class ApiException extends Error { code: number; constructor(code: number, message: string) { super(message); this.code code; this.message message; } } export class HttpManager { private static instance: HttpManager; private client: AxiosInstance; private token: string ; private unauthorizedHandler?: () void; private readonly baseURL: string https://api.example.com; private readonly timeout: number 10000; private constructor() { this.client axios.create({ baseURL: this.baseURL, timeout: this.timeout, }); this.setupInterceptors(); } public static getInstance(): HttpManager { if (!HttpManager.instance) { HttpManager.instance new HttpManager(); } return HttpManager.instance; } public setToken(token: string): void { this.token token; } public setUnauthorizedHandler(handler: () void): void { this.unauthorizedHandler handler; } }这里注意一个细节构造函数是private的确保外部只能通过getInstance()拿实例。baseURL我直接写死在类里如果你的项目需要区分测试/正式环境建议改成从构建配置里读取不要在多个地方维护环境地址。3.3 请求拦截器token注入、设备信息与参数归一化拦截器是这一整套封装的核心价值所在。我在请求发出前统一处理三件事private setupInterceptors(): void { this.client.interceptors.request.use((config: InternalAxiosRequestConfig) { // 1. 注入登录令牌 if (this.token) { config.headers[Authorization] Bearer ${this.token}; } // 2. 注入设备信息方便服务端做日志追踪 config.headers[X-Device-Type] deviceInfo.deviceType ?? unknown; config.headers[X-App-Version] deviceInfo.displayVersion ?? 1.0.0; // 3. 给GET请求的参数做归一化去掉undefined和null值 const params config.params as Recordstring, string | number | undefined | undefined; if (params) { const cleaned: Recordstring, string | number {}; Object.keys(params).forEach((key: string) { const value params[key]; if (value ! undefined value ! null value ! ) { cleaned[key] value as string | number; } }); config.params cleaned; } return config; }); }第三点容易被忽略但实际价值很高。很多后端框架对查询参数里出现paramundefined这种字面量会直接报参数格式错误提前清洗掉能省掉不少联调扯皮。需要说明的是deviceInfo.deviceType在部分模拟器上可能返回空串用?? unknown兜底避免后端日志里出现空字段。3.4 响应拦截器业务码与HTTP状态码的双层判断响应拦截器需要处理两类问题一类是网络层错误比如断网、超时、HTTP 5xx另一类是业务层错误HTTP状态码明明是200但返回体里的code不是0代表业务处理失败。private setupInterceptors(): void { // 请求拦截器部分省略... this.client.interceptors.response.use( (response: AxiosResponse) { const body response.data as ApiResponseobject; // 业务码判断 if (body.code ! undefined body.code ! ErrorCode.Success) { return Promise.reject(new ApiException(body.code, body.message || 业务处理失败)); } return response; }, (error: AxiosError | BusinessError) { return Promise.reject(this.transformError(error)); } ); } private transformError(error: AxiosError | BusinessError): ApiException { const axiosError error as AxiosError; if (axiosError.code ECONNABORTED) { return new ApiException(ErrorCode.Timeout, 请求超时请稍后重试); } if (axiosError.code ERR_CANCELED) { return new ApiException(ErrorCode.Canceled, 请求已取消); } if (axiosError.response) { const status axiosError.response.status; if (status 401) { this.unauthorizedHandler?.(); return new ApiException(ErrorCode.HttpError, 登录状态已失效请重新登录); } return new ApiException(ErrorCode.HttpError, 服务异常(${status})); } return new ApiException(ErrorCode.NetworkUnavailable, 网络连接失败请检查网络设置); }401处理的思路值得单独说一句这里通过unauthorizedHandler回调通知上层跳转登录页而不是在工具类内部直接导航。因为工具类原则上不该依赖具体的页面路由把决策权交回业务层更干净测试的时候也方便mock。注意判断error.code ECONNABORTED时要确保系统返回的错误码确实叫这个名字。不同axios版本或不同系统版本之间可能会有差异稳妥的做法是在调试阶段先打印一次JSON.stringify(error.code)看实际值。3.5 对外开放的请求方法get/post/put/delete 的封装有了拦截器真正对外的请求方法就非常简单了。我提供四个基础方法都带泛型调用方可以直接拿到解析好的业务数据public async getT(url: string, params?: object, headers?: Recordstring, string): PromiseT { const response await this.client.getApiResponseT(url, { params: params, headers: headers, }); return response.data.data; } public async postT(url: string, data?: object | string, headers?: Recordstring, string): PromiseT { const response await this.client.postApiResponseT(url, data, { headers: headers, }); return response.data.data; } public async putT(url: string, data?: object | string, headers?: Recordstring, string): PromiseT { const response await this.client.putApiResponseT(url, data, { headers: headers, }); return response.data.data; } public async deleteT(url: string, params?: object, headers?: Recordstring, string): PromiseT { const response await this.client.deleteApiResponseT(url, { params: params, headers: headers, }); return response.data.data; }有些后端对POST表单有要求需要application/x-www-form-urlencoded而不是application/json。这里我单独提供一个封装好的方法用URLSearchParams处理public async postFormT(url: string, formData: Recordstring, string | number): PromiseT { const params new URLSearchParams(); Object.keys(formData).forEach((key: string) { params.append(key, String(formData[key])); }); const response await this.client.postApiResponseT(url, params.toString(), { headers: { Content-Type: application/x-www-form-urlencoded, }, }); return response.data.data; }这几种方法已经能覆盖绝大多数业务场景。你可能会问为什么没有单独提供request方法目的是为了约束业务层。提供的方法越少团队里其他人写代码时就越容易保持规范不会出现各种自定义代码。真遇到特殊需求可以临时在工具类里增加方法而不是开放所有自由度。3.6 工具类完整代码汇总把上面几块拼起来就是一个可以直接使用的完整HttpManager。这里我统一贴一遍方便整体查看import axios from ohos/axios; import { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig, AxiosError } from ohos/axios; import { BusinessError } from ohos.base; import connection from ohos.net.connection; import deviceInfo from ohos.deviceInfo; export interface ApiResponseT object { code: number; message: string; data: T; } export enum ErrorCode { Success 0, Unknown 1000, NetworkUnavailable 1001, Timeout 1002, Canceled 1003, HttpError 1004, ParseError 1005, } export class ApiException extends Error { code: number; constructor(code: number, message: string) { super(message); this.code code; this.message message; } } export class HttpManager { private static instance: HttpManager; private client: AxiosInstance; private token: string ; private unauthorizedHandler?: () void; private readonly baseURL: string https://api.example.com; private readonly timeout: number 10000; private constructor() { this.client axios.create({ baseURL: this.baseURL, timeout: this.timeout, }); this.setupInterceptors(); } public static getInstance(): HttpManager { if (!HttpManager.instance) { HttpManager.instance new HttpManager(); } return HttpManager.instance; } public setToken(token: string): void { this.token token; } public setUnauthorizedHandler(handler: () void): void { this.unauthorizedHandler handler; } private setupInterceptors(): void { this.client.interceptors.request.use((config: InternalAxiosRequestConfig) { if (this.token) { config.headers[Authorization] Bearer ${this.token}; } config.headers[X-Device-Type] deviceInfo.deviceType ?? unknown; config.headers[X-App-Version] deviceInfo.displayVersion ?? 1.0.0; const params config.params as Recordstring, string | number | undefined | undefined; if (params) { const cleaned: Recordstring, string | number {}; Object.keys(params).forEach((key: string) { const value params[key]; if (value ! undefined value ! null value ! ) { cleaned[key] value as string | number; } }); config.params cleaned; } return config; }); this.client.interceptors.response.use( (response: AxiosResponse) { const body response.data as ApiResponseobject; if (body.code ! undefined body.code ! ErrorCode.Success) { return Promise.reject(new ApiException(body.code, body.message || 业务处理失败)); } return response; }, (error: AxiosError | BusinessError) { return Promise.reject(this.transformError(error)); } ); } private transformError(error: AxiosError | BusinessError): ApiException { const axiosError error as AxiosError; if (axiosError.code ECONNABORTED) { return new ApiException(ErrorCode.Timeout, 请求超时请稍后重试); } if (axiosError.code ERR_CANCELED) { return new ApiException(ErrorCode.Canceled, 请求已取消); } if (axiosError.response) { const status axiosError.response.status; if (status 401) { this.unauthorizedHandler?.(); return new ApiException(ErrorCode.HttpError, 登录状态已失效请重新登录); } return new ApiException(ErrorCode.HttpError, 服务异常(${status})); } return new ApiException(ErrorCode.NetworkUnavailable, 网络连接失败请检查网络设置); } public async getT(url: string, params?: object, headers?: Recordstring, string): PromiseT { const response await this.client.getApiResponseT(url, { params: params, headers: headers, }); return response.data.data; } public async postT(url: string, data?: object | string, headers?: Recordstring, string): PromiseT { const response await this.client.postApiResponseT(url, data, { headers: headers, }); return response.data.data; } public async putT(url: string, data?: object | string, headers?: Recordstring, string): PromiseT { const response await this.client.putApiResponseT(url, data, { headers: headers, }); return response.data.data; } public async deleteT(url: string, params?: object, headers?: Recordstring, string): PromiseT { const response await this.client.deleteApiResponseT(url, { params: params, headers: headers, }); return response.data.data; } public async postFormT(url: string, formData: Recordstring, string | number): PromiseT { const params new URLSearchParams(); Object.keys(formData).forEach((key: string) { params.append(key, String(formData[key])); }); const response await this.client.postApiResponseT(url, params.toString(), { headers: { Content-Type: application/x-www-form-urlencoded, }, }); return response.data.data; } }这段代码放在common/http/HttpManager.ets里然后就可以在任意页面直接调用。4. 进阶能力超时重试、请求取消、上传下载进度4.1 网络超时之后尽量做一次可控重试移动端网络环境复杂弱网场景下一个请求超时是很正常的事。直接失败让用户重试体验很差无限重试又可能拖垮服务器。所以我的方案是只对网络类错误做重试业务错误一律不重试重试最多一次。public async getWithRetryT(url: string, params?: object): PromiseT { let lastError: ApiException | undefined; for (let attempt 0; attempt 2; attempt) { try { return await this.getT(url, params); } catch (e) { lastError e as ApiException; if (lastError.code ! ErrorCode.Timeout lastError.code ! ErrorCode.NetworkUnavailable) { throw lastError; } await this.sleep(1000); } } throw lastError; } private sleep(ms: number): Promisevoid { return new Promise((resolve: () void) { setTimeout(resolve, ms); }); }这里为什么固定两次而不是更多我实测下来如果第一次超时后重试一次还是失败那大概率是服务端问题或者网络完全不可用再试也没有意义。而且重试间隔不能太短1秒是相对平衡的值既不会让用户等太久又能避开瞬时抖动。4.2 离开页面时取消在途请求有一个隐蔽的内存泄漏场景页面发起请求后用户立刻返回上一页请求回调还在执行里面可能要操作已经销毁的组件控制台就会刷“Cannot read property of undefined”。处理思路是在页面销毁时统一取消未完成的请求。axios原生支持CancelToken用法很简单import axios from ohos/axios; const source axios.CancelToken.source(); try { const data await HttpManager.getInstance().getUserInfo(/user/info, {}, { cancelToken: source.token, }); } catch (e) { const err e as ApiException; if (err.code ErrorCode.Canceled) { // 页面已销毁静默处理即可 } } // 页面销毁时 source.cancel(page destroyed);我建议在基类页面或自定义组件的AboutToDisappear里调用取消逻辑保证所有页面统一行为。如果你的页面同时发起多个请求可以维护一个CancelTokenSource[]数组统一遍历取消private cancelSources: ArrayReturnTypetypeof axios.CancelToken.source []; protected registerCancelToken(): ReturnTypetypeof axios.CancelToken.source { const source axios.CancelToken.source(); this.cancelSources.push(source); return source; } protected cancelAllRequests(): void { this.cancelSources.forEach((source: ReturnTypetypeof axios.CancelToken.source) { source.cancel(page destroyed); }); this.cancelSources []; }4.3 文件上传/下载进度的实现细节ohos/axios的进度回调是开箱即用的但有几个细节很容易踩坑。上传文件通常用multipart/form-data在ArkTS里构造FormData时要确保类型正确public async uploadT( url: string, fileUri: string, onProgress?: (percent: number) void ): PromiseT { const formData new FormData(); // 这里根据文件路径构造文件对象不同API版本写法略有差异 // 可以直接使用 ohos/axios 支持的文件对象类型 const response await this.client.postApiResponseT(url, formData, { headers: { Content-Type: multipart/form-data, }, onUploadProgress: (event: AxiosProgressEvent) { if (onProgress event.total) { const percent Math.round((event.loaded / event.total) * 100); onProgress(percent); } }, }); return response.data.data; }进度回调里最关键的一个坑就是event.total可能为0。有些服务器不返回Content-Length或者分块传输时total拿不到除零会得到Infinity。所以我在回调里加了event.total的判断拿不到total时直接不给百分比避免UI显示异常。下载进度同理用onDownloadProgress然后结合fs模块把数据写入到沙箱路径。因为写文件逻辑比较长这里不展开核心点在于进度回调不要直接操作UI建议通过emitter或状态管理Modifier把进度数字抛给页面层。5. 常见报错排查与调试经验给后来者省点时间5.1 “上传失败:网络请求错误”到底是谁报出来的这个报错是在开发调试阶段出现频率最高的但你得先区分几个场景因为根因完全不一样。一种情况是DevEco Studio在安装HAP到真机时提示“上传失败:网络请求错误”。这个不是你的业务代码问题而是IDE和设备之间的连接通道异常。优先查看USB连接是否稳定、设备的开发者模式是否开启、hdc list targets能不能正常识别设备。如果调试的是远程设备或模拟器检查DevEco Studio的设备列表状态重新连接一次往往就好。另一种情况是运行时的业务请求失败了统一被工具类翻译成“网络请求错误”。遇到这种问题我的排查顺序是先用浏览器或Postman在电脑上直接请求同一个接口确认服务端正常再看真机能否访问公网排除设备网络问题最后再看日志里axios抛出的底层code和message。建议在dev环境用下面这段代码打印完整错误信息console.error([HttpManager] request failed, code${err.code}, message${err.message});5.2 看见[object object]时先别急着打印error本身很多同学在catch里直接console.error(error)打出来的日志是[object object]完全没法看。这是因为error对象被当作字符串拼接了ArkTS的日志组件不会自动展开对象结构。正确做法是分开打印字段或者用JSON.stringify序列化关键字段const err e as ApiException; console.error(request error, code${err.code}, message${err.message});如果是axios底层的AxiosError优先关注三个字段code、message、response?.status。code反映的是网络层问题如ECONNABORTED就是超时response.status反映的是HTTP状态码。把这两个字段记牢大部分网络问题都能快速定位。5.3 代码包大小超限与真机调试失败的关联如果你的项目比较大DevEco Studio上传HAP到真机时会提示“代码包大小超过限制”之类的错误。这其实不是网络请求问题而是安装包太大导致IDE上传超时或被系统拒绝。我遇到过最夸张的一个案例是项目里打包了三套不同架构的so库包体积直接超过2GB真机安装永远失败。处理思路有几点检查libs目录下是否包含多余的.so文件不同CPU架构按需保留开启资源混淆和压缩大图片转WebP或改放云端检查是否有调试用的日志和冗余资源被一起打包。清理之后不仅安装变快应用启动速度也有明显提升。5.4 网络抓包与弱网模拟用Profiler和本地代理配置排查网络请求问题只靠日志是不够的。DevEco Studio自带Profiler在运行调试时可以查看网络请求的时序、Header、响应体比在代码里埋点高效得多。真机上可以用代理工具抓包但要注意HarmonyOS应用默认不走系统代理需要额外处理。弱网模拟这块如果只测超时和重试逻辑我更推荐在本地起一个可控的Mock服务通过人为延迟响应来模拟弱网。你可以写一个简单的Node服务对指定接口setTimeout几秒再返回就能复现超时场景顺带验证工具类的超时重试和取消逻辑是否正常。这个方法看着土但比找信号不好的环境靠谱多了。5.5 重试引发的“幽灵请求”幂等性检查最后说一个重试机制最容易踩的坑。你封装了网络层自动重试后等于把“用户点击一次请求”变成“用户点击一次服务端可能收到两次请求”。对仅查询的GET接口无所谓但对创建订单、提交表单、上传文件的POST接口重试可能造成重复下单、重复支付。所以我的原则很简单重试只对GET这类幂等请求开启写操作默认不重试。如果你确实需要写操作重试那一定要求后端支持幂等键——客户端在Header里带一个唯一的请求ID服务端根据这个ID去重。这个约束要在接口文档阶段就约定好而不是出了问题再补救。回到封装这件事本身工具类解决的是“怎么请求”但真正决定线上稳定性的是“请求失败之后怎么办”这套策略。统一错误码、超时处理、幂等重试、请求取消这些细节配齐了网络层才算是真正稳了。我现在的项目都在用这套方案新同学接手也很快因为页面里看不到任何一段裸的axios调用所有网络行为都有迹可循。如果你也在搭类似的工具类建议把它当成一层正式的架构代码来对待值得多花点心思。
返回列表