[鸿蒙从零到一] HarmonyOS 网络请求与 JSON 解析实战:类型安全、错误分层与状态联动

[鸿蒙从零到一] HarmonyOS 网络请求与 JSON 解析实战:类型安全、错误分层与状态联动

前言

网络请求几乎存在于每个鸿蒙应用中:资讯页要拉取列表,个人中心要读取用户资料,表单页要提交数据。能发出一个 HTTP 请求只是起点,真正影响工程质量的是如何管理权限、超时、状态码、JSON 类型、业务错误和页面状态。

本文以“文章列表”功能为例,使用 HarmonyOS 的 @kit.NetworkKit 和 ArkTS 搭建一条完整链路。我们会先完成基础请求,再逐步抽离类型模型、请求客户端和仓储层,最后让 ArkUI 页面正确处理加载、成功、空数据与失败状态。

请求链路应该分清哪些职责

一个可维护的网络模块通常包含以下层次:

  • 页面层:触发加载并展示状态,不解析底层响应。
  • 仓储层:表达“获取文章列表”这样的业务动作。
  • 客户端层:统一处理 URL、请求头、超时、HTTP 状态码和资源释放。
  • 模型层:定义服务端数据和页面所需数据的类型。

如果页面直接调用 http.createHttp(),它很快就会同时承担请求参数、JSON 解析、错误提示和重试逻辑。接口一多,相同代码会散落到各个页面,修改鉴权或超时策略也会变得困难。

配置网络访问权限

应用访问网络前,需要在模块的 module.json5 中声明权限:

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

INTERNET 属于系统授权权限,通常不需要弹窗向用户动态申请,但仍必须在配置文件中声明。生产环境应优先使用 HTTPS,并避免把测试域名、令牌或私有地址硬编码到页面代码中。

完成一个基础 GET 请求

HarmonyOS 提供 http.createHttp() 创建请求对象。请求结束后要调用 destroy() 释放资源,无论成功还是失败都不能遗漏。

import { http } from '@kit.NetworkKit'

async function loadRawArticles(): Promise<string> {
  const request = http.createHttp()
  try {
    const response = await request.request(
      'https://api.example.com/v1/articles?page=1&pageSize=20',
      {
        method: http.RequestMethod.GET,
        header: {
          'Accept': 'application/json'
        },
        connectTimeout: 10000,
        readTimeout: 15000,
        expectDataType: http.HttpDataType.STRING
      }
    )

    if (response.responseCode < 200 || response.responseCode >= 300) {
      throw new Error(`HTTP status: ${response.responseCode}`)
    }
    return response.result as string
  } finally {
    request.destroy()
  }
}

这里显式指定 HttpDataType.STRING,让 JSON 解析由应用控制。这样可以在解析失败时给出清晰错误,也便于先检查响应结构,而不是直接对不确定的对象做类型断言。

为 JSON 响应建立类型模型

假设服务端返回如下数据:

{
  "code": 0,
  "message": "ok",
  "data": {
    "items": [
      {
        "id": "a1001",
        "title": "ArkUI 状态管理实践",
        "author_name": "Harmony Developer",
        "published_at": 1784822400000
      }
    ],
    "has_more": false
  }
}

ArkTS 接口可以描述预期结构:

interface ArticleDto {
  id: string
  title: string
  author_name: string
  published_at: number
}

interface ArticlePageDto {
  items: ArticleDto[]
  has_more: boolean
}

interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

export interface Article {
  id: string
  title: string
  authorName: string
  publishedAt: number
}

DTO 保留服务端字段形式,业务模型使用应用内部统一的命名。两者分开后,服务端字段变化不会直接污染整个 UI 层。

需要注意,JSON.parse() 返回的内容来自运行时,类型断言不会自动验证数据。正式项目可以使用经过团队评估、适配 ArkTS 的校验方案,或者在边界处编写必要的字段检查。

function isArticleDto(value: Object): boolean {
  const item = value as Record<string, Object>
  return typeof item.id === 'string' &&
    typeof item.title === 'string' &&
    typeof item.author_name === 'string' &&
    typeof item.published_at === 'number'
}

function mapArticle(dto: ArticleDto): Article {
  return {
    id: dto.id,
    title: dto.title,
    authorName: dto.author_name,
    publishedAt: dto.published_at
  }
}

字段校验应放在网络边界,而不是等到页面渲染时才发现 title 不存在。

封装统一的 HttpClient

