鸿蒙 ArkUI 网络请求与数据持久化实战:从数据获取到本地存储+=

举报
yd_254122670 发表于 2025/10/19 15:33:05 2025/10/19
【摘要】 鸿蒙ArkUI网络请求与数据持久化实战:从数据获取到本地存储在鸿蒙应用开发中,“数据交互”是连接应用与服务端、保障离线可用的核心环节。开发者常面临“网络请求异步处理混乱”“不同场景不知选哪种存储方案”“离线数据同步冲突”等问题。本文将...

鸿蒙ArkUI网络请求与数据持久化实战:从数据获取到本地存储

在鸿蒙应用开发中,“数据交互”是连接应用与服务端、保障离线可用的核心环节。开发者常面临“网络请求异步处理混乱”“不同场景不知选哪种存储方案”“离线数据同步冲突”等问题。本文将系统讲解鸿蒙原生网络请求能力与数据持久化方案,通过 5 个实战案例覆盖“请求封装→数据解析→本地存储→离线缓存”全流程,帮你构建稳定、高效的数据交互层。

一、核心概念与技术选型

1. 网络请求技术栈

鸿蒙提供多套网络请求 API,需根据场景选择:

技术方案 适用场景 核心优势 局限性
@ohos.net.http 基础 HTTP/HTTPS 请求(GET/POST) 原生支持、轻量、无需第三方依赖 需手动封装拦截器、请求队列
@ohos.fetch 浏览器标准对齐的请求 语法与前端 fetch 一致,学习成本低 功能较基础,复杂场景需二次封装
第三方库(如 axios-harmony) 复杂业务(拦截器、取消请求) 支持拦截器、请求取消、重试机制 需引入第三方依赖,增加包体积

推荐选型:中小应用用@ohos.net.http(原生稳定),复杂应用用第三方封装库(提升开发效率)。本文以@ohos.net.http为核心讲解,兼顾实用性与原生特性。

2. 数据持久化技术栈

鸿蒙提供 3 类原生持久化方案,覆盖不同数据场景:

方案类型 适用场景 数据格式 核心 API
Preferences 轻量键值对存储(如用户设置、token) 基础类型(string/number/boolean 等) dataPreferencesManager
RelationalStore 结构化数据存储(如订单列表、用户信息) 表结构(类似 SQLite) relationalStoreManager
文件存储 大文件/二进制数据(如图片、日志、离线包) 任意二进制/文本格式 @ohos.file.fs

选型原则:“轻量用 Preferences,结构化用 RelationalStore,大文件用文件存储”,避免“用数据库存单个 token”或“用键值对存复杂列表”的不合理设计。

二、实战案例 1:网络请求封装与异步处理

场景需求

实现“商品列表获取”功能:包含加载状态、请求参数传递、响应解析、异常提示(网络错误、业务错误),并封装可复用的请求工具类。

核心逻辑

  1. 封装HttpManager工具类:统一处理请求头、BaseURL、异常捕获;
  2. async/await处理异步请求,避免回调嵌套;
  3. 区分“网络异常”(如无网、超时)与“业务异常”(如 token 过期、参数错误)。

完整代码实现

1. 封装网络请求工具类(CityModel.ets)

import { LocalTicketResponse } from '../model/ticketModel/TicketModel';

// 票务数据单例
export class TicketDataStore {
  private static instance: TicketDataStore;
  private ticketData: LocalTicketResponse; // 存储解析后的票务数据

  // 私有构造,初始化默认状态
  private constructor() {
    this.ticketData = {
      parseStatus: "no_data",
      message: "尚未查询票务数据"
    };
  }

  // 获取单例实例
  public static getInstance(): TicketDataStore {
    if (!TicketDataStore.instance) {
      TicketDataStore.instance = new TicketDataStore();
    }
    return TicketDataStore.instance;
  }

  // 存储票务数据
  public setData(data: LocalTicketResponse): void {
    this.ticketData = data;
  }

  // 获取票务数据
  public getData(): LocalTicketResponse {
    return this.ticketData;
  }

  // 清空数据
  public clearData(): void {
    this.ticketData = {
      parseStatus: "no_data",
      message: "数据已清空,等待新查询"
    };
  }
}

(TicketModel.ets)

// 1. 查询条件(出发/到达城市、日期)
export interface QueryCondition {
  departureCity: string; // 出发城市(与AI JSON字段一致)
  arrivalCity: string;   // 到达城市
  travelDate: string;    // 出行日期(yyyy-MM-dd)
}

// 2. 席别信息(二等座/一等座等)
export interface SeatType {
  seatName: string;      // 席别名称(如"二等座")
  price: string;         // 票价(含单位,如"553元")
  ticketStatus: string;  // 余票状态(如"有票"/"无票"/"候补")
}

// 3. 单条车次信息
export interface TicketItem {
  trainNo: string;               // 车次编号(如"G101")
  departureStation: string;      // 出发车站(如"北京南站")
  arrivalStation: string;        // 到达车站(如"上海虹桥站")
  departureTime: string;         // 出发时间(yyyy-MM-dd HH:mm:ss)
  arrivalTime: string;           // 到达时间(yyyy-MM-dd HH:mm:ss)
  duration: string;              // 历时(如"6小时30分")
  seatTypes: SeatType[];         // 该车次的席别列表
}

// 4. 元数据(数据来源、生成时间)
export interface TicketMetadata {
  dataGenerateTime: string;  // 数据生成时间(yyyy-MM-dd HH:mm:ss)
  dataSource: string;        // 数据来源(如"12306-MCP对接数据")
  status: "success" | "fail";// 数据状态
}

// 5. AI返回的完整票务JSON结构
export interface AITicketResponse {
  queryCondition: QueryCondition; // 查询条件
  ticketList: TicketItem[];       // 车次列表
  metadata: TicketMetadata;       // 元数据
  tips: string[];                 // 提示信息
}

