HarmonyOS应用开发实战:猫猫大作战-http 模块创建与请求、GET/POST 方法差异、请求头与响应体解析、错误处理与超时
前言
前面 60 篇我们都在本地数据驱动 UI——@State、@Local、引擎 GameEngine。但实战应用要联网:拉取战绩排行榜、上报得分、下载猫咪皮肤、检查更新。HarmonyOS 提供@ohos.net.http(官方 HTTP 模块)做远程接口调用,类似 Web 的fetch/axios。
本篇以「猫猫大作战」拉取全球排行榜、上报本局得分为场景,把http 模块创建与请求、GET/POST 方法差异、请求头与响应体解析、错误处理与超时四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–60 篇。本篇是阶段四第一篇,进入「网络与数据」阶段。
一、场景拆解:拉排行榜 + 上报得分
「猫猫大作战」当前只显示本地highScore(第 31 篇)——玩家想看全球排行榜:
- 开游戏时从服务器拉 Top 10 排行。
- 结束游戏时上报本局得分到服务器。
// 预演:HttpService 拉排行榜 + 上报得分 import { http } from '@kit.NetworkKit'; export class HttpService { private httpRequest: http.HttpRequest = http.createHttp(); // GET 拉排行榜 async fetchLeaderboard(): Promise<LeaderRecord[]> { const res = await this.httpRequest.request('https://api.catgame.com/leaderboard', { method: http.RequestMethod.GET, header: { 'Content-Type': 'application/json' }, connectTimeout: 5000, readTimeout: 5000 }); if (res.responseCode === 200) { return JSON.parse(res.result) as LeaderRecord[]; } throw new Error(`拉排行榜失败: ${res.responseCode}`); } // POST 上报得分 async submitScore(score: number, combo: number): Promise<boolean> { const res = await this.httpRequest.request('https://api.catgame.com/score', { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify({ score, maxCombo: combo, time: Date.now() }), connectTimeout: 5000, readTimeout: 5000 }); return res.responseCode === 200; } }核心问题:
@ohos.net.http怎么创建请求、设置方法、发请求?- GET 和 POST 在参数传递、语义上有什么不同?
- 请求头、响应体怎么解析?
- 网络错误、超时怎么处理?
二、http 模块创建与请求
2.1 申请网络权限
修改entry/src/main/module.json5(或config.json):
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "$string:reason_internet", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } }关键经验:联网必先申请 INTERNET 权限——不申请运行时请求被拒,http.request抛权限错。
2.2 创建 HttpRequest
import { http } from '@kit.NetworkKit'; export class HttpService { private httpRequest: http.HttpRequest = http.createHttp(); // 每次请求复用同一实例(也可每次新建) }拆解:
| API | 作用 |
|---|---|
http.createHttp() | 创建 HttpRequest 实例,后续请求都调它 |
http.HttpRequest | 类型,实例化后调.request()发请求 |
2.3 发请求
const res: http.HttpResponse = await this.httpRequest.request(url, options);返回 HttpResponse:
| 字段 | 含义 |
|---|---|
responseCode | HTTP 响应码(200/404/500 等) |
result | 响应体(字符串或对象,看 header) |
header | 响应头对象 |
2.4 销毁实例
aboutToDisappear() { this.httpRequest.destroy(); // 组件销毁时释放 HTTP 实例,避泄漏 }关键经验:aboutToDisappear 销毁 HttpRequest——不销毁可能内存泄漏,特别是频繁切页的应用。
三、GET/POST 方法差异
3.1 GET 拉数据
// GET:参数拼在 URL const url = 'https://api.catgame.com/leaderboard?limit=10&offset=0'; const res = await this.httpRequest.request(url, { method: http.RequestMethod.GET, header: { 'Content-Type': 'application/json' } });GET 语义:
- 拉数据(读操作)。
- 参数拼 URL(query string)。
- 幂等(多次调同样效果)。
- 可缓存(浏览器/CDN 可缓存 GET 响应)。
- URL 长度限制(通常 < 2KB)。
3.2 POST 发数据
// POST:数据放 body const res = await this.httpRequest.request('https://api.catgame.com/score', { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify({ score: 1500, maxCombo: 5, time: Date.now() }) });POST 语义:
- 发数据(写操作/创建)。
- 数据放 body(
extraData)。 - 非幂等(多次调可能创建多条记录)。
- 不可缓存。
- body 无长度限制(受服务器配置)。
3.3 对比表
| 维度 | GET | POST |
|---|---|---|
| 语义 | 读 | 写/创建 |
| 参数位置 | URL query | body |
| 幂等 | ✅ | ❌ |
| 可缓存 | ✅ | ❌ |
| 长度 | < 2KB | 无限 |
| 安全 | 参数暴露 URL | body 隐藏 |
关键经验:「拉数据」用 GET,「发数据」用 POST——拉排行榜 GET,上报得分 POST。
3.4 其他方法
enum RequestMethod { OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT }| 方法 | 语义 | 本项目用 |
|---|---|---|
| GET | 读 | ✅ 拉排行榜 |
| POST | 创建 | ✅ 上报得分 |
| PUT | 更新(整体) | 可选:改用户信息 |
| DELETE | 删除 | 可选:删战绩 |
| PATCH | 更新(局部) | 可选:改昵称 |
四、请求头与响应体解析
4.1 请求头设置
const res = await this.httpRequest.request(url, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', // body 是 JSON 'Authorization': 'Bearer ' + this.token, // 鉴权令牌 'Accept': 'application/json', // 接受 JSON 响应 'User-Agent': 'CatGame/1.0 HarmonyOS' // 客户端标识 }, extraData: JSON.stringify({ /* ... */ }) });常用请求头:
| 头 | 作用 |
|---|---|
Content-Type | body 格式(application/json/form-urlencoded/multipart) |
Authorization | 鉴权(Bearer token / Basic auth) |
Accept | 期望的响应格式 |
User-Agent | 客户端标识 |
4.2 Content-Type 对照
// JSON body header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify({ score: 1500 }) // 表单 body(form-urlencoded) header: { 'Content-Type': 'application/x-www-form-urlencoded' }, extraData: 'score=1500&combo=5' // 文件上传(multipart/form-data) header: { 'Content-Type': 'multipart/form-data; boundary=----CatBoundary' }, extraData: '------CatBoundary\r\nContent-Disposition: form-data; name="file"; filename="cat.png"\r\n...'关键经验:Content-Type必须与 body 格式匹配——JSON 配 application/json,表单配 form-urlencoded,文件配 multipart。
4.3 响应体解析
const res = await this.httpRequest.request(url, { /* ... */ }); // 响应码 if (res.responseCode === 200) { // result 通常是字符串(JSON 字符串) const data = JSON.parse(res.result) as LeaderRecord[]; // 如果服务器返回 Content-Type: application/json,某些版本 result 直接是对象 // const data = res.result as LeaderRecord[]; }两种 result 类型:
| 服务器 Content-Type | result 类型 | 解析 |
|---|---|---|
application/json | 字符串(JSON 字符串)或对象(版本差异) | JSON.parse或直接用 |
text/html | 字符串 | 直接用 |
application/octet-stream | ArrayBuffer | 二进制处理 |
实战经验:保险起见JSON.parse+ try-catch——兼容 result 是字符串或对象的情况。
4.4 响应头读取
const res = await this.httpRequest.request(url, { /* ... */ }); // 读响应头 const contentType = res.header['Content-Type']; const serverVersion = res.header['X-Version']; const rateLimit = res.header['X-RateLimit-Remaining'];五、错误处理与超时
5.1 try-catch 错误处理
async fetchLeaderboard(): Promise<LeaderRecord[]> { try { const res = await this.httpRequest.request(url, { /* ... */ }); if (res.responseCode === 200) { return JSON.parse(res.result) as LeaderRecord[]; } else if (res.responseCode === 401) { throw new Error('未授权,请登录'); } else if (res.responseCode === 404) { throw new Error('接口不存在'); } else if (res.responseCode >= 500) { throw new Error('服务器错误'); } else { throw new Error(`HTTP ${res.responseCode}`); } } catch (error) { console.error(`拉排行榜失败: ${error.message}`); throw error; // 向上抛,UI 层处理 } }5.2 超时设置
const res = await this.httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: 5000, // 连接超时 5s readTimeout: 5000 // 读取超时 5s });| 超时 | 含义 | 推荐 |
|---|---|---|
connectTimeout | 建立连接超时 | 5000ms |
readTimeout | 读取响应超时 | 5000–10000ms |
关键经验:必设超时——不设可能卡死(网络不通时无限等)。
5.3 UI 层错误处理
@State leaderboard: LeaderRecord[] = []; @State loading: boolean = false; @State errorMsg: string = ''; async loadLeaderboard() { this.loading = true; this.errorMsg = ''; try { this.leaderboard = await this.httpService.fetchLeaderboard(); } catch (error) { this.errorMsg = error.message; // 用本地缓存兜底(第 48 篇 LazyForEach 思想) this.leaderboard = this.loadLocalLeaderboard(); } finally { this.loading = false; } } @Builder LeaderboardView() { Column() { if (this.loading) { Text('加载中...').fontSize(16).fontColor('#95A5A6') } else if (this.errorMsg !== '') { Column() { Text('⚠️ 加载失败').fontSize(16).fontColor('#E74C3C') Text(this.errorMsg).fontSize(12).fontColor('#95A5A6').margin({ top: 4 }) Button('重试').onClick(() => { this.loadLeaderboard(); }) .margin({ top: 12 }).backgroundColor('#3498DB') } } else { // 正常显示排行榜 ForEach(this.leaderboard, (record: LeaderRecord) => { /* ... */ }, (record: LeaderRecord) => record.rank) } } }关键经验:UI 层三态显示——loading(加载中)、error(失败+重试)、normal(正常显示)。
六、完整实战:HttpService + 排行榜页
6.1 创建 HttpService
新建entry/src/main/ets/services/HttpService.ets:
import { http } from '@kit.NetworkKit'; import { LeaderRecord, ScoreSubmit } from '../components/GameTypes'; export class HttpService { private httpRequest: http.HttpRequest = http.createHttp(); private readonly baseUrl: string = 'https://api.catgame.com'; // GET 拉排行榜 async fetchLeaderboard(limit: number = 10, offset: number = 0): Promise<LeaderRecord[]> { const url = `${this.baseUrl}/leaderboard?limit=${limit}&offset=${offset}`; try { const res = await this.httpRequest.request(url, { method: http.RequestMethod.GET, header: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + (this.token || '') }, connectTimeout: 5000, readTimeout: 5000 }); if (res.responseCode === 200) { return JSON.parse(res.result) as LeaderRecord[]; } else if (res.responseCode === 401) { throw new Error('未授权,请登录'); } else { throw new Error(`HTTP ${res.responseCode}`); } } catch (error) { console.error(`fetchLeaderboard 失败: ${(error as Error).message}`); throw error; } } // POST 上报得分 async submitScore(data: ScoreSubmit): Promise<boolean> { const url = `${this.baseUrl}/score`; try { const res = await this.httpRequest.request(url, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + (this.token || '') }, extraData: JSON.stringify(data), connectTimeout: 5000, readTimeout: 10000 }); if (res.responseCode === 200 || res.responseCode === 201) { return true; } else if (res.responseCode === 401) { throw new Error('未授权,请登录'); } else { throw new Error(`HTTP ${res.responseCode}`); } } catch (error) { console.error(`submitScore 失败: ${(error as Error).message}`); throw error; } } private token: string = ''; setToken(token: string) { this.token = token; } // 销毁 destroy() { this.httpRequest.destroy(); } }6.2 补充类型
修改entry/src/main/ets/components/GameTypes.ets:
export interface LeaderRecord { rank: number; playerName: string; score: number; maxCombo: number; date: string; } export interface ScoreSubmit { score: number; maxCombo: number; mergeCount: number; highestLevel: number; duration: number; // 用时(秒) date: string; }6.3 创建 LeaderboardPage
新建entry/src/main/ets/pages/LeaderboardPage.ets:
import { LeaderRecord } from '../components/GameTypes'; import { HttpService } from '../services/HttpService'; @Component export struct LeaderboardPage { @State leaderboard: LeaderRecord[] = []; @State loading: boolean = false; @State errorMsg: string = ''; private httpService: HttpService = new HttpService(); aboutToAppear() { this.loadLeaderboard(); } aboutToDisappear() { this.httpService.destroy(); // 销毁 HTTP 实例,避泄漏 } async loadLeaderboard() { this.loading = true; this.errorMsg = ''; try { this.leaderboard = await this.httpService.fetchLeaderboard(10, 0); } catch (error) { this.errorMsg = (error as Error).message; // 用本地缓存兜底 this.leaderboard = this.loadLocalLeaderboard(); } finally { this.loading = false; } } loadLocalLeaderboard(): LeaderRecord[] { // 简化:返回空数组或本地缓存(第 65 篇会专讲 Preference) return []; } build() { Column() { // 标题栏 Row() { Text('🏆 全球排行榜').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2C3E50') } .width('100%').height(56).padding({ left: 16 }) .justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center) // 内容区三态显示 if (this.loading) { Column() { LoadingProgress().width(48).height(48).color('#3498DB') Text('加载中...').fontSize(16).fontColor('#95A5A6').margin({ top: 12 }) } .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) } else if (this.errorMsg !== '' && this.leaderboard.length === 0) { Column() { Text('⚠️').fontSize(48).margin({ bottom: 12 }) Text('加载失败').fontSize(18).fontColor('#E74C3C').margin({ bottom: 4 }) Text(this.errorMsg).fontSize(12).fontColor('#95A5A6').margin({ bottom: 16 }) Button('重试') .width(120).height(40) .fontSize(16).fontColor('#FFFFFF').backgroundColor('#3498DB') .borderRadius(20) .onClick(() => { this.loadLeaderboard(); }) } .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) } else if (this.leaderboard.length === 0) { Column() { Text('📭').fontSize(48).margin({ bottom: 12 }) Text('暂无排行数据').fontSize(16).fontColor('#95A5A6') } .width('100%').layoutWeight(1) .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) } else { List() { ForEach(this.leaderboard, (record: LeaderRecord) => { ListItem() { Row() { // 排名 Text(`#${record.rank}`).fontSize(18).fontWeight(FontWeight.Bold) .fontColor(record.rank <= 3 ? '#E67E22' : '#7F8C8D') .width(60) // 玩家名 Text(record.playerName).fontSize(16).fontColor('#2C3E50') .layoutWeight(1) // 得分 Text(record.score.toString()).fontSize(18).fontWeight(FontWeight.Bold) .fontColor('#2ECC71') } .width('100%').height(56) .padding({ left: 16, right: 16 }) .border({ width: { bottom: 1 }, color: '#ECF0F1' }) } }, (record: LeaderRecord) => `rank_${record.rank}`) } .width('100%').layoutWeight(1) .divider({ strokeWidth: 1, color: '#ECF0F1', startMargin: 16, endMargin: 16 }) } // 错误时底部提示(有本地缓存兜底也显示) if (this.errorMsg !== '' && this.leaderboard.length > 0) { Text(`⚠️ ${this.errorMsg},显示本地缓存`) .fontSize(12).fontColor('#95A5A6') .padding({ left: 16, right: 16, bottom: 8 }) } } .width('100%').height('100%') .backgroundColor('#FFFFFF') } }6.4 Index 结束游戏时上报得分
// 来源:entry/src/main/ets/pages/Index.ets endGame(HTTP 改造后) import { HttpService } from '../services/HttpService'; @Entry @Component struct Index { /* ... 其他 state 和成员 */ private httpService: HttpService = new HttpService(); endGame() { this.gameState = GameState.GAME_OVER; this.maxCombo = this.gameEngine.getMaxCombo(); this.mergeCount = this.gameEngine.getMergeCount(); this.highestLevel = this.gameEngine.getHighestLevel(); if (this.score > this.highScore) { this.highScore = this.score; } this.clearTimers(); // 异步上报得分(不阻塞 UI,失败静默) this.submitScoreToServer(); } async submitScoreToServer() { try { const data: ScoreSubmit = { score: this.score, maxCombo: this.maxCombo, mergeCount: this.mergeCount, highestLevel: this.highestLevel, duration: this.gameTime, date: new Date().toISOString() }; const success = await this.httpService.submitScore(data); if (success) { console.info('得分上报成功'); } } catch (error) { // 静默失败,不阻塞玩家 console.error(`得分上报失败: ${(error as Error).message}`); } } aboutToDisappear() { this.clearTimers(); this.httpService.destroy(); // 销毁 HTTP 实例 } /* ... 其他方法 */ }七、踩坑提示
7.1 忘申请 INTERNET 权限
// ❌ 错误:没申请权限,请求被拒 // module.json5 里没 requestPermissions // ✅ 正确:申请 INTERNET { "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET", "reason": "$string:reason_internet" } ] } }7.2 忘设超时卡死
// ❌ 错误:没超时,网络不通时无限等 const res = await this.httpRequest.request(url, { method: http.RequestMethod.GET // 忠了 connectTimeout / readTimeout }); // ✅ 正确:必设超时 const res = await this.httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: 5000, readTimeout: 5000 });7.3 Content-Type 与 body 格式不匹配
// ❌ 错误:说是 JSON 但 body 是字符串拼接 header: { 'Content-Type': 'application/json' }, extraData: 'score=1500&combo=5' // 不是 JSON // ✅ 正确:JSON 配 JSON.stringify header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify({ score: 1500, combo: 5 })7.4 忘销毁 HttpRequest 泄漏
// ❌ 错误:没 destroy,组件销毁后实例泄漏 aboutToDisappear() { this.clearTimers(); // 忠了 this.httpService.destroy(); } // ✅ 正确:销毁释放 aboutToDisappear() { this.clearTimers(); this.httpService.destroy(); }7.5 UI 层忘错误处理
// ❌ 错误:没 try-catch,网络失败时崩溃 async loadLeaderboard() { this.leaderboard = await this.httpService.fetchLeaderboard(); // 抛错未捕 } // ✅ 正确:try-catch + 三态显示 async loadLeaderboard() { this.loading = true; try { this.leaderboard = await this.httpService.fetchLeaderboard(); } catch (error) { this.errorMsg = (error as Error).message; this.leaderboard = this.loadLocalLeaderboard(); // 缓存兜底 } finally { this.loading = false; } }7.6 上报得分阻塞 UI
// ❌ 错误:endGame 里 await 上报,玩家点结束要等上报完才看到结束弹窗 async endGame() { /* ... */ await this.submitScoreToServer(); // 阻塞 this.gameState = GameState.GAME_OVER; // 上报完才切,玩家等 } // ✅ 正确:异步上报不阻塞,失败静默 endGame() { this.gameState = GameState.GAME_OVER; // 先切结束态 /* ... */ this.submitScoreToServer(); // 不 await,异步上报 }八、调试技巧
console.info打响应码和体:追服务器响应,验证接口。- DevEco Network Inspector:查看请求/响应头和体,类似浏览器 Network 面板。
- 请求不发排查:检查 INTERNET 权限;检查 URL 是否对;检查是否在 aboutToAppear 调(组件未挂载)。
- 超时排查:检查 connectTimeout/readTimeout;检查网络是否通;检查服务器是否响应。
九、性能与最佳实践
- 联网必申请 INTERNET 权限——不申请请求被拒。
- 必设 connectTimeout/readTimeout——不设可能卡死。
- GET 拉数据,POST 发数据——语义清晰,幂等性正确。
- Content-Type 与 body 格式匹配——JSON 配 application/json + JSON.stringify。
- UI 层三态显示——loading/error/normal,错误时用本地缓存兜底。
- aboutToDisappear 销毁 HttpRequest——避泄漏。
- 异步上报不阻塞 UI——先切状态再异步上报,失败静默。
- 复用 HttpRequest 实例——不要每次请求新建,开销大。
十、阶段四进度开篇(61–65)
本篇是阶段四「网络与数据」第 1 篇,开篇覆盖:
| 篇 | 主题 | 核心要点 |
|---|---|---|
| 61(本篇) | HTTP 请求 | http 模块、GET/POST、错误处理 |
| 62 | REST/GraphQL | 接口设计取舍 |
| 63 | 数据序列化 | JSON/proto 创建解析 |
| 64 | SQLite 持久化 | 关系型数据存储 |
| 65 | Preference 键值 | 轻量配置存储 |
接下来阶段四还会覆盖:文件 IO、数据绑定、列表分页、下拉刷新、上拉加载、离线缓存等。
总结
本篇我们从 HTTP 请求切入,掌握了http 模块创建与请求(createHttp + request)、GET/POST 方法差异(读 vs 写,幂等 vs 非幂等)、请求头(Content-Type 必配 body)与响应体解析(JSON.parse)、**错误处理与超时(try-catch + 三态显示)**四大要点,并给出了 HttpService + 排行榜页 + 上报得分的完整代码。核心要点:联网先申请权限;必设超时;GET 拉数据 POST 发数据;UI 三态显示+缓存兜底;aboutToDisappear 销毁;异步上报不阻塞。
下一篇我们将拆解 REST/GraphQL——接口设计取舍。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/services/HttpService.ets、entry/src/main/ets/pages/LeaderboardPage.ets、entry/src/main/ets/pages/Index.ets - @ohos.net.http HTTP 模块官方指南
- HarmonyOS 网络权限申请官方文档
- HTTP RequestMethod 枚举官方文档
- HarmonyOS 网络编程最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md