HarmonyOS 网络请求稳定性实战:超时、重试、错误分层与弱网兜底

移动端网络问题最麻烦的地方,不是接口本身不可用,而是它经常表现得“不稳定”:地铁里请求转圈、弱网下重复点击、服务端返回了业务错误但页面只提示“失败”、登录失效后多个接口同时弹窗。用户看到的是体验差,开发者排查时看到的是一堆零散日志。

这篇文章不把网络请求当成一个 request() 调用来讲,而是把它拆成一条可维护链路:统一请求入口、超时边界、错误分层、克制重试、弱网缓存兜底和请求留账。示例代码以 ArkTS 写法表达,具体 HTTP 能力可按项目实际接入 HarmonyOS 的网络请求 API 或团队已有网络库。

请添加图片描述

1. 先确定这篇文章要解决的网络问题

实际项目里,网络层常见问题可以先分成四类,不要一上来就把所有失败都丢给页面处理。

问题 用户看到的现象 工程上应该做的事
请求没有边界 loading 长时间不结束 统一设置超时,并给页面返回可理解状态
错误没有分类 所有失败都叫“请求失败” 区分网络错误、业务错误、鉴权错误、解析错误
重试太激进 弱网下接口被打爆 只重试可恢复错误,并使用退避间隔
弱网没有兜底 页面空白或数据闪没 优先保留可用缓存,再提示刷新失败

本文的目标是让网络层对页面交付一个稳定结果,而不是把所有细节都泄漏给 UI。

请添加图片描述

2. 资料边界和项目落点

写网络稳定性时,建议先看三类资料:官方网络请求能力、项目内已有 HTTP 封装、线上错误日志。这里的示例不会绑定某一个业务域名,重点放在工程结构。

资料 用途
华为开发者文档中心:https://developer.huawei.com/consumer/cn/doc/ 确认 HarmonyOS 能力入口和 API 版本边界
HarmonyOS 指南:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ 查网络、数据、日志等能力的官方说明
项目网络封装文件 确认是否已经有 baseURL、header、token、错误码处理
线上接口日志 找出超时、401、业务错误、解析失败的真实比例

如果项目已经有网络库,不建议推翻重写。更稳妥的做法是在现有网络库外侧加稳定性边界:请求模型、错误映射、重试策略和结果记录。

3. 请求模型要先收口

网络层最怕每个页面自己拼 URL、自己传 header、自己判断错误。先定义请求模型,让页面只描述“我要什么”,不要关心底层细节。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

interface StableRequest<TBody = Record<string, Object>> {
  requestId: string;
  path: string;
  method: HttpMethod;
  body?: TBody;
  timeoutMs: number;
  retryable: boolean;
  cacheKey?: string;
}

interface StableResponse<TData> {
  requestId: string;
  data?: TData;
  fromCache: boolean;
  costMs: number;
}

这段代码负责请求边界:requestId 用来串联日志,timeoutMs 决定等待上限,retryable 控制是否允许重试,cacheKey 表示失败后是否能用缓存兜底。页面不再直接拼接底层参数,后面换网络库也不会影响调用方。

4. 统一入口处理超时和请求耗时

稳定请求的第一步是统一入口。不要让页面自己 setTimeout,也不要让不同接口有不同的默认等待时间。

class StableHttpClient {
  private readonly baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async send<TData>(request: StableRequest): Promise<StableResponse<TData>> {
    const startAt = Date.now();
    const task = this.performRequest<TData>(request);
    const data = await this.withTimeout(task, request.timeoutMs, request.requestId);

    return {
      requestId: request.requestId,
      data,
      fromCache: false,
      costMs: Date.now() - startAt,
    };
  }

  private async performRequest<TData>(request: StableRequest): Promise<TData> {
    const url = `${this.baseUrl}${request.path}`;
    // 这里替换为项目实际网络请求能力,例如 HarmonyOS 网络请求 API 或团队已有 HTTP 封装。
    return await Promise.resolve(JSON.parse(`{"url":"${url}"}`) as TData);
  }

