在这里插入图片描述

一、我们想解决什么问题

1.1 场景还原:用户打开 App,界面要显示服务器上的数据

想象一下这个场景:你在写一个社交类的小应用,用户打开 App 之后,界面上需要展示"当前登录用户的头像"和"用户名"。这数据不在手机本地,而是在服务器上。那怎么办?

你得去"问"服务器:“嘿,能把这个用户的信息给我吗?” 服务器收到问题,查了查数据库,把结果回给你。你收到之后再把头像和名字填到界面上去。

这个"问→等→答→更新"的过程,就是网络请求

听起来不复杂对吧?但实际动手写代码的时候,问题就来了:请求发出去之后,程序不能卡在那儿等结果——否则用户看到的就是一个完全卡住不动、像死机一样的界面。那怎么做到"不等"呢?这里就涉及到一个核心概念:异步

1.2 异步是什么?打个比方就懂了

同步的意思是:你让朋友帮你买杯咖啡,你就站在奶茶店门口干等,等他买完回来你再继续走路。异步的意思是:你说"帮我买杯咖啡",然后自己先走了,等朋友买好了给你发消息,你再回来拿。

在代码里,"发消息"就是发起网络请求,“等朋友买咖啡"就是等待响应。如果你用同步写法,程序会真的"站在原地干等”——界面卡死,用户点不了任何东西,体验极差。如果你用异步写法,程序先去做别的事,等数据回来了再更新界面,用户看到的是一个流畅的加载过程,而不是卡死。

在 HarmonyOS 的 ArkUI 里,我们用 @kit.NetworkKit 提供的 HTTP 工具来完成这件事。它的能力和浏览器里的 fetch 类似,但 API 设计上有点不一样,需要专门学一下。

1.3 这篇文章我们要做到什么

这篇文章我们就用 AtomGit 的场景来练手。AtomGit 是一个代码托管平台,类似于 GitHub。我们做一个简单的页面,用 HarmonyOS 应用从 GitHub 的公开 API 拉取某个用户的信息,然后展示到界面上。

具体来说,我们要展示:

  • 用户的头像
  • 用户名(login)
  • 公开仓库数量
  • 粉丝数量
  • 关注数量
  • 个人简介

整个过程拆成这几步:定模型 → 写请求 → 拿数据 → 展示界面。我们就按这个顺序一路讲下去。


二、数据模型设计

2.1 为什么先设计模型,而不是先写请求?

动手之前,先把"我们想要什么样的数据"定义清楚。就像盖房子先画图纸,数据模型就是你的图纸。很多新手喜欢直接写请求代码,写完才发现数据结构乱七八糟,字段名对不上,取数据的时候到处乱找。这就是没有先设计模型的代价。

而且,HarmonyOS 用的是 TypeScript 扩展(ArkTS),它有类型检查的能力。如果你先定义了接口(interface),代码里用错了字段名、传错了类型,IDE 直接报错,根本编译不过。如果你没定义接口,用错了字段名 IDE 也发现不了,只有运行时才发现数据是 undefined,然后页面展示一片空白,排查起来非常痛苦。

2.2 GitHub API 返回的原始数据长什么样

在我们定义自己的模型之前,先看看 GitHub API 实际返回的是什么。GitHub 公开 API 的地址是 https://api.github.com/users/{username},返回的数据大概长这样:

{
  "login": "atomgit",
  "avatar_url": "https://avatars.githubusercontent.com/u/xxxxx?v=4",
  "public_repos": 42,
  "followers": 128,
  "following": 35,
  "bio": "开源让世界更美好 🌟"
}

注意到字段名的风格了吗?GitHub API 用的是下划线命名(snake_case),比如 avatar_urlpublic_repos。这种命名风格在很多后端接口里很常见。

2.3 定义我们自己的数据模型

我们自己用的模型,字段名应该符合 ArkUI 的习惯(驼峰命名 camelCase)。所以我们定义一个 UserProfile 接口,然后写一个转换函数,把 API 返回的下划线字段映射到我们的驼峰字段上:

// UserProfile.ets
export interface UserProfile {
  login: string          // 用户名
  avatarUrl: string      // 头像地址(映射自 avatar_url)
  publicRepos: number    // 公开仓库数量(映射自 public_repos)
  followers: number      // 粉丝数量
  following: number      // 关注数量
  bio: string            // 个人简介
}

