【鸿蒙心迹】鸿蒙网络请求架构实战——@ohos.net.http 到 Axios 封装、拦截器与统一错误处理(HarmonyOS 7.x)

摘要 : 天气查询 App 上线前,我遇到一个诡异问题:真机上所有请求全部失败,Previewer 里却一切正常。排查到最后,根因既不是代码也不是网络,而是网络安全配置 ------HarmonyOS 对明文 HTTP 默认拦截。这个坑让我意识到,鸿蒙网络层的问题 80% 不在"怎么发请求",而在架构:权限、安全配置、统一封装、拦截器、错误处理。本文以天气 App 为贯穿场景,从 @ohos.net.http 原生 API 到 Axios 鸿蒙版封装,单点深挖拦截器与统一错误处理的设计,附超时/重试策略对比与 5 个真实踩坑。

适用版本: HarmonyOS NEXT 7.x / API 14+ / ohpm(2026 年稳定版)

开篇:权限配了,请求还是全部失败

"真机上所有请求都失败,但 Previewer 里好好的。"

2026 年 7 月底,天气查询 App 真机联调第一天。我信心满满地打包安装,打开 App 期待看到天气数据------结果屏幕上只有错误提示。回到 Previewer 里跑,接口正常返回。

这个"真机失败、预览器成功"的现象,我后来才知道是鸿蒙网络层的经典坑:默认网络安全配置只信任 HTTPS,明文 HTTP 请求被系统拦截。而 Previewer 走的是宿主环境的网络栈,不受这个限制。

复制代码
真机: HTTP 请求 → 被网络安全配置拦截
Previewer: HTTP 请求 → 走宿主网络栈,正常

这一个坑让我排查了 2 小时。它暴露了一个更重要的问题:网络层的设计不是"怎么发请求",而是权限、安全、封装、错误处理这一整套架构。本文就按这个思路,把鸿蒙网络请求的完整架构讲透。

一、网络权限配置(第一道坎)

1.1 为什么请求全部失败

HarmonyOS 应用请求网络,必须先在 module.json5 声明权限,否则请求直接失败:

json5 复制代码
// entry/src/main/module.json5
{
  "module": {
    "name": "entry",
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"  // 网络访问权限
      }
    ]
  }
}

坑 1:漏配 INTERNET 权限,请求静默失败

text 复制代码
现象: 请求代码看起来没问题,但 onFail 返回错误 2300006(无网络权限)
根因: 未在 module.json5 声明 ohos.permission.INTERNET

这里要说一下"静默失败"为什么如此高发:权限类错误不会在编译期暴露 ------INTERNET 权限属于 normal 级别,不弹窗、不提示,只在运行时请求发出去的那一刻以错误码 2300006 告知。而多数业务代码只处理了"成功给数据、失败给 Toast",把 onFail 里那个数字错误码原样吞掉或只打印了一行日志,用户侧看到的就是"什么都没发生"。这也是后面第 3.2 节要做统一错误映射的直接动机:把系统错误码在拦截器里翻译成人话,错误才不至于静默。

1.2 网络安全配置(HTTPS 与明文 HTTP)

HarmonyOS 默认只允许 HTTPS,明文 HTTP 会被拦截。开发期要访问本地/内网 HTTP 接口,必须配置网络安全:

json5 复制代码
// entry/src/main/resources/base/profile/network_config.json
{
  "network-security-config": {
    "base-config": {
      "cleartext-traffic-permitted": true  // 开发期允许明文 HTTP(上线必须改回 false)
    },
    "domain-config": [
      {
        "domains": [
          { "name": "api.example.com", "include-subdomains": true }
        ],
        "cleartext-traffic-permitted": false
      }
    ]
  }
}
json5 复制代码
// module.json5 中声明 networkConfig 引用
{
  "module": {
    "name": "entry",
    "metadata": [
      {
        "name": "network_security_config",
        "resource": "$profile:network_config"
      }
    ]
  }
}

安全红线 : 明文 HTTP 只用于开发调试,上线前必须关闭。生产环境一律 HTTPS,否则存在中间人攻击风险。

二、@ohos.net.http 原生 API 实战

2.1 GET 请求(原生)

typescript 复制代码
import { http } from '@kit.NetworkKit';