  private async withTimeout<TData>(
    task: Promise<TData>,
    timeoutMs: number,
    requestId: string
  ): Promise<TData> {
    let timer = 0;
    const timeoutTask = new Promise<TData>((_, reject) => {
      timer = setTimeout(() => {
        reject(new Error(`NETWORK_TIMEOUT:${requestId}:${timeoutMs}`));
      }, timeoutMs);
    });

    try {
      return await Promise.race([task, timeoutTask]);
    } finally {
      clearTimeout(timer);
    }
  }
}

这段代码的重点不是 Promise.race 本身,而是把“等多久算失败”变成请求模型的一部分。输入只信任 StableRequest,失败时抛出带 requestId 的超时异常,下一层 ErrorMapper 可以继续处理。

5. ErrorMapper 不让页面猜错误原因

如果网络层只返回 Error,页面就只能猜。更稳的做法是把错误转换成明确类型。

type NetworkFailureKind =
  | 'timeout'
  | 'offline'
  | 'server'
  | 'auth'
  | 'business'
  | 'decode';

interface NetworkFailure {
  requestId: string;
  kind: NetworkFailureKind;
  message: string;
  canRetry: boolean;
  shouldLogout: boolean;
}

class ErrorMapper {
  map(request: StableRequest, error: Error): NetworkFailure {
    const raw = error.message;

    if (raw.startsWith('NETWORK_TIMEOUT')) {
      return {
        requestId: request.requestId,
        kind: 'timeout',
        message: '当前网络较慢,请稍后重试',
        canRetry: request.retryable,
        shouldLogout: false,
      };
    }

    if (raw.includes('401')) {
      return {
        requestId: request.requestId,
        kind: 'auth',
        message: '登录状态已过期,请重新登录',
        canRetry: false,
        shouldLogout: true,
      };
    }

    return {
      requestId: request.requestId,
      kind: 'server',
      message: '服务暂时不可用,请稍后再试',
      canRetry: request.retryable,
      shouldLogout: false,
    };
  }
}

这一层不直接弹窗,也不直接跳登录页。它只负责把底层异常转换成业务可理解的结果:能不能重试、是否需要退出登录、用户提示文案是什么。页面只消费明确状态,避免每个页面写一套错误判断。

6. 重试策略要克制,不要把弱网变成雪崩

重试不是越多越好。登录、支付、提交订单这类接口不应该随便重试;列表、配置、推荐数据可以在可控次数内重试。

interface RetryDecision {
  shouldRetry: boolean;
  delayMs: number;
  reason: string;
}

class RetryPolicy {
  decide(failure: NetworkFailure, attempt: number): RetryDecision {
    if (!failure.canRetry) {
      return { shouldRetry: false, delayMs: 0, reason: 'request_not_retryable' };
    }

    if (failure.kind === 'auth' || failure.kind === 'business') {
      return { shouldRetry: false, delayMs: 0, reason: `not_recoverable_${failure.kind}` };
    }

    if (attempt >= 2) {
      return { shouldRetry: false, delayMs: 0, reason: 'retry_limit_reached' };
    }

    const delayMs = 400 * Math.pow(2, attempt);
    return { shouldRetry: true, delayMs, reason: `retry_after_${delayMs}` };
  }
}

async function sleep(delayMs: number): Promise<void> {
  await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}

这里把最大重试次数控制在 2 次,并且使用退避间隔。它防止的是弱网下接口被页面连续触发,也防止业务错误被错误地重复提交。输入来自 ErrorMapper,下一层可以按 RetryDecision 决定是否再次请求。

7. 弱网兜底不要把旧数据直接当新数据

弱网兜底可以提升体验,但必须明确告诉页面数据来源。否则用户可能把旧数据当成最新结果。

interface CachedPayload<TData> {
  data: TData;
  savedAt: number;
  expireAt: number;
}

class NetworkFallbackStore<TData> {
  private readonly memory = new Map<string, CachedPayload<TData>>();