为什么要单独定义这个 interface,而不直接用 API 返回的结果?

打个比方:你去便利店买东西,收银台给你找了零钱,你随手塞口袋里不管了。但如果你提前想好"我需要知道找回的硬币是几枚、纸币是几张",你就会专门留意。同样,定义了 UserProfile interface 之后,代码里每个用到用户数据的地方都清楚知道有哪些字段可用,IDE 也能帮你检查有没有写错。

2.4 字段映射转换函数

光定义 interface 还不够,还需要一个转换函数,把 API 的原始数据转成我们自己的结构:

// GitHubUser 是 API 返回的原始数据结构
interface GitHubUser {
  login: string
  avatar_url: string
  public_repos: number
  followers: number
  following: number
  bio: string | null
}

export function convertToUserProfile(raw: GitHubUser): UserProfile {
  return {
    login: raw.login,
    avatarUrl: raw.avatar_url,
    publicRepos: raw.public_repos,
    followers: raw.followers,
    following: raw.following,
    bio: raw.bio ?? '这个人很懒,什么都没写'
  }
}

raw.bio ?? '这个人很懒,什么都没写' 这行代码的意思是:如果 bionull 或者 undefined,就使用默认值。为什么要这样做?因为 API 返回的 bio 字段可能是 null,如果你直接 raw.bio 赋值给字符串类型,TypeScript 会报错,用 ?? 操作符给一个默认值就解决了。


三、核心设计决策

3.1 方案对比:从零开始写网络请求的两条路

从零开始做网络请求,你面前有两条路:

方案A:直接用 @kit.NetworkKit 工具包

每次需要请求数据的时候,直接调用 http.createHttp().request(url, option, callback),把 URL、请求方法、头信息、回调函数都写在一个地方。需要请求的地方多了,就多写几份。

方案B:封装一个统一请求方法

把请求逻辑抽成一个独立的函数或者类,统一处理 URL 前缀、token 注入、超时设置、错误格式化这些事情。每次发起请求,只需要调用这个封装好的方法,传入接口名称和参数就行。

我们来看个对比:

维度 方案A:直接用工具包 方案B:封装统一请求方法
学习成本 需要熟悉官方工具包文档 一次封装,多次复用,上手更顺滑
代码复用 每个页面都要写重复的请求逻辑 抽取公共方法,代码更简洁
错误处理 每个接口单独处理 统一拦截错误,逻辑集中
依赖程度 强依赖官方接口版本 封装层屏蔽底层变化
适用场景 简单的一次性请求 复杂应用、多接口协作
维护成本 高(改动一处要改很多处) 低(统一入口,改一处全生效)
可测试性 难以单独测试请求逻辑 可以单独测试封装层

3.2 我们为什么选方案B

想象一下你开了一家餐厅,方案A就像每个厨师自己跑去菜市场买菜——有人买了土豆,有人买了番茄,谁也不知道今天库存有什么。方案B则是有一个专职的采购员,所有食材都从他那里走,他要什么菜就去找供应商。