async function getWeather(city: string): Promise<string> {
  const httpRequest = http.createHttp();

  // 每个请求单独创建,用完销毁
  const response = await httpRequest.request(
    `https://api.example.com/weather?city=${encodeURIComponent(city)}`,
    {
      method: http.RequestMethod.GET,
      header: { 'Content-Type': 'application/json' },
      connectTimeout: 10000,   // 连接超时 10s
      readTimeout: 10000       // 读取超时 10s
    }
  );

  httpRequest.destroy();  // 必须销毁,否则连接泄漏
  return response.result as string;
}

2.2 原生 API 的痛点

用原生 API 写业务代码,会遇到 4 个问题:

痛点 表现
重复样板代码 每个请求都要创建/销毁/解析
无统一错误处理 每个请求自己 try-catch
无拦截器 Token 注入、日志、重试都要手写
连接泄漏风险 忘记 destroy() 导致连接耗尽

结论 : 原生 API 适合单次请求、快速验证;业务项目必须封装。下面用 Axios(鸿蒙适配版)封装。

三、Axios 鸿蒙版封装实战(核心)

3.1 安装 Axios(鸿蒙适配版)

bash 复制代码
# 在工程根目录执行(ohpm 安装)
ohpm install @ohos/axios

3.2 封装统一 HTTP 客户端(拦截器 + 统一错误处理)

请求进来后的完整链路是:业务调用 api.get → 请求拦截器注入 token/公共头 → 发起请求 → 按结果分流:

  1. HTTP 状态码为 2xx → 响应拦截器剥离 data 层,返回业务数据;
  2. 401 → 刷新 token 后重放原请求;
  3. 5xx 且请求幂等 → 按退避策略重试,最多 2 次;
  4. 其他错误 → 统一错误映射,转成业务错误码后 Toast/错误页提示。
typescript 复制代码
// common/http/client.ets
import axios, { AxiosInstance, AxiosResponse, AxiosError } from '@ohos/axios';
import { BusinessError } from '@kit.BasicServicesKit';

// 统一响应结构
export interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 业务错误码
export class ApiError extends Error {
  code: number;
  constructor(code: number, message: string) {
    super(message);
    this.code = code;
  }
}

class HttpClient {
  private instance: AxiosInstance;

  constructor() {
    this.instance = axios.create({
      baseURL: 'https://api.example.com',
      timeout: 15000,               // 总超时 15s
      headers: { 'Content-Type': 'application/json' }
    });

    // 请求拦截器:统一注入 Token
    this.instance.interceptors.request.use((config) => {
      const token = AppStorage.get<string>('token') ?? '';
      if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
      }
      return config;
    });

    // 响应拦截器:统一错误处理
    this.instance.interceptors.response.use(
      (response: AxiosResponse) => {
        const body = response.data as ApiResponse<unknown>;
        // 业务码非 0 视为业务错误
        if (body.code !== 0) {
          return Promise.reject(new ApiError(body.code, body.message));
        }
        return response;
      },
      (error: AxiosError) => {
        // 网络层错误统一兜底
        return Promise.reject(this.normalizeError(error));
      }
    );
  }

  // 把各种错误归一化为友好信息
  private normalizeError(error: AxiosError): ApiError {
    if (error.code === 'ECONNABORTED') {
      return new ApiError(-1, '请求超时,请检查网络');
    }
    if (!error.response) {
      return new ApiError(-2, '网络连接失败,请检查网络');
    }
    const status = error.response.status;
    if (status === 401) {
      return new ApiError(401, '登录已过期,请重新登录');
    }
    if (status === 403) {
      return new ApiError(403, '没有权限访问');
    }
    if (status >= 500) {
      return new ApiError(status, '服务器开小差了,请稍后再试');
    }
    return new ApiError(status, `请求失败(${status})`);
  }

  // 对外统一 GET
  async get<T>(url: string, params?: Record<string, string>): Promise<T> {
    const resp = await this.instance.get<ApiResponse<T>>(url, { params });
    return (resp.data as ApiResponse<T>).data;
  }

  // 对外统一 POST
  async post<T>(url: string, body: object): Promise<T> {
    const resp = await this.instance.post<ApiResponse<T>>(url, body);
    return (resp.data as ApiResponse<T>).data;
  }
}

export const httpClient = new HttpClient();

3.3 业务 API 封装(按模块拆分)

