Administrator
发布于 2026-08-30 / 53 阅读
0
0

鸿蒙网络请求实战:HTTP 封装、数据流转与错误处理最佳实践

系列文章《鸿蒙开发从入门到专家》第 6 篇(中级篇)

前言

数据持久化搞定了,但应用不能只有本地数据。网络请求是现代 App 的命脉——登录鉴权、内容加载、表单提交,样样离不开 HTTP。

鸿蒙官方提供了 @kit.NetworkKit 里的 http 模块,但直接用它的 API 写业务代码会非常痛苦:每个请求都要手写异常处理、重复拼装 URL、手动管理 loading 状态。本文从官方 API 出发,一步步封装出一个企业级的网络层,并讨论数据流转、错误处理和缓存策略。

一、官方 HTTP API 基础

1.1 发起一个简单请求

import { http } from '@kit.NetworkKit'

async function fetchNewsList(): Promise<void> {
  let httpRequest = http.createHttp()
  
  try {
    let response = await httpRequest.request(
      'https://api.example.com/news/list',
      {
        method: http.RequestMethod.GET,
        header: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer xxx_token'
        },
        connectTimeout: 10000,
        readTimeout: 10000
      }
    )
    
    if (response.responseCode === 200) {
      let data = JSON.parse(response.result.toString())
      console.info(`获取到 ${data.length} 条新闻`)
    } else {
      console.error(`请求失败: ${response.responseCode}`)
    }
  } catch (err) {
    console.error(`网络错误: ${err.message}`)
  } finally {
    httpRequest.destroy()  // 必须手动销毁,否则会内存泄漏
  }
}

1.2 POST 请求与表单提交

async function submitLogin(phone: string, password: string): Promise<void> {
  let httpRequest = http.createHttp()
  
  try {
    let response = await httpRequest.request(
      'https://api.example.com/auth/login',
      {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'application/json'
        },
        extraData: JSON.stringify({
          phone: phone,
          password: password
        })
      }
    )
    
    let result = JSON.parse(response.result.toString())
    if (result.code === 0) {
      // 保存 token
      await saveToken(result.data.token)
    } else {
      throw new Error(result.message)
    }
  } finally {
    httpRequest.destroy()
  }
}

1.3 文件上传

async function uploadAvatar(fileUri: string): Promise<void> {
  let httpRequest = http.createHttp()
  
  let response = await httpRequest.request(
    'https://api.example.com/upload',
    {
      method: http.RequestMethod.POST,
      header: {
        'Content-Type': 'multipart/form-data'
      },
      extraData: [
        { name: 'file', value: 'uri', type: 'file', filePath: fileUri }
      ]
    }
  )
  
  httpRequest.destroy()
}

二、封装网络层:从原始 API 到业务可用

直接使用 http.createHttp() 的问题很明显:

  • 每个请求都要手动 destroy(),容易遗漏导致内存泄漏

  • URL、header、超时配置到处重复

  • 错误处理逻辑分散,难以统一

  • 没有请求/响应拦截机制

我们封装一个 HttpClient 类:

// types/NetResponse.ets
interface NetResponse<T> {
  code: number        // 业务状态码
  message: string     // 提示信息
  data: T             // 实际数据
}

// network/HttpClient.ets
import { http } from '@kit.NetworkKit'

class HttpClient {
  private baseURL: string = 'https://api.example.com'
  private defaultTimeout: number = 15000

  // 请求拦截器
  private async interceptRequest(options: http.HttpRequestOptions): Promise<http.HttpRequestOptions> {
    let token = await getToken()
    if (token) {
      options.header = {
        ...options.header,
        'Authorization': `Bearer ${token}`
      }
    }
    return options
  }

  // 响应拦截器
  private async interceptResponse<T>(response: http.HttpResponse): Promise<NetResponse<T>> {
    if (response.responseCode !== 200) {
      throw new NetworkError(response.responseCode, `HTTP ${response.responseCode}`)
    }

    let result: NetResponse<T>
    try {
      result = JSON.parse(response.result.toString())
    } catch {
      throw new NetworkError(-1, '响应格式错误')
    }

    // Token 过期处理
    if (result.code === 401) {
      await clearToken()
      throw new NetworkError(401, '登录已过期,请重新登录')
    }

    if (result.code !== 0) {
      throw new NetworkError(result.code, result.message)
    }

    return result
  }