// 6. 本地解析结果模型(含状态,方便UI判断)
export interface LocalTicketResponse {
  parseStatus: "success" | "fail" | "no_data"; // 解析状态
  message: string;                             // 状态描述
  data?: AITicketResponse;                     // 结构化票务数据
}

(ReceiveModel.ets)

/**
 * 华为云IAM认证接口模型
 *
 */
export interface AuthResponse {

  token: Token;
}

/**
 * Token核心数据结构(JSON中token对象的内容)
 */
export interface Token {
  // token过期时间
  expires_at: string;
  methods: string[];

}

export interface data {
  exent: string
  content: string
  createdTime: string
}

(TokenModel.ets)

//调取请求AIM获取token的请求体
class Domain{
  name:string ="账户名"
}
class User{
  domain:Domain = new Domain()
  name:string = "用户名"
  password:string = "用户密码"
}
class Password{
  user:User = new User()
}
class Identity{
  methods:string[] = ["password"]
  password:Password = new Password()
}
class Project{
  id:string = "项目ID"
}
class Scope{
  project:Project = new Project()
}




interface GeneratedTypeLiteralInterface_1 {
  identity: Identity;
  scope: Scope;
}

export class auth{
  auth:GeneratedTypeLiteralInterface_1 ={
    identity:new Identity(),
    scope:new Scope()
  }

}

//ai参数
interface  GeneratedTypeLiteralInterface_2{
  query:  string
}
export  class Inputs{
  inputs: GeneratedTypeLiteralInterface_2={
    query:'你好'
  }
}

2. 封装api(http_ai.ets)

import { rcp } from '@kit.RemoteCommunicationKit'
import { auth, Inputs } from '../../../model/tokenApi/TokenModel'

const url =  'ai链接'
export async function get_ai(token:string,a:Inputs){
  try {
    const headers:rcp.RequestHeaders = {
      'X-Auth-Token':token,
      'Content-Type': 'application/json'
    }
    const  security:rcp.SecurityConfiguration = {
      remoteValidation:'skip'
    }
    const session =  rcp.createSession({
      headers:headers,
      requestConfiguration:{
        security:security
      }
    })

    const post_ai =await session.post(url,a)
    return post_ai;
  }catch (e) {
    console.log('ai回答_get_ai_error'+JSON.stringify(e))
    return null
  }
}


const url_token = token链接'
export async function token_api():Promise<rcp.Response | undefined>{
  const headers: rcp.RequestHeaders = {
    'Content-Type': 'application/json'
  }
  const session = rcp.createSession({
    headers: headers
  })
  try {

    return await session.post(url_token, new auth() )

  } catch (e) {
    console.error(JSON.stringify(e) + "ai调用3")
  }
  return undefined
}

3. 工具类

(getData.ets)

import { AITicketResponse, LocalTicketResponse } from '../model/ticketModel/TicketModel';
import { Inputs } from '../model/tokenApi/TokenModel';
import { get_ai } from '../pages/http/api/http_ai';
import { SSEParser } from './SSEParser';
import { Token_management } from './getToken';
import { TicketDataStore } from './TicketDataStore';
import { common } from '@kit.AbilityKit';


let aiData: AITicketResponse | null = null
let  token: string | undefined = ''
let  tokenManager: Token_management | null = null;
let  ticketData: LocalTicketResponse = TicketDataStore.getInstance().getData();
/**
 * 获取数据
 * @param fromStation 传入始站
 * @param toStation 传入终点
 * @param date 传入日期
 * @param context 传入上下文 示例:getContext(this) as common.UIAbilityContext
 * @returns : AITicketResponse | null 返回数据
 */
export  async function getData(fromStation:string,toStation:string,date:string,context: common.UIAbilityContext): Promise<AITicketResponse | null> {
  try {
    tokenManager = new Token_management(context)
    await tokenManager.init()
    token = await tokenManager.loadToken()



    const aa = new Inputs();
    aa.inputs.query = `{
  "fromStation": ${fromStation},
  "toStation": ${toStation},
  "date": ${date}
}`
    await get_ai(token as string, aa).then(async (car) => {
      console.log("ai对话 token" + token)
      ticketData = SSEParser.parseAndStore(car?.toString() as string)
      console.log("ai对话 解析成功" + ticketData.message)
      aiData = ticketData.data as AITicketResponse
      return aiData
    })
  } catch (error) {
    ticketData = {
      parseStatus: "fail",
      message: `查询失败:${(error as Error).message}`
    };
  };
return aiData
}

(getToken.ets)

import { preferences } from "@kit.ArkData"
import { common } from "@kit.AbilityKit"
import { token_api } from "../pages/http/api/http_ai"
import { BusinessError } from "@kit.BasicServicesKit"
import { util } from "@kit.ArkTS"
import { AuthResponse } from "../model/tokenApi/ReceiveModel"

interface returnData {
  token_get: string
  time_get: string
}

export class Token_management {
  private name: string = 'Token'
  private preferences: preferences.Preferences | null = null
  private context: common.UIAbilityContext

  // 外部调用方传入上下文
  constructor(context: common.UIAbilityContext) {
    this.context = context
  }

  //初始化preferences
  async init(): Promise<boolean> {
    try {
      this.preferences = await preferences.getPreferences(this.context, this.name)
      console.log('初始化成功')
      return true
    } catch (err) {
      return false
    }
  }