在应用里,这个"采购员"就是封装好的请求方法。好处是:

  1. URL 前缀统一管理。如果后端地址变了(从 https://api.v1.com 换成 https://api.v2.com),只需要改一个地方。
  2. token 自动注入。所有请求需要带的认证 token,在封装层统一加到 header 里,不用每个接口单独写。
  3. 错误格式统一。后端返回的错误码,在封装层统一转换成前端能理解的错误信息,展示给用户。
  4. 日志统一。每次请求的耗时、状态,在封装层统一打日志,方便排查问题。

3.3 具体选型理由

我们选择 HarmonyOS 官方的 @kit.NetworkKit,而不是第三方库,原因也很直接:

  • 官方维护,稳定性有保障。ArkUI 版本更新时,官方工具包的兼容性是经过验证的。
  • 权限体系打通。用官方工具包,系统在权限校验的时候更好处理。
  • 不需要额外引入依赖。不需要 npm install,不需要担心第三方库版本冲突。

接下来我们就基于 @kit.NetworkKit 来写请求代码。


四、完整代码实现

这里我们把完整的代码拆成几块来讲,每一块只盯住一个核心目标。代码量不多,但每行都有它的作用。

4.1 发起网络请求

首先,核心的 HTTP 请求逻辑:

// UserService.ets
import { http } from '@kit.NetworkKit'
import { BusinessError } from '@kit.BasicServicesKit'

// 从网络获取用户资料
function fetchUserProfile(username: string): Promise<UserProfile> {
  const url = `https://api.github.com/users/${username}`
  const req = http.createHttp()
  const option: http.HttpRequestOptions = {
    method: http.RequestMethod.GET,
    header: {
      'User-Agent': 'AtomGit-App',
    },
    expectDataType: http.HttpDataType.OBJECT
  }

  return new Promise((resolve, reject) => {
    req.request(url, option, (err: BusinessError, data: http.HttpResponse) => {
      if (err.code !== 0) {
        reject(new Error(`网络请求失败: ${err.message}`))
        return
      }
      const raw = data.result as GitHubUser
      resolve(convertToUserProfile(raw))
    })
  })
}

这段代码做了一件什么事呢?想象一下你发微信:先编辑好内容(option 里定义请求方式、头信息),然后点击发送(req.request),对方回复之后,你再决定是处理消息还是告诉朋友"发失败了"。这里的 Promise 就是那个"等回复"的机制——发出去之后不卡在原地,先去做别的事,服务器回话了再来处理。

有几个地方值得特别说明一下:

  • http.RequestMethod.GET:明确指定是 GET 请求。HarmonyOS 的 HTTP 工具支持 GET、POST、PUT、DELETE 等常见方法。
  • 'User-Agent': 'AtomGit-App':GitHub API 要求所有请求都带 User-Agent,否则会返回 403 错误。这是个很容易被忽略的坑。
  • expectDataType: http.HttpDataType.OBJECT:设置为 OBJECT 之后,返回的 data.result 会自动被解析成 JS 对象,不需要你手动 JSON.parse

4.2 展示用户卡片

有了数据来源,接下来要把它显示到界面上。我们把用户信息的展示封装成一个独立的组件 UserCard,这样代码更清晰,也方便复用:

// UserCard.ets
@Component
export struct UserCard {
  @Prop user: UserProfile

  build() {
    Column() {
      Image(this.user.avatarUrl)
        .width(80).height(80)
        .borderRadius(40)
        .alt($r('sys.media.app_icon'))

      Text(this.user.login)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 16 })

      Text(`仓库: ${this.user.publicRepos}  |  粉丝: ${this.user.followers}`)
        .fontSize(14)
        .fontColor('#666666')
        .margin({ top: 8 })

      if (!this.user.bio.includes('很懒')) {
        Text(this.user.bio)
          .fontSize(13)
          .fontColor('#888888')
          .margin({ top: 10 })
          .textAlign(TextAlign.Center)
          .padding({ left: 20, right: 20 })
      }
    }
    .width('100%')
    .padding(20)
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
}

这里有一个小细节:@Prop 接收的是外部传入的数据,和 @State 不同,它是从父组件"单向流动"过来的。就像你从领导那里领任务,领到的是只读副本,改不动原数据。这种设计保证了数据流向清晰,不容易出现"页面悄悄改了状态,自己却不知道"的问题。

Image 组件里的 .alt() 是当图片加载失败时显示的占位图,这样用户体验会好一些,不会看到一张破图图标。

4.3 主页面:串联请求和展示

最后,把请求和展示串起来,这是整个页面的入口:

// Index.ets
import promptAction from '@ohos.promptAction'

@Entry
@Component
struct Index {
  @State user: UserProfile | null = null
  @State isLoading: boolean = false
  @State errorMsg: string = ''

  aboutToAppear(): void {
    this.loadUser('atomgit')
  }

  loadUser(username: string): void {
    this.isLoading = true
    this.errorMsg = ''

    fetchUserProfile(username)
      .then((data: UserProfile) => {
        this.user = data
        this.isLoading = false
      })
      .catch((err: Error) => {
        this.errorMsg = err.message
        this.isLoading = false
        promptAction.showToast({ message: `加载失败: ${err.message}` })
      })
  }