  save(cacheKey: string, data: TData, ttlMs: number): void {
    const now = Date.now();
    this.memory.set(cacheKey, {
      data,
      savedAt: now,
      expireAt: now + ttlMs,
    });
  }

  read(cacheKey: string): CachedPayload<TData> | undefined {
    const cached = this.memory.get(cacheKey);
    if (!cached) {
      return undefined;
    }
    if (cached.expireAt < Date.now()) {
      this.memory.delete(cacheKey);
      return undefined;
    }
    return cached;
  }
}

这段代码只演示内存兜底,真实项目可以替换为 Preferences、RDB 或文件缓存。关键点是 savedAtexpireAt:页面可以显示“数据来自缓存,正在尝试刷新”,而不是静默展示旧数据。

8. 把请求、错误、重试和缓存串起来

单个类很难解决网络稳定性,真正起作用的是编排层。

class StableNetworkGateway<TData> {
  constructor(
    private readonly client: StableHttpClient,
    private readonly mapper: ErrorMapper,
    private readonly retry: RetryPolicy,
    private readonly fallback: NetworkFallbackStore<TData>
  ) {}

  async request(request: StableRequest): Promise<StableResponse<TData>> {
    let attempt = 0;

    while (true) {
      try {
        const response = await this.client.send<TData>(request);
        if (request.cacheKey && response.data !== undefined) {
          this.fallback.save(request.cacheKey, response.data, 5 * 60 * 1000);
        }
        return response;
      } catch (e) {
        const failure = this.mapper.map(request, e as Error);
        const decision = this.retry.decide(failure, attempt);

        if (decision.shouldRetry) {
          attempt += 1;
          await sleep(decision.delayMs);
          continue;
        }

        if (request.cacheKey) {
          const cached = this.fallback.read(request.cacheKey);
          if (cached) {
            return {
              requestId: request.requestId,
              data: cached.data,
              fromCache: true,
              costMs: Date.now() - cached.savedAt,
            };
          }
        }

        throw new Error(`${failure.kind}:${failure.message}`);
      }
    }
  }
}

这段编排代码承担完整边界:先请求,失败后映射错误,再按策略重试,最后才读缓存兜底。它防止页面到处写 try/catch,也让所有网络失败都能被统一记录。

请添加图片描述

9. 请求结果要留账,排查时才有线索

线上问题最怕只有一句“用户说打不开”。每个请求都应该留下最小可排查记录。

interface NetworkRecord {
  requestId: string;
  path: string;
  result: 'success' | 'cache' | 'failed';
  costMs: number;
  failureKind?: NetworkFailureKind;
  retryCount: number;
  createdAt: number;
}

class NetworkLedger {
  private readonly records: NetworkRecord[] = [];

  append(record: NetworkRecord): void {
    this.records.push(record);
    if (this.records.length > 100) {
      this.records.shift();
    }
  }

  latest(): NetworkRecord[] {
    return this.records.slice().reverse();
  }
}

这类记录不应该包含 token、手机号、完整地址等敏感信息。它只保留定位问题必要字段:接口路径、耗时、失败类型、重试次数和时间。后续如果接入日志系统,也可以从这里统一上报。

10. 页面层只处理四种状态

页面不应该知道底层是超时、DNS、HTTP 还是 JSON 解析,它只需要可展示状态。

type NetworkViewState<TData> =
  | { type: 'loading' }
  | { type: 'content'; data: TData; fromCache: boolean }
  | { type: 'empty'; message: string }
  | { type: 'error'; message: string; canRetry: boolean };

function toViewState<TData>(response?: StableResponse<TData>, error?: Error): NetworkViewState<TData> {
  if (response?.data) {
    return {
      type: 'content',
      data: response.data,
      fromCache: response.fromCache,
    };
  }

  if (error) {
    return {
      type: 'error',
      message: error.message,
      canRetry: !error.message.includes('auth'),
    };
  }

  return { type: 'empty', message: '暂无数据' };
}

页面只围绕 loading/content/empty/error 渲染,弱网缓存通过 fromCache 告诉用户“当前展示缓存数据”。这样网络层再复杂,UI 状态也不会失控。