  //调用获取token的api
  async get_new_token(): Promise<returnData | null> {
    if (!this.preferences) {
      return null
    }

    let token_get: string = ''
    let time_get: string = ''
    try {
      await token_api().then(response => {
        console.log('获取token' + response)
        token_get = response?.headers['x-subject-token'] as string;
        time_get = buf2String(response?.body).token.expires_at
      })

      if (token_get && time_get) {
        console.log('get_new_token' + "获取成功")
        return { token_get, time_get }
      } else {
        console.log("返回值为空")
        return null
      }
    } catch (err) {
      console.error('token_get' + err)
      return null
    }
  }

  /**
   * 储存新的token
   */
  async set_token(): Promise<string | undefined> {
    if (!this.preferences) {
      console.log(this.preferences + '')
      return undefined
    }
    //判读本地是否有token
    if (this.preferences?.hasSync('token') && this.preferences.hasSync('time')) {
      const storedToken = this.preferences.getSync('token', '') as string;
      const storedExpireTime = this.preferences.getSync('time', '') as string;
      //判断是否过期
      if (!this.is_overdue(storedExpireTime)) {
        console.log('token可以直接使用')
        return storedToken
      } else {
        //已经过期重新将新的放在容器中 获取新的token
        const new_token_vessel = await this.get_new_token()
        const new_token = new_token_vessel?.token_get
        const new_time = new_token_vessel?.time_get
        //将获取到的token等持久化
        this.persistent_token(new_token, new_time)
        return new_token
      }
    } else {
      //本地并没有储存调用接口来进行储存
      const new_token_vessel = await this.get_new_token()
      const new_token = new_token_vessel?.token_get
      const new_time = new_token_vessel?.time_get
      //将获取到的token等持久化
      this.persistent_token(new_token, new_time)
      return new_token
    }
  }

  /**
   * 拿到token可以自动判断是否过期
   * 获取token
   */
  async get_token(): Promise<string | null> {
    try {
      if (this.preferences?.hasSync('token') && this.preferences.hasSync('time')) {
        const storedExpireTime = this.preferences.getSync('time', 'defValue') as string;
        if (!this.is_overdue(storedExpireTime)) {
          //没有过期可以直接使用
          const token_get = this.preferences.getSync('token', 'defValue')
          return token_get as string
        } else {
          //已经过期或不存在
          return null
        }
      } else {
        console.log('没有本地储存,进行储存')
        // this.persistent_token()
        return null
      }
    } catch (err) {
      console.log('获取token失败' + err)
      return null
    }
  }

  //将获取到的token等持久化
  async persistent_token(new_token: string | undefined, new_time: string | undefined): Promise<boolean> {
    if (!this.preferences) {
      return false
    }
    if (new_token && new_time) {
      this.preferences.putSync(new_token, 'new_token')
      this.preferences.putSync(new_time, 'new_time')

      await new Promise<void>((resolve, reject) => {
        this.preferences!.flush((err: BusinessError | undefined) => {
          if (err) {
            console.error(`Token持久化失败: 代码=${err.code}, 消息=${err.message}`);
            reject(err);
          } else {
            console.info('Token已成功持久化到本地');
            resolve();
          }
        });
      });

      return true
    } else {
      return false
    }
  }

  //判断token是否过期如果过期就重新获取token
  async is_overdue(End_time: string): Promise<boolean> {
    try {
      const end_time = new Date(End_time)
      const Current_time = new Date()
      return end_time.getTime() < Current_time.getTime()
    } catch (err) {
      console.log(err + '时间比对失败')
      return true
    }
  }

  //获取当前时间
  get_Current_time(): string {
    return new Date().toISOString()
  }

  async loadToken(): Promise<string | undefined> {
    if (!this.get_token() == null) {
      //可以正常获取到token
      const token_get = await this.preferences?.getSync('token', 'defValue') as string
      return token_get
    } else {
      //token过期或不存在时调用储存方法来重新获取
      return await this.set_token()
    }
  }
}

//接收返回数据进行解析
function buf2String(buf: ArrayBuffer | undefined) {
  let msgArray = new Uint8Array(buf as ArrayBuffer);
  let textDecoder = util.TextDecoder.create("utf-8");
  let rString = textDecoder.decodeWithStream(msgArray);
  return JSON.parse(rString) as AuthResponse;
}

(SSEParser.ets)

import { AITicketResponse, LocalTicketResponse } from '../model/ticketModel/TicketModel';
import { TicketDataStore } from './TicketDataStore';

// SSE原始事件结构
interface RawSSEEvent {
  event: string; // 事件类型(如"summary_response"/"message")
  content?: string; // 核心内容
  role?: string; // 可选字段
  createdTime?: number; // 可选字段
}

// SSE解析器
export class SSEParser {
  // 主方法:解析SSE数据 → 结构化票务数据 → 存入全局存储
  public static parseAndStore(sseData: string): LocalTicketResponse {
    try {
      // 1. 筛选有效SSE行(仅保留"data:"开头且非空的行)
      const validLines = sseData.split('\n')
        .map(line => line.trim())
        .filter(line => line.startsWith('data:') && line.slice(5).trim());
      if (validLines.length === 0) {
        const res: LocalTicketResponse = {
          parseStatus: "no_data",
          message: "SSE响应无有效内容"
        };
        TicketDataStore.getInstance().setData(res);
        return res;
      }

      // 2. 优先解析"summary_response"事件
      for (const line of validLines) {
        const sseEvent = SSEParser.parseSingleLine(line);
        if (sseEvent?.event === 'summary_response' && sseEvent.content) {
          console.log('原始content:', sseEvent.content); // 打印原始内容
          const cleanedContent = SSEParser.cleanContent(sseEvent.content);
          if (!cleanedContent) {
            throw new Error("清理后无有效JSON内容");
          }
          console.log('清理后content:', cleanedContent);
          // 解析纯净的JSON
          const aiData = JSON.parse(cleanedContent) as AITicketResponse;
          const res: LocalTicketResponse = {
            parseStatus: "success",
            message: "解析成功(来源:summary_response,已清理格式)",
            data: aiData
          };
          TicketDataStore.getInstance().setData(res);
          return res;
        }
      }

      // 3. 解析"message"事件(多段拼接)
      const fullContent = validLines
        .map(line => SSEParser.parseSingleLine(line))
        .filter(event => event?.event === 'message')
        .map(event => event?.content || '')
        .join('');

      if (fullContent) {
        const aiData = JSON.parse(fullContent) as AITicketResponse;
        const res: LocalTicketResponse = {
          parseStatus: "success",
          message: "解析成功(来源:message拼接)",
          data: aiData
        };
        TicketDataStore.getInstance().setData(res);
        return res;
      }

      // 4. 无匹配事件
      const noMatchRes: LocalTicketResponse = {
        parseStatus: "no_data",
        message: "未找到summary_response或message事件"
      };
      TicketDataStore.getInstance().setData(noMatchRes);
      return noMatchRes;

    } catch (error) {
      // 解析异常(如JSON格式错误)
      const errMsg = error instanceof Error ? error.message : "未知错误";
      const errRes: LocalTicketResponse = {
        parseStatus: "fail",
        message: `SSE解析失败:${errMsg}`
      };
      TicketDataStore.getInstance().setData(errRes);
      return errRes;
    }
  }