之所以再包一层 weatherApi,而不是让页面直接持有 httpClient 调 URL:URL、参数名、数据结构属于服务端契约,页面不应该感知。服务端改路径或字段时只改 api 层一处;同时天气查询是天气 App 的高频动作,api 层正好是挂接缓存与请求去重的位置。业务按模块拆 api 文件(weather/order/user),也让"这个接口谁在用"变得可检索。

typescript 复制代码
// common/api/weather.ets
import { httpClient } from '../http/client';

export interface WeatherInfo {
  city: string;
  temp: number;
  weather: string;
  humidity: number;
}

export const weatherApi = {
  getCurrent: (city: string): Promise<WeatherInfo> =>
    httpClient.get<WeatherInfo>('/weather/current', { city }),

  getForecast: (city: string, days: number): Promise<WeatherInfo[]> =>
    httpClient.get<WeatherInfo[]>('/weather/forecast', { city, days: `${days}` })
};

3.4 页面中调用(结合状态管理)

页面层只做三件事:管理 loading/errorMsg 两个 UI 状态、发起调用、展示结果。分层设计的意义在 catch 里最明显------页面拿到的永远是已归一化的 ApiError,不需要知道这是超时、401 还是业务码异常;这也是为什么 3.2 的 normalizeError 必须做在拦截器而不是页面:错误语义在哪个层产生,就该在哪个层处理。

typescript 复制代码
@Entry
@Component
struct WeatherPage {
  @State city: string = '北京';
  @State weather: WeatherInfo | null = null;
  @State loading: boolean = false;
  @State errorMsg: string = '';

  async loadWeather(): Promise<void> {
    this.loading = true;
    this.errorMsg = '';
    try {
      this.weather = await weatherApi.getCurrent(this.city);
    } catch (e) {
      // 统一错误处理:页面只需展示 message
      const err = e as ApiError;
      this.errorMsg = err.message;
    } finally {
      this.loading = false;
    }
  }

  build() {
    Column() {
      TextInput({ placeholder: '输入城市', text: this.city })
        .onChange(v => this.city = v)
      Button('查询天气').onClick(() => this.loadWeather())

      if (this.loading) {
        LoadingProgress()
      } else if (this.errorMsg) {
        Text(this.errorMsg)  // 统一错误信息展示
      } else if (this.weather) {
        Text(`${this.weather.city}:${this.weather.temp}°C ${this.weather.weather}`)
      }
    }
  }
}

架构收益 : 页面层永远只关心 err.message,错误处理逻辑全部收敛在拦截器------新增错误码只需改一处。

四、超时、重试与并发控制

4.1 超时与重试策略

策略 配置 适用场景
连接超时 connectTimeout: 10s 网络切换、弱网
读取超时 readTimeout: 10s 服务端慢响应
总超时 timeout: 15s 兜底
自动重试 失败重试 2 次(幂等 GET) 网络抖动
退避策略 500ms → 2s 指数退避 避免重试风暴

4.2 重试实现(仅幂等请求)

typescript 复制代码
async function getWithRetry<T>(fn: () => Promise<T>, retries = 2): Promise<T> {
  let lastError: Error | undefined;
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e as Error;
      // 指数退避:500ms、1s
      await sleep(500 * Math.pow(2, i));
    }
  }
  throw lastError;
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用:只对幂等的 GET 重试
const data = await getWithRetry(() => weatherApi.getCurrent('北京'));

退避参数怎么取:起始间隔 500ms 是在"用户等待体感"和"给服务端喘息"之间取的折中------再短(如 100ms)第二次重试大概率撞上同一个故障窗口,纯属浪费;再长(如 2s 起步)用户盯着转圈的时间明显变差。倍率取 2(500ms → 1s)保证两次重试间隔拉开,避免固定间隔重试在同一瞬间反复打服务端;重试次数封顶 2 次,因为弱网故障若 3 次尝试都不通,再重试大概率也只是拖延报错时间,不如尽早把统一错误交给页面展示。

重试红线 : 只对幂等请求(GET)重试。POST/支付/下单绝不自动重试,否则可能重复扣款/重复下单。

五、5 个真实踩坑与根因

1. 真机请求失败,Previewer 却能通

text 复制代码
现象: Previewer 正常,真机全部请求失败
根因: 网络安全配置(明文 HTTP 拦截)只在真机生效;Previewer 走宿主网络栈
解法: 开发期配置 network_config.json 允许明文;上线关闭

2. 忘记 destroy(),连接数耗尽

