鸿蒙网络请求与数据持久化

本文系统讲解 HarmonyOS NEXT 的数据层建设:用 @ohos.net.http 与 WebSocket 完成网络通信,用 Preferences、RelationalStore、distributedKVStore 与文件系统完成本地持久化。给出可复用的 HttpUtil 封装与 RDB 建表、事务示例,并用对照表说明四种存储方案的差异与高频坑。

开篇:数据层的两条腿

任何一个真实的鸿蒙应用,最终都会落到两件事上:把远端的数据拿回来,把本地的状态存下来。前者依赖 @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 时间戳控制缓存新鲜度,就能覆盖绝大多数离线优先场景。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理