  /**
   * 清理content格式:移除```json开头、```结尾及多余换行/空格
   * @param rawContent - 原始content字符串
   * @returns 纯净的JSON字符串(失败返回空)
   */
  private static cleanContent(rawContent: string): string {
    if (!rawContent) {
      return "";
    }
    // 1. 移除开头的换行符(\n)和空格
    let cleaned = rawContent.trimStart();
    // 2. 移除开头的```json标记(不区分大小写,兼容可能的格式差异)
    const startMarker = /^```json/i; // 正则:匹配开头的```json(忽略大小写)
    cleaned = cleaned.replace(startMarker, "").trimStart();
    // 3. 移除结尾的```标记(可能带换行或空格)
    const endMarker = /```\s*$/; // 正则:匹配结尾的```及后续空格/换行
    cleaned = cleaned.replace(endMarker, "").trimEnd();
    return cleaned;
  }

  // 辅助方法:解析单行SSE(移除"data:"前缀,转JSON)
  private static parseSingleLine(line: string): RawSSEEvent | null {
    try {
      const jsonStr = line.slice(5).trim();
      return JSON.parse(jsonStr);
    } catch (e) {
      console.warn(`单行SSE解析失败(跳过): ${line}`);
      return null;
    }
  }
}

(TicketDataStore.ets)

import { LocalTicketResponse } from '../model/ticketModel/TicketModel';

// 票务数据单例
export class TicketDataStore {
  private static instance: TicketDataStore;
  private ticketData: LocalTicketResponse; // 存储解析后的票务数据

  // 私有构造,初始化默认状态
  private constructor() {
    this.ticketData = {
      parseStatus: "no_data",
      message: "尚未查询票务数据"
    };
  }

  // 获取单例实例
  public static getInstance(): TicketDataStore {
    if (!TicketDataStore.instance) {
      TicketDataStore.instance = new TicketDataStore();
    }
    return TicketDataStore.instance;
  }

  // 存储票务数据
  public setData(data: LocalTicketResponse): void {
    this.ticketData = data;
  }

  // 获取票务数据
  public getData(): LocalTicketResponse {
    return this.ticketData;
  }

  // 清空数据
  public clearData(): void {
    this.ticketData = {
      parseStatus: "no_data",
      message: "数据已清空,等待新查询"
    };
  }
}

关键注意

  • finally确保加载状态必隐藏,避免“加载中”一直显示;
  • GET 请求参数需手动拼接在 URL 后(鸿蒙http的 GET 不支持extraData);
  • 分页数据处理时,第一页覆盖列表,后续页追加,符合用户体验。

三、实战案例 2:数据持久化方案(3 种场景)

场景 1:Preferences(键值对存储)—— 保存用户 Token 与设置

需求

登录成功后保存用户 Token(用于后续请求鉴权),保存用户设置(如“是否开启推送”),应用重启后仍能读取。

实现代码(PreferencesManager.ts)

import dataPreferences from '@ohos.data.preferences';
import { BusinessError } from '@ohos.base';

// Preferences存储实例名(建议按业务模块划分)
const PREFERENCES_NAME = 'app_user_preferences';

// 存储键名常量(避免硬编码)
export enum PreferencesKeys {
  USER_TOKEN = 'user_token',
  PUSH_ENABLED = 'push_enabled',
  LAST_LOGIN_TIME = 'last_login_time'
}

/**
 * 获取Preferences实例(单例)
 */
async function getPreferencesInstance() {
  try {
    // 获取应用沙箱内的Preferences实例(上下文需传入当前页面/组件的context)
    const context = getContext(this) as any; // 实际使用时需替换为组件的this.context
    return await dataPreferences.getPreferences(context, PREFERENCES_NAME);
  } catch (error) {
    const err = error as BusinessError;
    console.error(`获取Preferences实例失败:code=${err.code}, message=${err.message}`);
    throw error;
  }
}

/**
 * 保存键值对(支持string/number/boolean等基础类型)
 * @param key 存储键名(来自PreferencesKeys)
 * @param value 存储值
 */
export async function saveToPreferences<T>(key: string, value: T) {
  try {
    const preferences = await getPreferencesInstance();
    // 根据值类型调用不同方法(Preferences需区分类型)
    switch (typeof value) {
      case 'string':
        await preferences.putString(key, value as string);
        break;
      case 'number':
        await preferences.putNumber(key, value as number);
        break;
      case 'boolean':
        await preferences.putBoolean(key, value as boolean);
        break;
      default:
        throw new Error(`不支持的存储类型:${typeof value}`);
    }
    // 提交修改(Preferences需显式提交才会持久化)
    await preferences.flush();
    console.log(`Preferences保存成功:${key}=${value}`);
  } catch (error) {
    console.error(`Preferences保存失败:${key},原因:`, error);
    throw error;
  }
}