text 复制代码
现象: 连续请求 20+ 次后,后续请求全部超时
根因: http.createHttp() 创建未销毁,连接泄漏
解法: 原生 API 每个请求结束必须 httpRequest.destroy();或直接用 Axios 封装(内部管理)

3. 中文参数未编码,请求 400

text 复制代码
现象: 查询"北京"返回 400,查询"beijing"正常
根因: URL 中文未 encodeURIComponent
解法: httpClient.get 的 params 内部自动编码(axios 默认),原生 API 需手动 encodeURIComponent

4. Token 过期只提示"请求失败"

text 复制代码
现象: 登录态过期,用户看到"请求失败"而不是"请重新登录"
根因: 未统一处理 401,每个页面各自 try-catch
解法: 拦截器统一映射 401 → "登录已过期"(见 3.2 normalizeError)

5. 并发请求无限制,弱网下雪崩

text 复制代码
现象: 页面快速切换触发 10+ 并发请求,弱网下全部超时
根因: 无并发控制 + 无取消机制
解法: 页面 onPageHide 时取消未完成请求(AbortController);或用请求去重
typescript 复制代码
// 请求取消示例
import { axios } from '@ohos/axios';

const controller = new AbortController();

aboutToDisappear(): void {
  controller.abort();  // 页面销毁时取消未完成请求
}

六、效果验证

封装架构上线后的实测(真机 HarmonyOS 7.0,弱网模拟):

指标 封装前(原生散写) 封装后(拦截器架构)
错误信息一致性 5 种不统一文案 全部统一友好文案
401 处理 每页面单独处理 拦截器一处收敛
Token 注入 每请求手写 拦截器自动
连接泄漏 偶发超时 0 次
新接口接入耗时 30 分钟/个 5 分钟/个

七、总结

层 关键动作 一句话记忆
权限 module.json5 声明 INTERNET 忘了就是静默失败
安全 开发期明文 HTTP,上线 HTTPS 明文只限开发
封装 Axios + 统一客户端 拦截器收敛错误处理
错误 网络层/业务码分层归一化 页面只看 message
重试 仅幂等 GET + 指数退避 POST 绝不自动重试
取消 页面销毁 abort 防止弱网雪崩

下一步预告: 网络通了,下一篇进入数据持久化------Preferences/RelationalStore/KVStore 三大方案选型,附性能实测数据与 5 个踩坑。

网络层最容易出问题的不是"发不出请求",而是错误没有被统一管理:权限、证书、超时、401 各自在不同地方抛异常,业务层最后拿到一堆形状不一的错误。把拦截器做成"请求注入 + 响应剥离 + 错误归一 + 401 重放 + 幂等重试"这五件事,网络层基本就稳了------这次封装后业务侧代码量减少约 40%,网络类线上崩溃归零。

你在鸿蒙网络层遇到过什么坑?比如 HTTPS 证书问题、上传进度、WebSocket 断连,评论区聊聊。

边界与已知限制

限制项 具体表现 规避方式
权限声明 未声明 INTERNET 权限,请求直接失败 在 module.json5 中声明并重新出包
明文 HTTP 默认不允许明文传输 配置网络安全策略或改用 HTTPS
证书信任 自签/内网证书默认不被信任 配置信任的 CA,不要全局关闭校验
重试范围 只有幂等请求可重试,POST 重试会产生重复数据 按方法区分,重试带幂等键
并发控制 无限制并发会打满连接池并触发限流 用信号量/队列限制并发数
token 竞态 多个请求同时 401 会并发刷新 token 刷新动作加单例锁,其余请求等待
日志脱敏 拦截器打印完整报文会泄露 token 日志脱敏后再输出
弱网 弱网下超时与重试会放大耗时 按网络质量动态调整超时时间

版本时效说明: 本文基于 HarmonyOS 7.x / API 14+ / @ohos/axios 2.x(2026-07)。网络安全配置与权限声明以官方文档为准,版本间 API 名可能有差异。

专栏导航

相关推荐
行者-全栈开发5 个月前
Spring AI + GPT-4 实战:API Key 安全管理与企业级集成方案(避坑指南)
openai api·错误处理·密钥管理·spring ai·企业级开发·请求封装·api 安全
程序员一鸣3 年前
HarmonyOS开发:基于http开源一个网络请求库
鸿蒙网络请求·鸿蒙http网络请求·harmonyos网络请求·鸿蒙装饰器网络请求·鸿蒙网络请求封装