鸿蒙 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:网络请求封装与异步处理
场景需求
实现“商品列表获取”功能:包含加载状态、请求参数传递、响应解析、异常提示(网络错误、业务错误),并封装可复用的请求工具类。
核心逻辑
- 封装
HttpManager
工具类:统一处理请求头、BaseURL、异常捕获; - 用
async/await
处理异步请求,避免回调嵌套; - 区分“网络异常”(如无网、超时)与“业务异常”(如 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 种核心数据持久化方案的实战应用,解决了“数据从哪里来(网络)”“数据到哪里去(本地存储)”的核心问题。进阶学习可关注以下方向:
- 网络请求优化:实现请求重试(失败自动重试 2 次)、请求队列(控制并发数)、断点续传(大文件下载);
- 数据同步策略:结合鸿蒙分布式能力,实现多设备(手机、平板)间的数据同步(如用
DistributedData
); - 性能优化:数据库添加索引(提升查询速度)、网络请求结果缓存(减少重复请求)、文件分块读写(避免内存溢出);
- 安全加固:敏感数据加密存储(如 Token 用
EncryptPreferences
)、网络请求 HTTPS 证书校验(防止中间人攻击)。
若你在实际开发中遇到特定场景的网络或存储问题(如大文件上传、数据库加密),欢迎在评论区分享需求,一起探讨解决方案!
- 点赞
- 收藏
- 关注作者
评论(0)