/**
 * 从Preferences读取值
 * @param key 存储键名
 * @param defaultValue 默认值(当值不存在时返回)
 */
export async function getFromPreferences<T>(key: string, defaultValue: T): Promise<T> {
  try {
    const preferences = await getPreferencesInstance();
    // 检查键是否存在
    const hasKey = await preferences.hasKey(key);
    if (!hasKey) {
      return defaultValue;
    }

    // 根据默认值类型读取(默认值类型即目标类型)
    switch (typeof defaultValue) {
      case 'string':
        return (await preferences.getString(key)) as T;
      case 'number':
        return (await preferences.getNumber(key)) as T;
      case 'boolean':
        return (await preferences.getBoolean(key)) as T;
      default:
        throw new Error(`不支持的读取类型:${typeof defaultValue}`);
    }
  } catch (error) {
    console.error(`Preferences读取失败:${key},原因:`, error);
    return defaultValue; // 异常时返回默认值,避免应用崩溃
  }
}

/**
 * 删除Preferences中的键值对(如退出登录时删除Token)
 */
export async function removeFromPreferences(key: string) {
  try {
    const preferences = await getPreferencesInstance();
    await preferences.delete(key);
    await preferences.flush();
    console.log(`Preferences删除成功:${key}`);
  } catch (error) {
    console.error(`Preferences删除失败:${key},原因:`, error);
    throw error;
  }
}

// 快捷方法:获取Token(供网络请求使用)
export async function getTokenFromPreferences(): Promise<string> {
  return getFromPreferences(PreferencesKeys.USER_TOKEN, '');
}

// 快捷方法:保存Token
export async function saveTokenToPreferences(token: string) {
  return saveToPreferences(PreferencesKeys.USER_TOKEN, token);
}

使用示例(登录页面保存 Token)

// 登录成功后保存Token和登录时间
async function handleLogin(userName: string, password: string) {
  const loginResult = await httpManager.post<{ token: string }>('/user/login', { userName, password });
  // 保存Token
  await saveTokenToPreferences(loginResult.token);
  // 保存登录时间(时间戳)
  await saveToPreferences(PreferencesKeys.LAST_LOGIN_TIME, Date.now());
  // 跳转首页
  router.pushUrl({ url: 'pages/Index' });
}

关键注意

  • Preferences 需显式调用flush()才会持久化,否则仅在内存中;
  • 存储键名建议用枚举(PreferencesKeys)管理,避免拼写错误;
  • 读取时必须传入默认值,确保返回类型安全。

场景 2:RelationalStore(数据库)—— 存储结构化商品数据

需求

将网络获取的商品列表存储到本地数据库,支持离线查询、按价格筛选、批量更新库存,适合结构化、多条件查询的数据。

实现代码(ProductDatabase.ts)

import relationalStore from '@ohos.data.relationalStore';
import { BusinessError } from '@ohos.base';

// 数据库名与版本号
const DB_NAME = 'product_db.db';
const DB_VERSION = 1;

// 商品表结构定义
export const PRODUCT_TABLE = {
  NAME: 'products', // 表名
  COLUMNS: {
    ID: 'id', // 主键(商品ID)
    NAME: 'name', // 商品名称
    PRICE: 'price', // 价格(浮点型)
    IMG_URL: 'img_url', // 图片URL
    STOCK: 'stock', // 库存(整数)
    UPDATE_TIME: 'update_time' // 最后更新时间(时间戳)
  }
};

// 数据库实例(单例)
let dbInstance: relationalStore.RdbStore | null = null;

/**
 * 初始化数据库(创建表、升级处理)
 */
async function initDatabase() {
  if (dbInstance) return dbInstance;

  try {
    const context = getContext(this) as any; // 替换为组件的context
    // 打开数据库(不存在则创建)
    dbInstance = await relationalStore.getRdbStore(context, {
      name: DB_NAME,
      version: DB_VERSION,
      // 数据库升级回调(版本号提升时触发)
      upgrade: (oldVersion, newVersion, db) => {
        console.log(`数据库升级:${oldVersion}${newVersion}`);
        // 示例:V1→V2时添加新字段
        if (oldVersion < 2) {
          db.executeSql(`ALTER TABLE ${PRODUCT_TABLE.NAME} ADD COLUMN ${PRODUCT_TABLE.COLUMNS.UPDATE_TIME} INTEGER`);
        }
      }
    });

    // 创建商品表(若不存在)
    const createTableSql = `
      CREATE TABLE IF NOT EXISTS ${PRODUCT_TABLE.NAME} (
        ${PRODUCT_TABLE.COLUMNS.ID} TEXT PRIMARY KEY,
        ${PRODUCT_TABLE.COLUMNS.NAME} TEXT NOT NULL,
        ${PRODUCT_TABLE.COLUMNS.PRICE} REAL NOT NULL,
        ${PRODUCT_TABLE.COLUMNS.IMG_URL} TEXT NOT NULL,
        ${PRODUCT_TABLE.COLUMNS.STOCK} INTEGER NOT NULL,
        ${PRODUCT_TABLE.COLUMNS.UPDATE_TIME} INTEGER NOT NULL DEFAULT ${Date.now()}
      )
    `;
    await dbInstance.executeSql(createTableSql);
    console.log('数据库初始化成功,商品表已创建');
    return dbInstance;
  } catch (error) {
    const err = error as BusinessError;
    console.error(`数据库初始化失败:code=${err.code}, message=${err.message}`);
    throw error;
  }
}