  build() {
    Column() {
      if (this.isLoading) {
        Column() {
          LoadingProgress().width(50).height(50)
          Text('正在加载用户信息...').fontSize(14).margin({ top: 12 })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      } else if (this.errorMsg) {
        Column() {
          Text('⚠ 加载出错')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 8 })
          Text(this.errorMsg).fontSize(13).fontColor('#999999')
          Button('重新加载').onClick(() => this.loadUser('atomgit'))
            .margin({ top: 16 })
        }
        .width('100%')
        .padding(20)
        .alignItems(HorizontalAlign.Center)
      } else if (this.user) {
        UserCard({ user: this.user as UserProfile })
        Button('换一个用户试试')
          .onClick(() => this.loadUser('github'))
          .margin({ top: 20 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }
}

几个关键点说明一下:

aboutToAppear 是在页面即将显示的时候自动调用的生命周期函数——我们用它来触发"页面一出来就加载数据"。这比把请求代码写在 build() 里要好,因为 build() 可能会被多次调用(比如状态变化时重新渲染),如果把请求也写在里面,就会重复发请求。

LoadingProgress 是 HarmonyOS 内置的加载动画组件,不需要自己写,非常方便。只需要在合适的状态下显示或隐藏它就好了。

promptAction.showToast 是HarmonyOS 提供的轻量级提示工具,用来在底部弹出一个短暂的提示文字,比 AlertDialog 打扰性小很多,适合用于错误提示。


五、深度技术原理

5.1 HTTP 请求的完整旅程

当你调用 http.createHttp().request(url, option) 时,背后发生了什么呢?让我们把整个过程拆开了看。

第一步:DNS 解析。 你的手机拿到 api.github.com 这个域名之后,要把它转换成 IP 地址。这需要访问 DNS 服务器,一般在 10~50 毫秒之间完成。如果 DNS 被劫持或者服务器 DNS 响应慢,这一步就会成为瓶颈。

第二步:建立 TCP 链接。 拿到 IP 地址之后,手机和 GitHub 服务器之间要建立一条可靠的传输通道,这就是 TCP 三次握手。这一步大约需要几十毫秒到一百毫秒不等,取决于网络质量。如果用户用的是移动网络,这一百毫秒可能是常态。

第三步:TLS 握手(HTTPS 专属)。 因为 GitHub 用的是 HTTPS,在 TCP 之上还要做一次 TLS 安全握手,确认双方的身份、协商加密算法、交换密钥。这一步大约再增加几十毫秒。所以 HTTPS 相对于 HTTP,连接建立时间要多出大约 100 毫秒。

第四步:发送 HTTP 请求。 链接建立好了,把你的请求报文塞进去发出去。这个报文里包含了请求方法(GET/POST 等)、URL 路径、请求头(Headers)、以及可选的请求体(Body)。报文本身通常很小,几十到几百字节,发送时间可以忽略不计。

第五步:服务器处理。 这是最不可控的一步。GitHub 的服务器收到请求,查数据库,组装 JSON 响应。在负载高的时候,服务器可能还要排队等 CPU。这个时间从几十毫秒到几秒不等,是整个请求链路上最不确定的一环。

第六步:接收响应数据。 服务器把响应发回来,手机一点一点接收。这个过程是流式的——数据不是一下子全到,而是一块一块来。expectDataType: http.HttpDataType.OBJECT 这个参数告诉 HarmonyOS:“等数据收完了再通知我,中途不用管。”

第七步:解析数据。 数据收齐之后,按照你指定的类型做解析。如果是 OBJECT 类型,HarmonyOS 会自动调用 JSON.parse 把字符串转成对象,然后回调你的函数。

整个过程加起来,短则 200 毫秒(局域网),长则 3~5 秒(弱网+跨国)。所以我们必须用异步的方式来写请求代码,否则整个界面会卡死在等待的这几秒钟里,用户看到的就是一个完全无响应的 App——这绝对不是你想要的结果。

5.2 同步写法 vs 异步写法:图解对比

如果用同步的方式写请求,大概是这样:

// 这种写法在 HarmonyOS 里是不存在的,但可以理解它的意思
const result = http.requestSync(url) // 卡住!界面不动!
this.user = result

用户看到的是:打开 App → 整个界面完全卡死 3 秒 → 数据突然蹦出来。这在体验上非常糟糕,尤其是弱网环境下,卡个 10 秒钟用户可能直接把你的 App 卸载了。

用异步的方式写:

fetchUserProfile(username).then(data => {
  this.user = data  // 数据回来了,更新界面
})

用户看到的是:打开 App → 看到加载动画 → 数据来了,动画消失,用户卡片出现。整个过程界面是流畅的,用户始终能感知到 App 在工作。

这就是为什么 HarmonyOS 的 HTTP 工具包从设计上就是基于回调和 Promise 的——它强制你用异步的方式写代码,从语言层面就堵死了同步卡界面这条路。

5.3 Promise 是什么,为什么用它

刚才代码里我们用的是 Promise 风格:

return new Promise((resolve, reject) => {
  req.request(url, option, (err, data) => {
    if (err.code !== 0) {
      reject(new Error('出错了'))
    } else {
      resolve(data)
    }
  })
})

为什么要包这一层?因为 HarmonyOS 的 HTTP 模块本身是基于回调函数的。回调函数的写法像这样:

req.request(url, option, (err, data) => {
  // 在这里处理结果
})

回调函数本身没问题,但如果请求多了——比如先拿用户信息、再拿用户仓库列表、再拿仓库的详情——代码会变成这样:

req.request(url1, option, (err1, data1) => {
  req.request(url2, option, (err2, data2) => {
    req.request(url3, option, (err3, data3) => {
      // 终于拿到所有数据了
    })
  })
})

这就是著名的"回调地狱"——一层套一层,缩进越来越深,错误处理要重复写,代码读起来简直是一种折磨。

Promise 把这个嵌套结构"拍扁"了,变成了链式写法:

fetchUser()
  .then(data1 => fetchRepos(data1.login))
  .then(data2 => fetchDetails(data2.id))
  .then(data3 => display(data3))
  .catch(err => showError(err))

每一行的意图都非常清晰:先做 A,再做 B,再做 C,出了错统一处理。这就是 Promise 的魅力——让异步代码看起来像同步代码一样线性可读

5.4 async/await:Promise 的语法糖

如果你觉得 .then().catch() 链还是有点绕,还有一个更简洁的写法:async/await。它让异步代码读起来几乎和同步代码一模一样:

async loadUserAndRepos(username: string): Promise<void> {
  try {
    const user = await fetchUserProfile(username)    // 等这个完成
    const repos = await fetchUserRepos(username)      // 等这个完成
    this.user = user
    this.reposList = repos
  } catch (err) {
    this.errorMsg = (err as Error).message
  }
}

await 关键字的意思是"在这里暂停,等 Promise 完成再继续往下走"。表面上看起来像是在等,但其实底层还是异步的——界面不会被卡住。async/await 只是 Promise 的语法糖,写起来更符合人写代码的自然思维。

5.5 @State 和 @Prop 的数据流原理

ArkUI 是一个声明式 UI框架。声明式的意思是:你描述"界面应该长什么样",框架负责"怎么把它渲染出来"以及"什么时候需要更新"。

打个比方:传统命令式编程像你在画布上自己一笔一笔画,每次有变化你都要判断"哪里需要重画",很麻烦。声明式 UI 像你给画家一张画像描述(“画一个红头发的人,戴眼镜”),画家(框架)自动决定怎么画、什么时候需要重画、你不需要管重画的技术细节。

在 ArkUI 里:

  • @State 装饰的变量:组件自己持有、自己修改的"私有状态"。一旦它的值变了,框架会自动触发这个组件的重新渲染——所有用到这个变量的 UI 都会更新。
  • @Prop 装饰的变量:从父组件传进来的"属性",是只读的。子组件收到的是一个值的副本,不能直接改它。

Index.ets 里,user@State,因为它需要在加载完成后被赋值,然后触发界面重新渲染。在 UserCard.ets 里,user@Prop,因为卡片组件只负责展示数据,不负责修改数据。这种单向数据流的约束让应用的状态变得可预测——你永远知道数据是从哪里来、谁有权修改它。


六、常见问题解答

Q1:请求报 “Network error”,但我网络明明正常,是怎么回事?

这种情况最常见的原因是权限没有配置。HarmonyOS 应用要访问网络,必须在 module.json5 里声明网络权限,否则系统会直接拒绝你的请求。检查一下你的项目里有没有这个配置:

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

没有这条配置,App 根本没有权限访问网络,连 DNS 解析都做不了,根本走不到发请求那一步。如果权限配了还报错,再依次检查:目标 URL 是否正确、服务器是否真的可达、是不是 VPN 把某些域名墙掉了。


Q2:接口返回的数据字段和我写的不一样,比如 GitHub 的 avatar_url 我想叫 avatarUrl,怎么处理?

在解析响应的回调里做一次字段映射就好。就像我们在 convertToUserProfile 函数里做的那样:

const result = data.result as GitHubUser
resolve({
  avatarUrl: result.avatar_url,  // API字段 -> 代码字段
  publicRepos: result.public_repos,
  bio: result.bio ?? '这个人很懒,什么都没写'
})

这样做有两个好处:第一,代码里统一用驼峰命名,后端 API 的下划线命名风格不会渗透到业务逻辑里;第二,万一哪天后端接口改了字段名(比如把 avatar_url 改成了 avatarUrl),你只需要改 convertToUserProfile 这一个函数,其他所有用到 avatarUrl 的地方不用动。


Q3:我想给请求加 token 认证(比如调用需要登录才能用的接口),要怎么做?

在请求头里加 Authorization 字段就好:

const option: http.HttpRequestOptions = {
  method: http.RequestMethod.GET,
  header: {
    'User-Agent': 'AtomGit-App',
    'Authorization': 'Bearer ghp_xxxxxxxxxxxxxxxxxxxx'  // 加这一行
  }
}

不过,token 不要硬编码在代码里。虽然代码写起来最简单,但别人反编译你的 App 就能直接看到 token,然后就可以用你的身份调用接口,后果不堪设想。正确的做法是从你自己的后端服务器获取 token,或者使用 HarmonyOS 提供的 cryptoFramework 和安全存储 API,把 token 加密存在设备的安全区域。GitHub 的公开接口(只读用户信息)其实不需要 token,但如果你要操作私有仓库、提交代码、或者调用频率比较高的接口,就需要认证了。


Q4:请求有时候很慢,有时候干脆超时了,超时了怎么处理?

http.createHttp() 支持单独设置超时时间:

const option: http.HttpRequestOptions = {
  method: http.RequestMethod.GET,
  connectTimeout: 8000,   // 建联超时:8秒内连不上就放弃
  readTimeout: 12000      // 读数据超时:12秒内收不到数据就放弃
}
  • connectTimeout:TCP 链接建立和 TLS 握手的时间上限。如果超过 8 秒还没建立好连接,就认为这次请求失败了。
  • readTimeout:从发出请求到收到完整响应的时间上限。如果服务器很慢,12 秒内还没返回数据,就认为失败了。

超时会进入 .catch() 分支。你可以在那里给用户一个友好的提示,比如"网络有点慢,请稍后再试",同时提供一个重试按钮。


Q5:我要连续发两个请求,第二个依赖第一个的结果,要怎么写?

async/await 把两个请求串起来就好了:

async loadFullUserInfo(username: string): Promise<void> {
  try {
    this.isLoading = true
    const user = await fetchUserProfile(username)       // 先拿到用户信息
    const repos = await fetchUserRepos(username)         // 再拿仓库列表
    this.user = user
    this.reposList = repos
    this.isLoading = false
  } catch (err) {
    this.isLoading = false
    this.errorMsg = (err as Error).message
  }
}

await 的神奇之处在于:它会"停在这里等",但整个应用不会卡——其他事情照常进行,只有这段代码在等第一个请求完成。等 fetchUserProfile 拿到数据了,才继续执行下一行 fetchUserRepos。这就是链式等待——前一个好了再下一个,不浪费。

如果你两个请求之间没有依赖(同时拿用户信息和拿公告列表),可以用 Promise.all 并行发出去:

async loadBoth(): Promise<void> {
  const [user, announcements] = await Promise.all([
    fetchUserProfile('atomgit'),
    fetchAnnouncements()
  ])
  this.user = user
  this.announcements = announcements
}

Promise.all 会在两个请求都完成之后再继续,比串行发两个请求快很多。


Q6:接口返回的数据里有 null,页面展示的时候崩溃了,怎么办?

这是最常见的运行时错误之一。API 字段可能是 null 的情况比你想象的多——用户没填简介、bio 就是 null;用户没有公开仓库,public_repos 可能是 null。

给所有可能为空的字段加默认值是最稳妥的做法:

bio: (raw.bio as string | null) ?? '这个人很懒,什么都没写'
followers: (raw.followers as number | null) ?? 0

在模板里也可以用条件渲染做兜底:

if (this.user && this.user.bio) {
  Text(this.user.bio).fontSize(13)
}

原则就是:永远不要假设 API 返回的数据是完整的。网络波动、接口变更、字段改名、版本迁移,任何一个环节都可能让你拿到一个 null 或者 undefined。提前兜底比事后修崩溃要省心得多,而且修复线上崩溃还需要用户更新 App,影响面更大。


七、运行效果

我们来看一下这个页面在不同状态下的实际效果。

7.1 页面打开,正在加载中

在这里插入图片描述

LoadingProgress 是 HarmonyOS 内置的加载动画,会自动旋转,你不需要写任何动画代码,只需要在 build() 里根据状态渲染它就好了。

7.2 数据加载成功,展示用户卡片

在这里插入图片描述

头像显示的是圆形裁剪后的 GitHub 用户头像。用户名用粗体大字突出展示,下面是统计数字,最下面是个人简介。点击按钮可以换成另一个用户(这里演示用的是 GitHub 官方账号)。

7.3 网络出错,显示错误提示

在这里插入图片描述

错误状态下的提示要尽量对用户友好。直接展示错误码 err.code: -1 之类的技术信息对普通用户来说毫无意义,要翻译成"网络连接失败,请检查网络设置"这样的自然语言。如果可能的话,给出下一步操作(比如重试按钮),让用户知道该怎么办。


八、扩展方向

学会了拉取一个用户的信息,恭喜你完成了 HarmonyOS 网络请求的入门。接下来可以从以下几个方向继续探索,每一个方向都对应着实际开发中常见的场景。

8.1 列表页:展示多个用户,支持分页

把单个用户卡片扩展成列表页,用 @ForEach 循环渲染多张用户卡片。这需要调用 GitHub 的搜索用户接口:https://api.github.com/search/users?q=关键词,它返回一个用户数组。

但这里要特别注意的是分页。GitHub API 每次最多返回 30 条数据,如果用户有 1000 个粉丝,不可能一次性全拉下来。正确的做法是:

  • 先拉前 30 条,展示到列表里
  • 用户下拉或者滚动到底部的时候,再拉第 31~60 条
  • 把新数据追加到现有列表后面,而不是覆盖

这就涉及到一个重要的 UI 模式:下拉刷新 + 上拉加载更多。HarmonyOS 的 List 组件有 onReachEnd 事件可以监听滚动到底部,配合加载状态的管理,可以实现流畅的分页体验。

8.2 搜索功能:按关键词实时查找用户

加一个搜索框,用户输入用户名,实时发送搜索请求。这里有一个性能陷阱:如果用户输入 “a” 就发一次请求,输入 “ab” 又发一次,输入 “abo” 又发一次——用户还没打完字,你已经发了三条请求了,而且后面的请求可能比前面的先返回,导致列表顺序乱掉。

解决方案是防抖(debounce):用户停止输入 300 毫秒后才发请求。如果用户还在继续输入,就取消上一次的计划,重新计时。这样用户打完一个名字,实际上只发了一次请求,大大减轻了服务器压力。

// 防抖逻辑示意
let timer: number | null = null
function onSearchInput(name: string): void {
  if (timer) {
    clearTimeout(timer)
  }
  timer = setTimeout(() => {
    this.loadSearch(name)
  }, 300)
}

8.3 本地缓存:避免重复请求同一个数据

同一个用户的信息可能在一次会话里被访问多次。比如用户从列表页点进详情页看了一遍,又退回来,再点进去——如果每次都重新发请求,既浪费流量,体验也不好(用户要多等一轮时间)。

解决方案是加一层内存缓存

const cache = new Map<string, UserProfile>()

function fetchUserProfile(username: string): Promise<UserProfile> {
  if (cache.has(username)) {
    return Promise.resolve(cache.get(username)!)
  }
  return realFetch(username).then(data => {
    cache.set(username, data)
    return data
  })
}

加了缓存之后,第一次请求走真实网络,请求结果存入 Map;后续请求直接读 Map,返回速度几乎是即时的。

如果想要持久化缓存(App 重启之后也能用),可以配合 HarmonyOS 的 persistent 或者 preferences 实现,把数据存到本地文件里。

8.4 完整的错误分类处理

现在的错误处理比较简单,就是一个通用的"网络请求失败"。实际项目中,错误可以分为很多种,每种的处理方式也不一样:

  • 网络不可达:提示"请检查网络连接",引导用户去设置页
  • 请求超时:提示"加载超时,请稍后重试",自动触发一次重试
  • 401 未认证:token 过期,跳转登录页面
  • 403 权限不足:提示"没有权限访问此内容"
  • 404 资源不存在:提示"未找到该用户"
  • 500 服务器内部错误:提示"服务器开小差了,请稍后再试",自动记录日志

把这些错误分门别类地处理,用户体验会好很多——不同的问题有不同的应对方式,而不是每次都弹同一个"出错了"。

8.5 请求拦截与统一封装

当应用里有十几个甚至几十个接口的时候,每个接口都单独写一遍 header、token、超时配置,维护成本会急剧上升。更好的做法是用请求拦截器统一处理:

  • 自动给每个请求加上 Authorization: token xxx
  • 自动给请求参数加上时间戳防止缓存
  • 统一处理 401 错误,自动跳转登录页
  • 统一打印请求日志,方便调试

这些都可以在封装层实现,业务代码完全不感知。对于大型应用来说,这套封装是必备的基础设施。


附:一个完整的工具函数封装示例

在实际项目中,我们通常会把所有请求相关的逻辑集中到一个 httpClient 文件里,这样整个应用的请求入口是统一的。下面给出一个更完整的封装思路,供大家参考:

// httpClient.ets - 统一请求封装
import { http } from '@kit.NetworkKit'

// 统一错误处理函数
function handleHttpError(code: number, message: string): never {
  switch (code) {
    case 401:
      throw new Error('登录已过期,请重新登录')
    case 403:
      throw new Error('没有权限访问该资源')
    case 404:
      throw new Error('请求的资源不存在')
    case 429:
      throw new Error('请求太频繁了,请稍后再试')
    default:
      throw new Error(`请求失败: ${message}`)
  }
}

// 通用的 GET 请求封装
function httpGet<T>(url: string, token?: string): Promise<T> {
  const req = http.createHttp()
  const headers: Record<string, string> = {
    'User-Agent': 'AtomGit-App',
    'Accept': 'application/json'
  }
  if (token) {
    headers['Authorization'] = `Bearer ${token}`
  }

  const option: http.HttpRequestOptions = {
    method: http.RequestMethod.GET,
    header: headers,
    connectTimeout: 10000,
    readTimeout: 15000,
    expectDataType: http.HttpDataType.OBJECT
  }

  return new Promise((resolve, reject) => {
    req.request(url, option, (err, data) => {
      req.destroy()
      if (err.code !== 0) {
        handleHttpError(err.code, err.message)
        return
      }
      resolve(data.result as T)
    })
  })
}

export { httpGet }

这段封装里有一个小细节:req.destroy()。HarmonyOS 的 HTTP 请求对象是有状态的,每次请求完成后如果不销毁,下次请求可能会复用上一次的状态,导致一些莫名其妙的问题(比如 header 混乱)。养成在回调里立即调用 destroy() 的习惯,是一个非常好的实践。

如果你在写一个团队项目,建议把这套封装方案做成一个独立的 HAR(Harmony Archive)包,让多个模块共享。这样每个开发者在自己的页面里只需要调用 httpGet<UserProfile>(url),完全不用关心底层的请求细节——URL 前缀、超时配置、token 管理这些都统一处理好了。


Logo

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

更多推荐