开篇:数据层的两条腿
任何一个真实的鸿蒙应用,最终都会落到两件事上:把远端的数据拿回来,把本地的状态存下来。前者依赖 @ohos.net.http、@ohos.net.webSocket 与 @ohos.net.connection 三大网络模块,后者依赖 @ohos.data.preferences、@ohos.data.relationalStore、@ohos.data.distributedKVStore 与文件系统。
很多开发者从 Android 或 Flutter 转过来时,会习惯性地把网络与存储当成两个互不相干的模块分别实现,结果在离线场景、Token 过期重试、跨页面缓存一致性上反复踩坑。本文的主张是:网络层与持久化层应该共同收敛到一个 Repository 层,由它统一决定"先读缓存还是先发请求"。
本文基于 HarmonyOS NEXT 的 API 12 及以上版本,示例使用 kit 形式的导入路径(如 @kit.NetworkKit),这些路径从 API 12 开始成为官方推荐写法,旧的 @ohos.net.http 写法仍然可用但不再推荐。如果你还没搭好工程,建议先看 HarmonyOS NEXT 全景与开发环境搭建
。
一、网络能力全景
鸿蒙的网络能力按职责拆成三个模块,理解它们的边界比记住 API 更重要。
| 模块 | 导入路径 | 核心职责 | 典型场景 |
|---|---|---|---|
| HTTP | @kit.NetworkKit 的 http | 一次性请求响应 | 拉列表、提交表单、上传文件 |
| WebSocket | @kit.NetworkKit 的 webSocket | 长连接双向通信 | 聊天、行情推送、协同编辑 |
| Connection | @kit.NetworkKit 的 connection | 网络状态与链路信息 | 断网提示、弱网降级、流量判断 |
1.1 http 基础用法
http.createHttp() 每次返回一个独立的 HttpRequest 实例,请求完成后必须调用 destroy() 释放,否则会持续占用底层资源。
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
export interface ApiResult<T> {
code: number;
message: string;
data: T;
}
export async function fetchArticleList(page: number, size: number): Promise<string> {
const request = http.createHttp();
try {
const response = await request.request('https://api.example.com/articles', {
method: http.RequestMethod.GET,
header: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
extraData: { page: page, size: size },
connectTimeout: 10000,
readTimeout: 30000,
expectDataType: http.HttpDataType.STRING
});
if (response.responseCode === http.ResponseCode.OK) {
return response.result as string;
}
throw new Error(`HTTP ${response.responseCode}`);
} catch (err) {
const e = err as BusinessError;
throw new Error(`request failed: ${e.code} ${e.message}`);
} finally {
request.destroy();
}
}
request() 的关键参数需要逐个理解:
method:http.RequestMethod枚举,含 GET、POST、PUT、DELETE、OPTIONS、HEAD、TRACE、CONNECT。extraData:POST 请求体。传入字符串时按字符串发送,传入Object时默认按 JSON 序列化。connectTimeout:连接超时,默认 60000 毫秒,建议压到 10000 以内以加快失败反馈。readTimeout:读取超时,默认 60000 毫秒,大文件下载需要放宽。expectDataType:期望的响应类型,STRING或OBJECT。设为OBJECT时框架会自动做一次JSON.parse,但在 ArkTS 静态类型下拿到的是Object,仍需显式转换。
1.2 权限声明
网络请求必须在 module.json5 中声明权限,漏声明会在运行时抛出错误而不是编译期报错,这是新手最容易卡住的地方。
{
"module": {
"name": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
}
]
}
}
ohos.permission.INTERNET 是普通权限,声明即可用;ohos.permission.GET_NETWORK_INFO 用于读取网络状态。两者都不需要弹窗申请,但必须出现在 requestPermissions 数组里。
1.3 常见错误码
| 错误码 | 含义 | 排查方向 |
|---|---|---|
| 401 | 参数错误 | 检查 method 与 extraData 组合是否合法 |
| 201 | 权限未声明 | 补 ohos.permission.INTERNET |
| 2300007 | 无法解析主机 | 域名拼写、DNS、是否需要代理 |
| 2300028 | 操作超时 | 放宽 connectTimeout 或检查弱网 |
| 2300052 | 服务器返回空 | 服务端异常,看服务端日志 |
二、封装一个可用的 HttpUtil
直接在每个页面里写 http.createHttp() 是灾难的开始。合理的做法是收敛成一个工具类,把超时、重试、统一错误、Token 注入集中处理。
2.1 统一配置与 Token 注入
import { http } from '@kit.NetworkKit';
import { preferences } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
export class HttpUtil {
private static readonly BASE_URL: string = 'https://api.example.com';
private static readonly MAX_RETRY: number = 2;
private token: string = '';
setToken(token: string): void {
this.token = token;
}
private buildHeader(): Record<string, string> {
const header: Record<string, string> = {
'Content-Type': 'application/json'
};
if (this.token.length > 0) {
header['Authorization'] = `Bearer ${this.token}`;
}
return header;
}
async get<T>(path: string, params?: Record<string, Object>): Promise<T> {
return this.send<T>(http.RequestMethod.GET, path, params);
}
async post<T>(path: string, body: Object): Promise<T> {
return this.send<T>(http.RequestMethod.POST, path, body);
}
private async send<T>(method: http.RequestMethod, path: string,
payload?: Object): Promise<T> {
let lastError: Error = new Error('unknown');
for (let attempt = 0; attempt <= HttpUtil.MAX_RETRY; attempt++) {
const request = http.createHttp();
try {
const response = await request.request(HttpUtil.BASE_URL + path, {
method: method,
header: this.buildHeader(),
extraData: payload,
connectTimeout: 10000,
readTimeout: 30000,
expectDataType: http.HttpDataType.STRING
});
if (response.responseCode === http.ResponseCode.OK) {
return JSON.parse(response.result as string) as T;
}
if (response.responseCode >= 500 && attempt < HttpUtil.MAX_RETRY) {
continue;
}
throw new Error(`HTTP ${response.responseCode}`);
} catch (err) {
const e = err as BusinessError;
lastError = new Error(`${e.code} ${e.message}`);
} finally {
request.destroy();
}
}
throw lastError;
}
}
2.2 关于重试的取舍
只在 5xx 与网络异常时重试,4xx 一律不重试。原因很直接:4xx 代表请求本身有问题,重试只会放大错误流量。重试间隔建议做指数退避,本文为保持示例简洁省略了定时器部分,生产代码可以用 setTimeout 叠加 Math.pow(2, attempt) 毫秒延迟。
2.3 在 ArkTS 中安全解析 JSON
ArkTS 禁用了 any,所以 JSON.parse 的返回值必须显式转换。推荐先用 interface 描述结构,再断言,并在字段缺失时给出默认值。
export interface Article {
id: number;
title: string;
tags: string[];
}
export function parseArticles(raw: string): Article[] {
const parsed = JSON.parse(raw) as Article[];
return parsed.map((item: Article) => {
return {
id: item.id,
title: item.title ?? '',
tags: item.tags ?? []
} as Article;
});
}
三、WebSocket 与网络状态
3.1 webSocket 长连接
import { webSocket } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class ChatClient {
private ws: webSocket.WebSocket = webSocket.createWebSocket();
connect(url: string): void {
this.ws.on('open', (err: BusinessError, value: Object) => {
console.info('ws opened');
});
this.ws.on('message', (err: BusinessError, value: string | ArrayBuffer) => {
console.info(`ws message: ${value}`);
});
this.ws.on('close', (err: BusinessError, value: webSocket.CloseResult) => {
console.info(`ws closed: ${value.code}`);
});
this.ws.connect(url, (err: BusinessError, value: boolean) => {
if (err) {
console.error(`connect failed: ${err.code}`);
}
});
}
send(text: string): void {
this.ws.send(text);
}
close(): void {
this.ws.close({ code: 1000, reason: 'client close' });
}
}
WebSocket 与 HTTP 的选择标准很简单:需要服务端主动推送、或需要高频双向交互时用 WebSocket,其余场景用 HTTP 轮询或长轮询即可,不必为了"看起来高级"引入长连接。
3.2 connection 网络状态监听
connection.createNetConnection() 返回一个 NetConnection,注册 netAvailable 与 netLost 两个事件即可拿到上下线通知,最后必须调用 register() 才生效。它的典型用途是在断网时把 UI 切到离线态,而不是用它来判断"这次请求能不能成功",因为状态回调本身有延迟。
四、持久化方案选型
鸿蒙提供四种主流持久化手段,选型的核心变量是数据规模、是否需要结构化查询、是否跨设备。
| 方案 | 数据模型 | 容量上限 | 查询能力 | 跨设备 | 适用场景 |
|---|---|---|---|---|---|
| Preferences | 键值对 | 单值建议小于 8KB | 仅按键读取 | 否 | Token、开关、主题、上次登录账号 |
| RelationalStore | 关系表 | 受设备存储限制 | 完整 SQL 与谓词 | 否 | 列表数据、订单、消息记录 |
| distributedKVStore | 分布式键值 | 受设备存储限制 | 按键与谓词 | 是 | 跨设备同步的配置与小数据 |
| 文件系统 | 字节流 | 受设备存储限制 | 无 | 否 | 图片、日志、导出文件、大 JSON 缓存 |
一句话原则:小配置用 Preferences,结构化数据用 RelationalStore,需要跨设备同步的小数据用 distributedKVStore,其余落文件。
五、Preferences 轻量键值存储
preferences 适合存少量配置。它的值类型被严格限制为 number、string、boolean 及其数组,不能直接存对象。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class SettingsStore {
private store: preferences.Preferences | null = null;
async init(context: common.UIAbilityContext): Promise<void> {
this.store = await preferences.getPreferences(context, 'app_settings');
}
async putToken(token: string): Promise<void> {
if (!this.store) {
return;
}
await this.store.put('token', token);
await this.store.flush();
}
async getToken(): Promise<string> {
if (!this.store) {
return '';
}
return (await this.store.get('token', '')) as string;
}
async clear(): Promise<void> {
if (!this.store) {
return;
}
await this.store.clear();
await this.store.flush();
}
}
两个细节必须记住:put 只是写入内存,必须调用 flush() 才会落盘;getPreferences 的第二个参数是文件名,同一个文件名在不同模块间会隔离。
六、RelationalStore 关系型数据库
relationalStore 是基于 SQLite 的关系型数据库,是本地数据量上规模后的唯一正解。
6.1 建库与建表
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
const STORE_CONFIG: relationalStore.StoreConfig = {
name: 'article.db',
securityLevel: relationalStore.SecurityLevel.S1,
encrypt: false
};
const CREATE_TABLE: string =
'CREATE TABLE IF NOT EXISTS article (' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'title TEXT NOT NULL, ' +
'tags TEXT, ' +
'cached_at INTEGER)';
export class ArticleDao {
private store: relationalStore.RdbStore | null = null;
async init(context: common.UIAbilityContext): Promise<void> {
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
await this.store.executeSql(CREATE_TABLE);
}
}
securityLevel 是鸿蒙特有的概念,取值 S1 到 S4,代表数据敏感度,系统据此决定是否允许跨设备同步与备份。普通业务数据用 S1 即可。
6.2 插入、查询与事务
async insert(article: Article): Promise<number> {
if (!this.store) {
return -1;
}
const bucket: relationalStore.ValuesBucket = {
'title': article.title,
'tags': article.tags.join(','),
'cached_at': Date.now()
};
return await this.store.insert('article', bucket);
}
async queryByTitle(keyword: string): Promise<Article[]> {
if (!this.store) {
return [];
}
const predicates = new relationalStore.RdbPredicates('article');
predicates.like('title', `%${keyword}%`);
predicates.orderByDesc('cached_at');
predicates.limitAs(20);
const resultSet = await this.store.query(predicates, ['id', 'title', 'tags']);
const list: Article[] = [];
while (resultSet.goToNextRow()) {
list.push({
id: resultSet.getLong(resultSet.getColumnIndex('id')),
title: resultSet.getString(resultSet.getColumnIndex('title')),
tags: resultSet.getString(resultSet.getColumnIndex('tags')).split(',')
} as Article);
}
resultSet.close();
return list;
}
async batchInsert(articles: Article[]): Promise<void> {
if (!this.store) {
return;
}
this.store.beginTransaction();
try {
for (const item of articles) {
await this.insert(item);
}
this.store.commit();
} catch (err) {
this.store.rollBack();
throw new Error('batch insert failed');
}
}
ResultSet 用完必须 close(),否则游标会一直占用数据库句柄,批量查询时很快会触发资源耗尽。
6.3 版本升级与迁移
RDB 的版本通过 StoreConfig 之外的 store.version 属性管理。升级时先读旧版本号,再逐级执行迁移语句,最后回写新版本号。
async upgrade(context: common.UIAbilityContext): Promise<void> {
const store = await relationalStore.getRdbStore(context, STORE_CONFIG);
const oldVersion = store.version;
if (oldVersion < 2) {
await store.executeSql('ALTER TABLE article ADD COLUMN author TEXT');
store.version = 2;
}
if (oldVersion < 3) {
await store.executeSql('CREATE INDEX idx_title ON article(title)');
store.version = 3;
}
}
七、distributedKVStore 与文件存储
distributedKVStore 的 API 比 Preferences 重,但它支持跨设备同步,适合"用户在手机上改的偏好,在平板上立即生效"这类需求。它的 KVManager 需要 ohos.permission.DISTRIBUTED_DATASYNC 权限,且必须在多设备协同的场景下才有意义,单设备应用不要引入。
文件存储用 @kit.CoreFileKit 的 fileIo。写 JSON 缓存的标准写法是拿到 context.filesDir 后拼接路径。
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
export function writeCache(context: common.UIAbilityContext, name: string, data: string): void {
const path = `${context.filesDir}/${name}`;
const file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.writeSync(file.fd, data);
fileIo.closeSync(file);
}
读取时先用 fileIo.accessSync 判断存在,再用 fileIo.statSync 拿到 size 分配 ArrayBuffer,readSync 之后必须用 util.TextDecoder.create('utf-8') 解码。ArkTS 不允许把 ArrayBuffer 直接当字符串用,util 来自 @kit.ArkTS。所有 openSync 返回的 File 都要配对 closeSync,否则 fd 会泄漏。
八、离线优先的数据层架构
把上面的模块拼成一个可用的 Repository 层,推荐的读取顺序是:先返回本地缓存让 UI 立刻有内容,再发起网络请求,成功后写回缓存并通知 UI 刷新。这套模式在弱网与冷启动场景下的体感差异非常明显。
| 阶段 | 动作 | 失败时的降级 |
|---|---|---|
| 读缓存 | 查 RDB 或文件 | 返回空列表并显示骨架屏 |
| 发请求 | HttpUtil 带重试 | 展示缓存 + 顶部提示离线 |
| 写缓存 | 事务写入 RDB | 记录日志,不阻塞 UI |
| 通知 | 状态变量赋值触发刷新 | 无 |
缓存失效策略上,建议给每条记录写入 cached_at 时间戳,读取时判断是否超过阈值(如 5 分钟),超过则强制走网络。这套逻辑与前端体系里的缓存语义一致,Flutter 侧的同类实现可参考 Flutter 异步与网络
。
网络请求本身是 IO 密集型任务,虽然 request() 返回的是 Promise 不会阻塞主线程,但大 JSON 的解析与批量写库是 CPU 密集型操作,放在主线程会掉帧。这类任务应该交给并发框架处理,具体做法见 鸿蒙并发模型 TaskPool 与 Worker
。
九、常见坑清单
- 未在
module.json5声明ohos.permission.INTERNET,编译通过但运行时必失败。 http.createHttp()实例未destroy(),长列表页反复请求后内存持续上涨。- Preferences 的
put后忘记flush(),杀进程后数据丢失。 - 试图把对象直接塞进 Preferences,运行时抛类型错误。
- RDB 的
ResultSet未close(),游标泄漏导致后续查询失败。 - 批量写入未包事务,逐条提交导致写放大与卡顿。
- 数据库
version改了但没写迁移语句,老用户升级后建表语句被跳过,字段缺失。 - 用
context.filesDir之外的手写路径写文件,沙箱限制导致权限拒绝。 - WebSocket 重连逻辑缺失,网络切换后连接静默失效。
- 在主线程同步解析几十 MB 的 JSON,造成明显掉帧。
小结
鸿蒙的数据层并不复杂,难的是把网络与持久化当成一个整体来设计。网络侧记住三件事:声明 INTERNET 权限、每次请求后 destroy()、4xx 不重试 5xx 才重试。持久化侧记住三件事:小配置用 Preferences 且必须 flush(),结构化数据用 RelationalStore 且 ResultSet 必须 close(),跨设备同步才引入 distributedKVStore。把这两条线收敛到 Repository 层,用 cached_at 时间戳控制缓存新鲜度,就能覆盖绝大多数离线优先场景。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。