接下来把重复的请求逻辑集中到客户端中。客户端负责基础地址、公共请求头、状态码、JSON 解析和资源释放。

import { http } from '@kit.NetworkKit'

export class NetworkError extends Error {
  constructor(
    message: string,
    readonly statusCode?: number,
    readonly cause?: Object
  ) {
    super(message)
    this.name = 'NetworkError'
  }
}

export class HttpClient {
  constructor(private readonly baseUrl: string) {}

  async get<T>(path: string, token?: string): Promise<T> {
    const request = http.createHttp()
    const headers: Record<string, string> = {
      'Accept': 'application/json'
    }
    if (token !== undefined && token.length > 0) {
      headers['Authorization'] = `Bearer ${token}`
    }

    try {
      const response = await request.request(`${this.baseUrl}${path}`, {
        method: http.RequestMethod.GET,
        header: headers,
        connectTimeout: 10000,
        readTimeout: 15000,
        expectDataType: http.HttpDataType.STRING
      })

      if (response.responseCode < 200 || response.responseCode >= 300) {
        throw new NetworkError(
          '服务暂时不可用',
          response.responseCode
        )
      }

      try {
        return JSON.parse(response.result as string) as T
      } catch (error) {
        throw new NetworkError('响应数据格式错误', response.responseCode, error)
      }
    } catch (error) {
      if (error instanceof NetworkError) {
        throw error
      }
      throw new NetworkError('网络连接失败', undefined, error as Object)
    } finally {
      request.destroy()
    }
  }
}

客户端不要直接弹 Toast,也不要写入页面状态。它不知道当前请求来自列表页、后台同步还是启动流程,只需返回数据或抛出结构明确的错误。

区分 HTTP 错误与业务错误

HTTP 状态成功不代表业务一定成功。服务端可能返回 200,但 JSON 中的 code 表示登录失效、参数错误或访问受限。因此仓储层还要检查业务响应。