/**
 * 批量插入/更新商品数据(存在则更新,不存在则插入)
 * @param products 商品数组
 */
export async function batchUpsertProducts(products: Product[]) {
  const db = await initDatabase();
  const valuesArray: relationalStore.ValuesBucket[] = [];

  // 构建插入/更新的数据
  products.forEach(product => {
    const values: relationalStore.ValuesBucket = {
      [PRODUCT_TABLE.COLUMNS.ID]: product.id,
      [PRODUCT_TABLE.COLUMNS.NAME]: product.name,
      [PRODUCT_TABLE.COLUMNS.PRICE]: product.price,
      [PRODUCT_TABLE.COLUMNS.IMG_URL]: product.imgUrl,
      [PRODUCT_TABLE.COLUMNS.STOCK]: product.stock,
      [PRODUCT_TABLE.COLUMNS.UPDATE_TIME]: Date.now()
    };
    valuesArray.push(values);
  });

  try {
    // 批量操作(replace:存在则替换,不存在则插入)
    const result = await db.batchReplace(PRODUCT_TABLE.NAME, valuesArray);
    console.log(`批量操作成功,影响行数:${result}`);
    return result;
  } catch (error) {
    console.error('商品批量插入/更新失败:', error);
    throw error;
  }
}

/**
 * 按条件查询商品(如价格小于1000的商品)
 * @param condition 查询条件(如{ price: ['<', 1000] })
 */
export async function queryProducts(condition?: Record<string, [string, any]>): Promise<Product[]> {
  const db = await initDatabase();
  // 构建查询条件
  const predicates = new relationalStore.RdbPredicates(PRODUCT_TABLE.NAME);

  // 添加查询条件(如价格筛选、库存筛选)
  if (condition) {
    Object.entries(condition).forEach(([column, [operator, value]]) => {
      switch (operator) {
        case '=':
          predicates.equalTo(column, value);
          break;
        case '<':
          predicates.lessThan(column, value);
          break;
        case '>':
          predicates.greaterThan(column, value);
          break;
        case 'IN':
          predicates.in(column, value as any[]);
          break;
        default:
          console.warn(`不支持的查询操作符:${operator}`);
      }
    });
  }

  // 按更新时间降序排列
  predicates.orderByDesc(PRODUCT_TABLE.COLUMNS.UPDATE_TIME);

  try {
    // 执行查询
    const resultSet = await db.query(predicates);
    const products: Product[] = [];

    // 解析查询结果
    while (resultSet.goToNextRow()) {
      products.push({
        id: resultSet.getString(resultSet.getColumnIndex(PRODUCT_TABLE.COLUMNS.ID)),
        name: resultSet.getString(resultSet.getColumnIndex(PRODUCT_TABLE.COLUMNS.NAME)),
        price: resultSet.getDouble(resultSet.getColumnIndex(PRODUCT_TABLE.COLUMNS.PRICE)),
        imgUrl: resultSet.getString(resultSet.getColumnIndex(PRODUCT_TABLE.COLUMNS.IMG_URL)),
        stock: resultSet.getLong(resultSet.getColumnIndex(PRODUCT_TABLE.COLUMNS.STOCK))
      });
    }

    // 关闭结果集(避免内存泄漏)
    resultSet.close();
    console.log(`查询商品成功,共${products.length}条数据`);
    return products;
  } catch (error) {
    console.error('商品查询失败:', error);
    throw error;
  }
}

/**
 * 根据ID删除商品
 */
export async function deleteProductById(productId: string) {
  const db = await initDatabase();
  const predicates = new relationalStore.RdbPredicates(PRODUCT_TABLE.NAME);
  predicates.equalTo(PRODUCT_TABLE.COLUMNS.ID, productId);

  try {
    const result = await db.delete(predicates);
    console.log(`删除商品成功,影响行数:${result}`);
    return result;
  } catch (error) {
    console.error(`删除商品${productId}失败:`, error);
    throw error;
  }
}

使用示例(商品列表页面离线缓存)

// 优化商品列表获取:优先读本地数据库,再请求网络更新
async getProductList() {
  this.isLoading = true;
  try {
    // 1. 先从本地数据库读取(离线可用)
    const localProducts = await queryProducts();
    if (localProducts.length > 0) {
      this.products = localProducts; // 显示本地数据
    }

    // 2. 再请求网络更新数据(无论本地是否有数据)
    const remoteData = await httpManager.get<ProductListResponse>('/product/list', {
      page: this.currentPage,
      pageSize: this.pageSize
    });

    // 3. 更新本地数据库(批量插入/更新)
    await batchUpsertProducts(remoteData.list);

    // 4. 更新UI为最新网络数据
    this.products = remoteData.list;
  } catch (error) {
    // 网络失败时,若本地有数据则显示本地数据(离线友好)
    if (this.products.length === 0) {
      promptAction.showToast({ message: '网络异常,无法获取最新商品' });
    }
  } finally {
    this.isLoading = false;
  }
}

关键注意

  • RelationalStore 的ResultSet必须手动关闭,否则会导致内存泄漏;
  • 批量操作(batchReplace)比循环单条插入效率高 10 倍以上,建议优先使用;
  • 数据库升级需在upgrade回调中处理旧表结构修改,避免数据丢失。

场景 3:文件存储 —— 保存商品图片与日志文件

需求

将商品图片缓存到本地文件(避免重复下载),将应用操作日志写入本地文件(便于问题排查),适合大文件或自定义格式数据。

实现代码(FileStorageManager.ts)

import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';
import { join } from '@ohos.path';

// 应用沙箱目录(不同目录权限不同,需根据文件类型选择)
enum SandboxDir {
  CACHE = 'cache', // 缓存目录(应用卸载时删除,适合临时文件)
  USER_DATA = 'userData', // 用户数据目录(应用卸载时保留,适合重要文件)
  TEMP = 'temp' // 临时目录(系统可能清理,适合短期文件)
}