11. 网络稳定性验证动作

本地验证不要只测“正常网络能请求成功”,还要刻意制造失败场景。

验证场景 操作方式 预期结果
接口超时 把超时设为 1ms 或 mock 延迟 页面结束 loading,出现可理解提示
可恢复错误 mock 网络异常 按退避策略最多重试 2 次
业务错误 mock 业务码失败 不重试,返回业务提示
登录失效 mock 401 不重试,触发统一登录处理
弱网缓存 先成功一次,再断网刷新 展示缓存,并标记缓存来源

建议把这些场景做成调试开关或单元测试用例。网络层越早能复现失败,线上排查越少依赖猜测。

12. 常见网络问题排查表

现象 优先检查 处理方式
loading 不结束 是否所有请求都走统一超时 禁止页面绕过 StableHttpClient
重复弹登录 401 是否被多个页面各自处理 收口到 ErrorMapper 和登录协调器
弱网请求越来越多 重试次数和间隔是否可控 只对可恢复错误退避重试
页面显示旧数据但无提示 fromCache 是否传到 UI 页面增加缓存状态提示
排查不到失败接口 是否记录 requestId 和路径 NetworkLedger 增加最小留账

排查顺序建议从“是否绕过统一入口”开始。如果入口不统一,后面的错误分层、重试和日志都会被打散。

13. 发布前的网络链路检查

发布前不要只看页面是否能打开,要留下可复核的网络验收记录。建议每次发版前选 3 个核心接口:一个列表接口、一个详情接口、一个提交类接口,分别走正常网络、超时、鉴权失效和断网缓存四组场景。

  • 所有页面请求必须经过统一网络入口。
  • 每个请求都有明确 timeoutMs,不能无限等待。
  • 可重试接口和不可重试接口要分开声明。
  • 401、业务错误、解析错误不能混在同一个提示里。
  • 弱网兜底数据必须标记来源和过期时间。
  • 请求日志不能包含 token、手机号、身份证、详细地址。
  • 至少覆盖超时、重试、鉴权失效、缓存兜底四类测试。
interface NetworkReleaseCheck {
  apiName: string;
  normalPassed: boolean;
  timeoutHandled: boolean;
  authHandled: boolean;
  cacheFallbackChecked: boolean;
  owner: string;
}

const feedApiCheck: NetworkReleaseCheck = {
  apiName: 'feed/list',
  normalPassed: true,
  timeoutHandled: true,
  authHandled: true,
  cacheFallbackChecked: true,
  owner: 'network-module',
};

这份记录不需要复杂,但要能说明核心链路已经被验证。后续如果线上出现“弱网白屏”或“登录弹窗重复”,可以直接回看哪一项漏测。

网络链路专项证据包:把失败分层记录下来

弱网问题不能只靠 catch。一次请求失败要能分清楚是 DNS、连接、超时、状态码、业务码还是解析错误。分层越清楚,重试和兜底才不会误伤。

层级 记录字段 处理方向
连接层 networkError 判断是否可重试
HTTP 层 statusCode 处理服务异常
业务层 bizCode 展示业务提示
解析层 parserError 回退兼容结构
interface NetworkFailureEvidence {
  requestId: string
  stage: 'connect' | 'http' | 'business' | 'parse'
  retryable: boolean
  message: string
}

function shouldRetryNetwork(e: NetworkFailureEvidence): boolean {
  return e.retryable && (e.stage === 'connect' || e.stage === 'http')
}

这段代码把失败阶段作为重试输入,避免所有错误都机械重试。

14. 小结:稳定网络层不是多写 catch

HarmonyOS 应用里的网络稳定性,关键不是在每个页面多写几个 catch,而是把请求边界提前设计好。统一入口负责超时,错误映射负责分类,重试策略负责克制,缓存兜底负责体验,结果留账负责排查。这样写出来的网络层,后续接入新接口、改鉴权策略、补弱网体验,成本都会低很多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