export class ApiError extends Error {
  constructor(
    readonly code: number,
    message: string
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

export class ArticleRepository {
  constructor(private readonly client: HttpClient) {}

  async list(page: number, pageSize: number): Promise<Article[]> {
    const response = await this.client.get<ApiResponse<ArticlePageDto>>(
      `/v1/articles?page=${page}&pageSize=${pageSize}`
    )

    if (response.code !== 0) {
      throw new ApiError(response.code, response.message)
    }
    if (!Array.isArray(response.data.items)) {
      throw new NetworkError('文章列表格式错误')
    }

    return response.data.items.map((item: ArticleDto) => mapArticle(item))
  }
}

这种分层让调用方能按错误性质处理:连接失败可以提示检查网络,鉴权失效可以进入登录流程,服务端异常可以展示稍后重试,而解析异常应记录必要的诊断信息并关注接口兼容性。

POST 请求与请求体序列化

提交数据时,应明确请求类型并使用 JSON.stringify() 序列化请求体:

interface CreateArticleInput {
  title: string
  content: string
}

async function createArticle(input: CreateArticleInput): Promise<void> {
  const request = http.createHttp()
  try {
    const response = await request.request(
      'https://api.example.com/v1/articles',
      {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        extraData: JSON.stringify(input),
        connectTimeout: 10000,
        readTimeout: 15000,
        expectDataType: http.HttpDataType.STRING
      }
    )
    if (response.responseCode < 200 || response.responseCode >= 300) {
      throw new NetworkError('提交失败', response.responseCode)
    }
  } finally {
    request.destroy()
  }
}

不要手工拼接 JSON 字符串。JSON.stringify() 能正确处理引号和转义字符,也能让请求模型保持清晰。提交前仍要进行业务校验,例如标题为空或正文超长时,不应依赖服务端兜底。

用页面状态驱动 ArkUI

网络页面至少要区分加载、成功、空数据和失败。可以先定义一个简单状态模型:

enum LoadStatus {
  IDLE,
  LOADING,
  SUCCESS,
  EMPTY,
  ERROR
}

@Entry
@Component
struct ArticleListPage {
  @State status: LoadStatus = LoadStatus.IDLE
  @State articles: Article[] = []
  @State errorMessage: string = ''

  private readonly repository = new ArticleRepository(
    new HttpClient('https://api.example.com')
  )

  async aboutToAppear(): Promise<void> {
    await this.loadArticles()
  }

  private async loadArticles(): Promise<void> {
    this.status = LoadStatus.LOADING
    this.errorMessage = ''
    try {
      const result = await this.repository.list(1, 20)
      this.articles = result
      this.status = result.length === 0 ? LoadStatus.EMPTY : LoadStatus.SUCCESS
    } catch (error) {
      this.errorMessage = error instanceof Error ? error.message : '加载失败'
      this.status = LoadStatus.ERROR
    }
  }

  @Builder
  private content() {
    if (this.status === LoadStatus.LOADING) {
      LoadingProgress()
    } else if (this.status === LoadStatus.ERROR) {
      Column({ space: 12 }) {
        Text(this.errorMessage)
        Button('重新加载').onClick(() => this.loadArticles())
      }
    } else if (this.status === LoadStatus.EMPTY) {
      Text('暂无文章')
    } else {
      List() {
        ForEach(this.articles, (article: Article) => {
          ListItem() {
            Column({ space: 6 }) {
              Text(article.title).fontSize(18).fontWeight(FontWeight.Medium)
              Text(article.authorName).fontSize(14).fontColor('#666666')
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
            .padding(16)
          }
        }, (article: Article) => article.id)
      }
    }
  }

  build() {
    Column() {
      this.content()
    }
    .width('100%')
    .height('100%')
  }
}

状态切换应由一次加载流程集中管理。不要只维护一个 isLoading 布尔值,否则空数据、旧数据刷新失败和首次加载失败很容易混在一起。

处理重复请求与页面离开

搜索框、筛选器和快速点击可能触发多个并发请求。旧请求比新请求更晚返回时,会把页面覆盖成过期结果。简单场景可以用递增标识只接收最近一次结果:

private requestVersion: number = 0

private async search(keyword: string): Promise<void> {
  const currentVersion = ++this.requestVersion
  this.status = LoadStatus.LOADING
  try {
    const result = await this.repository.search(keyword)
    if (currentVersion !== this.requestVersion) {
      return
    }
    this.articles = result
    this.status = result.length === 0 ? LoadStatus.EMPTY : LoadStatus.SUCCESS
  } catch (error) {
    if (currentVersion !== this.requestVersion) {
      return
    }
    this.status = LoadStatus.ERROR
  }
}

页面销毁后也要避免继续更新页面状态。具体项目可以结合组件生命周期、请求取消能力或状态容器统一管理,核心原则是过期结果不能写回当前界面。

重试、日志与安全边界

重试并不是请求失败后无条件再发一次。GET 等幂请求在临时网络故障时可以有限重试,并加入间隔;创建订单、支付、提交表单等操作若没有幂等键,自动重试可能产生重复数据。

日志也要控制边界:

  • 可以记录请求路径、耗时、状态码和错误类别。
  • 不应记录访问令牌、Cookie、密码和完整身份证件信息。
  • 请求体和响应体可能含隐私数据,生产日志默认不要全文输出。
  • 面向用户的提示应简洁,底层异常细节留给受控诊断日志。

基础地址、环境和公共请求头建议通过构建配置或依赖注入提供。鉴权令牌应从合适的安全存储读取,不要写死在源码中。

常见问题

忘记销毁 HttpRequest

每次创建的请求对象都应在 finally 中调用 destroy()。只在成功分支释放,会让异常路径持续占用资源。

只判断业务 code

代理错误、网关故障和服务端异常可能返回非成功 HTTP 状态,甚至返回 HTML。应先检查状态码,再解析 JSON 和判断业务码。

用类型断言代替运行时校验

as ApiResponse&lt;T&gt; 只影响编译阶段,不会修复缺失字段。关键接口应在数据边界验证数组、必填字段和基础类型。

把原始错误直接展示给用户

底层异常可能包含实现细节,且对用户没有帮助。应将其转换为明确的业务提示,同时保留可控的诊断上下文。

每个页面各写一套请求代码

重复代码会导致超时、请求头和错误规则不一致。统一客户端处理协议细节,仓储层处理业务语义,页面只消费结果和状态。

小结

HarmonyOS 网络功能的核心不只是调用 request(),而是建立清晰的数据边界。客户端统一处理 HTTP、超时、JSON 和资源释放,仓储层判断业务结果并完成模型转换,ArkUI 页面则通过明确状态展示加载结果。

在此基础上,再按业务风险加入鉴权刷新、缓存、分页、取消、有限重试和监控,网络模块才能从“可以请求”成长为稳定、可测试、可演进的工程能力。

posted @ 2026-07-24 11:09  天总会晴的  阅读(10)  评论(0)    收藏  举报