  async request<T>(
    url: string,
    method: http.RequestMethod = http.RequestMethod.GET,
    data?: object,
    customOptions?: Partial<http.HttpRequestOptions>
  ): Promise<T> {
    let fullURL = url.startsWith('http') ? url : `${this.baseURL}${url}`
    
    let options: http.HttpRequestOptions = {
      method: method,
      header: {
        'Content-Type': 'application/json',
        ...customOptions?.header
      },
      connectTimeout: customOptions?.connectTimeout || this.defaultTimeout,
      readTimeout: customOptions?.readTimeout || this.defaultTimeout,
      ...customOptions
    }

    if (data && (method === http.RequestMethod.POST || method === http.RequestMethod.PUT)) {
      options.extraData = JSON.stringify(data)
    }

    // 请求拦截
    options = await this.interceptRequest(options)

    let httpRequest = http.createHttp()
    
    try {
      let response = await httpRequest.request(fullURL, options)
      let result = await this.interceptResponse<T>(response)
      return result.data
    } finally {
      httpRequest.destroy()  // 确保一定销毁
    }
  }

  // 便捷方法
  async get<T>(url: string, params?: Record<string, string>): Promise<T> {
    let queryString = params ? '?' + new URLSearchParams(params).toString() : ''
    return this.request<T>(url + queryString, http.RequestMethod.GET)
  }

  async post<T>(url: string, data: object): Promise<T> {
    return this.request<T>(url, http.RequestMethod.POST, data)
  }

  async put<T>(url: string, data: object): Promise<T> {
    return this.request<T>(url, http.RequestMethod.PUT, data)
  }

  async delete<T>(url: string): Promise<T> {
    return this.request<T>(url, http.RequestMethod.DELETE)
  }
}

// 错误类
class NetworkError extends Error {
  code: number
  constructor(code: number, message: string) {
    super(message)
    this.code = code
    this.name = 'NetworkError'
  }
}

// 单例导出
export const httpClient = new HttpClient()

三、数据流转:从网络到界面的完整链路

网络请求不是孤立的操作,它要跟 UI 状态管理配合。一个标准的请求流程包含四个状态:

初始状态 → 加载中 → 成功/失败 → 显示数据/错误提示

我们用 @State 管理这些状态:

// pages/NewsListPage.ets
import { httpClient } from '../network/HttpClient'

interface NewsItem {
  id: string
  title: string
  summary: string
  coverUrl: string
  publishTime: string
}

@Entry
@Component
struct NewsListPage {
  @State newsList: NewsItem[] = []
  @State loading: boolean = false
  @State error: string = ''
  @State hasMore: boolean = true
  @State currentPage: number = 1

  aboutToAppear() {
    this.loadData()
  }

  async loadData(isRefresh: boolean = true) {
    if (this.loading) return
    
    this.loading = true
    this.error = ''
    
    if (isRefresh) {
      this.currentPage = 1
    }

    try {
      let result = await httpClient.get<NewsItem[]>('/news/list', {
        page: this.currentPage.toString(),
        size: '20'
      })

      if (isRefresh) {
        this.newsList = result
      } else {
        this.newsList = [...this.newsList, ...result]
      }

      this.hasMore = result.length === 20
      this.currentPage++
    } catch (err) {
      this.error = err instanceof Error ? err.message : '加载失败'
    } finally {
      this.loading = false
    }
  }