/**
 * 获取应用沙箱内的目录路径
 * @param dirType 目录类型(缓存/用户数据/临时)
 */
async function getSandboxDirPath(dirType: SandboxDir): Promise<string> {
  try {
    const context = getContext(this) as any; // 替换为组件的context
    let dirPath = '';

    // 根据目录类型获取路径
    switch (dirType) {
      case SandboxDir.CACHE:
        dirPath = await context.getCacheDir();
        break;
      case SandboxDir.USER_DATA:
        dirPath = await context.getUserDataDir();
        break;
      case SandboxDir.TEMP:
        dirPath = await context.getTempDir();
        break;
    }

    console.log(`获取${dirType}目录路径:${dirPath}`);
    return dirPath;
  } catch (error) {
    console.error(`获取${dirType}目录路径失败:`, error);
    throw error;
  }
}

/**
 * 下载图片并保存到缓存目录(避免重复下载)
 * @param imgUrl 图片URL
 * @param fileName 保存的文件名(如"product_123.png")
 */
export async function downloadAndSaveImage(imgUrl: string, fileName: string): Promise<string> {
  const cacheDir = await getSandboxDirPath(SandboxDir.CACHE);
  const imgPath = join(cacheDir, 'images', fileName); // 图片保存路径:cache/images/xxx.png

  try {
    // 1. 检查图片是否已存在(存在则直接返回路径)
    if (await fs.access(imgPath)) {
      console.log(`图片已存在,直接使用:${imgPath}`);
      return imgPath;
    }

    // 2. 创建images子目录(不存在则创建)
    const imgDir = join(cacheDir, 'images');
    if (!await fs.access(imgDir)) {
      await fs.mkdir(imgDir, { recursive: true }); // recursive:创建多级目录
    }

    // 3. 下载图片(用http请求获取二进制数据)
    const httpClient = http.createHttpClient();
    const request = httpClient.request(imgUrl, {
      method: http.RequestMethod.GET,
      expectDataType: http.HttpDataType.ARRAY_BUFFER // 响应为二进制数组
    });
    const response = await request;

    if (response.responseCode !== 200) {
      throw new Error(`图片下载失败:${response.responseCode}`);
    }

    // 4. 写入文件(二进制数据)
    const file = await fs.open(imgPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    await fs.write(file.fd, response.result as ArrayBuffer);
    await fs.close(file.fd); // 必须关闭文件描述符

    console.log(`图片保存成功:${imgPath}`);
    return imgPath;
  } catch (error) {
    console.error(`图片下载保存失败(URL:${imgUrl}):`, error);
    throw error;
  }
}

/**
 * 写入日志到用户数据目录(应用卸载保留)
 * @param content 日志内容
 * @param logFileName 日志文件名(如"app_log_202405.txt")
 */
export async function writeLogToFile(content: string, logFileName: string): Promise<void> {
  const userDataDir = await getSandboxDirPath(SandboxDir.USER_DATA);
  const logPath = join(userDataDir, 'logs', logFileName); // 日志路径:userData/logs/xxx.txt

  try {
    // 1. 创建logs子目录
    const logDir = join(userDataDir, 'logs');
    if (!await fs.access(logDir)) {
      await fs.mkdir(logDir, { recursive: true });
    }

    // 2. 打开文件(追加模式:不存在则创建,存在则在末尾追加)
    const file = await fs.open(logPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.APPEND);

    // 3. 构建日志内容(带时间戳)
    const logContent = `[${new Date().toLocaleString()}] ${content}\n`;
    const textEncoder = new TextEncoder();
    const buffer = textEncoder.encode(logContent); // 字符串转ArrayBuffer

    // 4. 写入文件
    await fs.write(file.fd, buffer);
    await fs.close(file.fd);

    console.log(`日志写入成功:${logPath}`);
  } catch (error) {
    console.error(`日志写入失败:`, error);
    throw error;
  }
}

/**
 * 读取日志文件内容
 */
export async function readLogFromFile(logFileName: string): Promise<string> {
  const userDataDir = await getSandboxDirPath(SandboxDir.USER_DATA);
  const logPath = join(userDataDir, 'logs', logFileName);

  try {
    // 检查文件是否存在
    if (!await fs.access(logPath)) {
      return '日志文件不存在';
    }

    // 打开文件并读取
    const file = await fs.open(logPath, fs.OpenMode.READ_ONLY);
    const fileStat = await fs.fstat(file.fd);
    const buffer = new ArrayBuffer(fileStat.size);
    await fs.read(file.fd, buffer);
    await fs.close(file.fd);

    // ArrayBuffer转字符串
    const textDecoder = new TextDecoder();
    return textDecoder.decode(buffer);
  } catch (error) {
    console.error(`日志读取失败:`, error);
    return `日志读取失败:${(error as Error).message}`;
  }
}

使用示例(商品图片缓存)

// 商品图片组件:优先加载本地缓存,无缓存则下载
@Component
struct ProductImage {
  private imgUrl: string; // 网络图片URL
  private productId: string; // 商品ID(用于生成唯一文件名)

  build() {
    Column() {
      // 加载中状态
      LoadingProgress()
        .width(40)
        .height(40)
        .visibility(this.isLoading ? Visibility.Visible : Visibility.Hidden)

      // 图片显示(用本地路径或网络URL)
      Image(this.localImgPath || this.imgUrl)
        .width(80)
        .height(80)
        .objectFit(ImageFit.Cover)
        .borderRadius(8)
        .onError(() => {
          // 图片加载失败时显示默认图
          this.localImgPath = 'common/images/default_product.png';
        })
    }
    .width(80)
    .height(80)
  }

  // 本地图片路径、加载状态
  @State localImgPath: string = '';
  @State isLoading: boolean = true;

  // 组件加载时获取本地图片
  aboutToAppear() {
    this.loadLocalImage();
  }

  // 加载本地缓存图片,无则下载
  async loadLocalImage() {
    try {
      // 生成唯一文件名(如"product_123.png")
      const fileName = `product_${this.productId}.png`;
      // 下载并保存图片(返回本地路径)
      this.localImgPath = await downloadAndSaveImage(this.imgUrl, fileName);
    } catch (error) {
      console.error(`加载商品图片失败(ID:${this.productId}):`, error);
      // 失败时仍用网络URL(降级处理)
    } finally {
      this.isLoading = false;
    }
  }
}

关键注意

  • 文件操作必须关闭fd(文件描述符),否则会导致资源泄漏;
  • 缓存目录(cache)适合临时文件,用户数据目录(userData)适合需保留的重要文件;
  • 大文件(如超过 10MB)建议分块读写,避免内存溢出。

四、常见问题解决方案

1. 网络请求:Token 过期如何自动刷新?

问题:请求时 Token 过期,需自动刷新 Token 后重新发起原请求,避免用户重新登录。
解决:在HttpManager的请求拦截器中处理 401 错误,刷新 Token 后重试:

// 修改HttpManager的request方法,添加Token刷新逻辑
async request<T>(...) {
  try {
    // 首次请求
    const response = await httpRequest;
    // ...原有逻辑...
  } catch (error) {
    // 捕获401错误(Token过期)
    if (error.message.includes('401') && needToken) {
      try {
        // 1. 刷新Token(调用刷新接口)
        const refreshResult = await this.request<{ token: string }>(
          http.RequestMethod.POST,
          '/user/refreshToken',
          { refreshToken: await getFromPreferences('refresh_token', '') },
          false // 刷新Token不需要原Token
        );

        // 2. 保存新Token
        await saveTokenToPreferences(refreshResult.token);

        // 3. 重新发起原请求(用新Token)
        return this.request<T>(method, url, params, needToken);
      } catch (refreshError) {
        // 刷新Token失败,跳转登录页
        promptAction.showToast({ message: '登录已过期,请重新登录' });
        router.replaceUrl({ url: 'pages/Login' });
        throw refreshError;
      }
    }
    throw error; // 非401错误,正常抛出
  }
}

2. 数据持久化:Preferences 与 RelationalStore 如何选择?

问题:不确定哪种场景用哪种持久化方案,导致选型错误。
解决:按数据特征决策:

数据特征 推荐方案 不推荐方案
数据量小(<100 条) Preferences RelationalStore
键值对结构(如配置项) Preferences 文件存储
结构化数据(多字段) RelationalStore Preferences
需要多条件查询(如价格筛选) RelationalStore Preferences
二进制数据(图片、文件) 文件存储 Preferences/RelationalStore

3. 文件存储:如何清理过期缓存?

问题:缓存图片、日志文件过多,占用存储空间,需定期清理。
解决:实现缓存清理工具,按文件修改时间删除过期文件:

/**
 * 清理缓存目录中超过指定天数的文件
 * @param days 保留天数(如7天)
 */
export async function cleanExpiredCache(days: number) {
  const cacheDir = await getSandboxDirPath(SandboxDir.CACHE);
  const imgDir = join(cacheDir, 'images');

  try {
    // 检查目录是否存在
    if (!await fs.access(imgDir)) return;

    // 遍历目录下所有文件
    const fileList = await fs.readDir(imgDir);
    const expireTime = Date.now() - days * 24 * 60 * 60 * 1000; // 过期时间戳

    for (const file of fileList) {
      const filePath = join(imgDir, file.name);
      const fileStat = await fs.stat(filePath);

      // 删除超过过期时间的文件
      if (fileStat.mtime.getTime() < expireTime) {
        await fs.unlink(filePath);
        console.log(`删除过期缓存:${filePath}`);
      }
    }
  } catch (error) {
    console.error('清理过期缓存失败:', error);
    throw error;
  }
}

// 使用:清理7天前的图片缓存
cleanExpiredCache(7);

  • model 文件夹
    • cityModel 子文件夹
      • CityModel.ets 文件
    • ticketModel 子文件夹
      • TicketModel.ets 文件
    • tokenApi 子文件夹
      • ReceiveModel.ets 文件
      • TokenModel.ets 文件
  • pages 文件夹
    • http 子文件夹
      • api 子文件夹
        • http_ai.ets 文件
      • Index.ets 文件
  • utils 文件夹
    • getData.ets 文件
    • getToken.ets 文件
    • SSEParser.ets 文件
    • TicketDataStore.ets 文件

五、总结与进阶方向

本文覆盖了鸿蒙 ArkUI 网络请求的封装与异步处理,以及 3 种核心数据持久化方案的实战应用,解决了“数据从哪里来(网络)”“数据到哪里去(本地存储)”的核心问题。进阶学习可关注以下方向:

  1. 网络请求优化:实现请求重试(失败自动重试 2 次)、请求队列(控制并发数)、断点续传(大文件下载);
  2. 数据同步策略:结合鸿蒙分布式能力,实现多设备(手机、平板)间的数据同步(如用DistributedData);
  3. 性能优化:数据库添加索引(提升查询速度)、网络请求结果缓存(减少重复请求)、文件分块读写(避免内存溢出);
  4. 安全加固:敏感数据加密存储(如 Token 用EncryptPreferences)、网络请求 HTTPS 证书校验(防止中间人攻击)。

若你在实际开发中遇到特定场景的网络或存储问题(如大文件上传、数据库加密),欢迎在评论区分享需求,一起探讨解决方案!

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。