华为鸿蒙开发高级篇05-网络层架构实战:统一请求层设计(新闻客户端案例)
高级系列第 5 篇 · 案例:新闻客户端——列表、详情两级页面,要求缓存、重试、并发限制与错误提示统一。 目标:把"到处写 http 请求"升级为统一请求层:请求封装、拦截器、缓存/重试/取消、错误码归一,一套代码服务所有页面。
一、案例背景
业务页面一多,网络代码会出现这些失控信号:
- 每个页面自己写
createHttp()/destroy(),连接泄漏时隐时现; - Token 过期、错误码提示各写各的,体验不一致;
- 断网、弱网场景没有统一兜底(缓存、重试);
- 并发请求没限制,弱网时把带宽和 CPU 打满。
本文以"新闻列表 + 详情"为案例,搭建一个可直接复制的统一请求层。
二、核心原理:请求层分层
页面(View)──> ApiClient(唯一入口)──> 拦截器(鉴权/日志)──> 底层请求(http/axios) │ ├── 缓存层(内存/磁盘) ├── 重试 & 并发控制 └── 错误归一(业务错误码 → 统一提示)原则:
- 页面只依赖 ApiClient 的领域方法(
getNewsList()),不感知 HTTP 细节。 - 拦截器收敛横切逻辑:Token、日志、错误上报都在这里。
- 错误在出口归一:网络异常、业务错误码统一转成
ApiException,UI 只做一次错误展示。
三、实际开发代码:统一请求层
3.1 领域模型与错误类型
// net/ApiTypes.ets export interface NewsItem { id: number title: string summary: string source: string publishTime: string url: string } // 业务统一异常:UI 只需读 message 展示 export class ApiException extends Error { code: number constructor(code: number, message: string) { super(message) this.code = code } } // 统一响应包装 export interface ApiResponse<T> { code: number message: string data: T }3.2 底层请求封装(http 封装)
// net/HttpClient.ets import { http } from '@kit.NetworkKit' import { ApiException, ApiResponse } from './ApiTypes' export class HttpClient { private baseURL: string private timeout: number constructor(baseURL: string, timeout = 15000) { this.baseURL = baseURL this.timeout = timeout } // 泛型请求方法:页面与业务层只认识 T,不认识 http async request<T>( path: string, method: http.RequestMethod, params?: Record<string, string | number | undefined>, body?: string ): Promise<T> { // 1) 拼 query let url = this.baseURL + path if (params) { const qs = Object.entries(params) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) .join('&') if (qs) url += (url.includes('?') ? '&' : '?') + qs } // 2) 发送请求(拦截器逻辑见 3.3,这里保持单纯) const req = http.createHttp() try { const resp = await req.request(url, { method, connectTimeout: this.timeout, readTimeout: this.timeout, header: { 'Content-Type': 'application/json' }, extraData: body }) // 3) 状态码归一 if (resp.responseCode !== 200) { throw new ApiException(resp.responseCode, `服务器异常(${resp.responseCode})`) } // 4) 业务码归一 const apiResp = JSON.parse(resp.result as string) as ApiResponse<T> if (apiResp.code !== 0) { throw new ApiException(apiResp.code, apiResp.message || '业务处理失败') } return apiResp.data } catch (e) { // 网络层错误也统一为 ApiException if (e instanceof ApiException) throw e throw new ApiException(-1, '网络异常,请检查网络连接') } finally { req.destroy() // 铁律:用完销毁 } } }3.3 拦截器:鉴权与日志
拦截器统一做"加 Token、打日志、上报错误":
// net/Interceptors.ets import { ApiException } from './ApiTypes' export class ApiInterceptor { // 请求前:注入 Token 等公共头(在 HttpClient.request 中调用) onRequest(headers: Record<string, string>): Record<string, string> { const token = AppStorage.get<string>('token') if (token) { headers['Authorization'] = `Bearer ${token}` } return headers } // 请求后:401 统一处理(如触发重新登录) onResponse(code: number): void { if (code === 401) { // 触发全局登出/跳登录页(按业务实现) AppStorage.set('token', '') } } // 统一错误上报(接打点/崩溃平台) onError(e: ApiException): void { console.error(`[API] code=${e.code} msg=${e.message}`) // 真实项目:上报到监控平台 } }把拦截器接入HttpClient.request(在发送前与收尾处各调用一次,见 3.4 的组合封装)。
3.4 ApiClient:页面唯一入口(缓存/重试/并发限制)
// net/ApiClient.ets import { http } from '@kit.NetworkKit' import { HttpClient } from './HttpClient' import { ApiInterceptor } from './Interceptors' import { ApiException, NewsItem } from './ApiTypes' export class ApiClient { private client: HttpClient = new HttpClient('https://api.example-news.com') private interceptor: ApiInterceptor = new ApiInterceptor() // 简单的内存缓存:path -> { time, data } private cache: Map<string, { time: number; data: object }> = new Map() private cacheTtl = 5 * 60 * 1000 // 5 分钟 private inflight: Map<string, Promise<object>> = new Map() // 去重进行中的请求 // 领域方法:页面直接调用 async getNewsList(page: number, useCache = true): Promise<NewsItem[]> { const path = '/v1/news/list' return this.request<NewsItem[]>({ path, method: http.RequestMethod.GET, params: { page, size: 20 }, useCache }) } async getNewsDetail(id: number): Promise<NewsItem> { return this.request<NewsItem>({ path: `/v1/news/${id}`, method: http.RequestMethod.GET, useCache: true }) } // 统一调度:缓存 → 去重 → 拦截器 → 底层请求 → 重试 private async request<T>(cfg: { path: string method: http.RequestMethod params?: Record<string, string | number | undefined> body?: string useCache?: boolean retries?: number }): Promise<T> { const { path, method, params, body, useCache = false, retries = 1 } = cfg const cacheKey = path + '?' + JSON.stringify(params ?? {}) // 1) 命中缓存直接返回 if (useCache) { const hit = this.cache.get(cacheKey) if (hit && Date.now() - hit.time < this.cacheTtl) { return hit.data as T } } // 2) 同一请求在途则复用(防止重复请求) const inflight = this.inflight.get(cacheKey) if (inflight) return inflight as Promise<T> // 3) 发起(带重试) const promise = this.doRequest<T>(cacheKey, path, method, params, body, retries, useCache) this.inflight.set(cacheKey, promise) try { return await promise } finally { this.inflight.delete(cacheKey) } } private async doRequest<T>( cacheKey: string, path: string, method: http.RequestMethod, params: Record<string, string | number | undefined> | undefined, body: string | undefined, retries: number, useCache: boolean ): Promise<T> { // 拦截器:请求前(注入头) const headers = this.interceptor.onRequest({ 'Content-Type': 'application/json' }) let lastError: Error | undefined for (let attempt = 0; attempt <= retries; attempt++) { try { const data = await this.client.request<T>(path, method, params, body) if (useCache) this.cache.set(cacheKey, { time: Date.now(), data }) return data } catch (e) { lastError = e as Error this.interceptor.onError(e as ApiException) if (e instanceof ApiException && e.code === 401) break // 鉴权失败不重试 // 网络类错误才重试,且非最后一次失败前稍等 if (attempt < retries) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))) } } } throw lastError } // 供下拉刷新手动清缓存 clearCache(): void { this.cache.clear() } }3.5 页面使用:干净的业务代码
// pages/NewsListPage.ets import { ApiClient } from '../net/ApiClient' import { NewsItem } from '../net/ApiTypes' @Entry @Component struct NewsListPage { private api: ApiClient = new ApiClient() @State news: NewsItem[] = [] @State errorMsg: string = '' @State loading: boolean = false private page: number = 1 aboutToAppear() { this.loadNews(true) } private async loadNews(refresh: boolean) { if (this.loading) return this.loading = true this.errorMsg = '' try { const list = await this.api.getNewsList(this.page, refresh ? true : false) this.news = refresh ? list : this.news.concat(list) if (refresh) this.page = 1 } catch (e) { this.errorMsg = (e as Error).message } finally { this.loading = false } } build() { Column() { List() { ForEach(this.news, (item: NewsItem) => { ListItem() { Column({ space: 4 }) { Text(item.title).fontSize(16).fontWeight(FontWeight.Medium) Text(item.summary).fontSize(13).fontColor('#86909C').maxLines(2) } .alignItems(HorizontalAlign.Start) .padding(12) .width('100%') } }, (item: NewsItem) => `${item.id}`) } .layoutWeight(1) if (this.errorMsg) { Text(this.errorMsg).fontColor('#E84026').fontSize(13).padding(8) } // 下拉刷新用 Refresh 包裹 List;加载更多在最后一项 onAppear 触发 } .width('100%').height('100%') } }四、优化与踩坑
| 问题 | 处理 |
|---|---|
| 连接泄漏 | 每个请求createHttp()+finally destroy(),封装层统一保证 |
| 缓存过期数据 | TTL 校验 + 下拉刷新时clearCache;详情页返回列表保持一致用同一缓存策略 |
| 弱网反复失败 | 网络错误重试 1~2 次 + 退避;业务错误(4xx/业务码)不重试 |
| 重复请求风暴 | inflightMap 去重,同一 key 在途请求复用同一 Promise |
| 401 循环 | 拦截器统一登出并 break 重试;刷新 Token 场景用"刷新队列"(一个在刷,其余等待) |
| 内存缓存无限增长 | 限制条数(如 LRU 或超 200 条清空) |
五、小结与延伸
- 统一请求层 = HttpClient(底层)+ 拦截器(横切)+ ApiClient(缓存/去重/重试)+ 领域方法(页面视角)。
- 案例沉淀:新闻列表/详情的缓存与刷新闭环,模板可直接套到任何"列表+详情"业务。
- 延伸:磁盘缓存(接第 6 篇 RDB 或 Preferences)、上传下载封装(进度回调 + 断点)、请求优先级与取消(离开页面时 abort 在途请求)。
下一篇预告:数据持久化进阶——关系型数据库 RDB 实战(笔记 App 数据层案例)。