  build() {
    Column() {
      if (this.loading && this.newsList.length === 0) {
        // 首次加载
        LoadingProgress()
          .width(48)
          .height(48)
          .color('#317AF7')
      } else if (this.error && this.newsList.length === 0) {
        // 加载失败
        Column({ space: 12 }) {
          Text(this.error).fontSize(14).fontColor('#999')
          Button('重新加载')
            .onClick(() => this.loadData())
        }
      } else {
        // 列表内容
        List({ space: 8 }) {
          LazyForEach(this.newsList, (item: NewsItem) => {
            ListItem() {
              // ... 新闻卡片布局
            }
          }, (item: NewsItem) => item.id)

          // 底部加载更多
          if (this.hasMore) {
            ListItem() {
              Row() {
                if (this.loading) {
                  LoadingProgress().width(20).height(20)
                }
                Text(this.loading ? '加载中...' : '上拉加载更多')
                  .fontSize(14)
                  .fontColor('#999')
              }
              .height(50)
              .justifyContent(FlexAlign.Center)
              .onClick(() => {
                if (!this.loading) {
                  this.loadData(false)
                }
              })
            }
          }
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
  }
}

四、错误处理策略

4.1 错误分类与处理

错误类型

场景

处理方式

网络不可用

无 WiFi/蜂窝数据

提示检查网络,提供重试按钮

请求超时

服务器响应慢

自动重试 1-2 次,仍失败则提示

HTTP 错误

4xx/5xx

按状态码处理:401 跳转登录,500 提示服务端错误

业务错误

code ≠ 0

直接展示服务端返回的 message

解析错误

JSON 格式异常

记录日志,提示"数据异常"

4.2 自动重试机制

async function requestWithRetry<T>(
  requestFn: () => Promise<T>,
  maxRetries: number = 2
): Promise<T> {
  let lastError: Error | null = null
  
  for (let i = 0; i <= maxRetries; i++) {
    try {
      return await requestFn()
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err))
      
      // 只有超时错误才重试
      if (lastError.message.includes('timeout') && i < maxRetries) {
        await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
        continue
      }
      
      throw lastError
    }
  }
  
  throw lastError!
}

// 使用
let data = await requestWithRetry(() => httpClient.get('/api/data'))

五、缓存策略:离线也能用

5.1 三级缓存架构

内存缓存(L1)→ 磁盘缓存(L2)→ 网络请求(L3)
class CacheManager {
  private memoryCache: Map<string, { data: object; expireAt: number }> = new Map()
  private readonly defaultTTL: number = 5 * 60 * 1000  // 5 分钟

  // 内存缓存
  setMemory(key: string, data: object, ttl?: number): void {
    this.memoryCache.set(key, {
      data,
      expireAt: Date.now() + (ttl || this.defaultTTL)
    })
  }

  getMemory<T>(key: string): T | null {
    let item = this.memoryCache.get(key)
    if (!item) return null
    if (Date.now() > item.expireAt) {
      this.memoryCache.delete(key)
      return null
    }
    return item.data as T
  }

  // 带缓存的请求
  async requestWithCache<T>(
    key: string,
    requestFn: () => Promise<T>,
    options: { useCache?: boolean; ttl?: number } = {}
  ): Promise<T> {
    let { useCache = true, ttl } = options

    // 先读内存缓存
    if (useCache) {
      let cached = this.getMemory<T>(key)
      if (cached) {
        console.info('命中内存缓存')
        return cached
      }
    }

    // 网络请求
    let data = await requestFn()
    
    // 写入缓存
    if (useCache) {
      this.setMemory(key, data as object, ttl)
    }
    
    return data
  }
}

export const cacheManager = new CacheManager()

5.2 使用缓存的请求

async loadNewsList(): Promise<NewsItem[]> {
  return cacheManager.requestWithCache(
    'news_list',
    () => httpClient.get<NewsItem[]>('/news/list'),
    { ttl: 3 * 60 * 1000 }  // 缓存 3 分钟
  )
}

六、权限配置

别忘了在 module.json5 中声明网络权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

小结与下篇预告

本文搭建了一套企业级网络层:

  1. HttpClient 封装:统一处理 baseURL、header、超时、token 注入

  2. 拦截器机制:请求前自动加 token,响应后统一处理 401

  3. 状态管理:loading / error / data 三态让 UI 有据可依

  4. 错误处理:分类处理 + 自动重试,提升用户体验

  5. 三级缓存:内存缓存让重复访问毫秒级响应

下一篇(第 7 篇)我们将进入 UI 进阶:自定义组件与动画。包括 @BuilderParam 插槽机制、组件复用策略、属性动画和转场动画的实战用法。

参考阅读


评论