鸿蒙网络与数据持久化实战:从接口请求到本地存储的完整链路

一、这篇文章要解决什么问题
一款真实的应用,离不开两个基础能力:从远端拉取数据和把数据存在本地。前者决定了应用能提供什么内容,后者决定了应用能在没有网络时做什么、能在多大程度上减少重复请求。
鸿蒙提供了完整的网络和数据层能力,但文档分散、示例零碎,很多开发者在实际项目中遇到的问题是:知道有这些 API,但不知道怎么把它们串成一条完整的数据流。这篇文章的目标就是把这根链条打通——从发起一个 HTTP 请求、到处理响应数据、到选择合适的存储方案、再到数据变化时的自动同步,覆盖一个真实项目中会遇到的全部核心场景。
二、HTTP 请求的封装
2.1 为什么不能裸用 fetch
ArkUI 提供了 http 模块用于发起 HTTP 请求。但直接用原始 API 有几个明显的问题:每个接口都要重复写请求头、超时配置、错误处理;没有任何统一的地方做 token 注入和响应拦截;响应数据的解析散落在各处,容易出现不一致。
项目里需要一个 Request 封装类,把这些通用逻辑收敛起来,一次编写,到处使用。
// entry/src/main/ets/utils/Request.ets
import http from '@ohos.net.http'
// 定义接口响应的标准结构
interface ApiResponse<T> {
code: number
message: string
data: T
}
// 定义请求配置
interface RequestConfig {
url: string
method?: http.RequestMethod
headers?: Record<string, string>
params?: Record<string, string | number>
body?: object
timeout?: number
}
class Request {
private baseUrl: string = 'https://api.example.com/v1'
private timeout: number = 10000
// 注入 token(实际项目中从 AppStorage 或首选项读取)
private getHeaders(): Record<string, string> {
const token = AppStorage.get<string>('auth_token') || ''
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
'X-Platform': 'HarmonyOS',
'X-App-Version': '1.0.0'
}
}
async request<T>(config: RequestConfig): Promise<T> {
const fullUrl = this.buildUrl(config.url, config.params)
const requestOptions: http.HttpRequestOptions = {
method: config.method || http.RequestMethod.GET,
header: { ...this.getHeaders(), ...config.headers },
extraData: config.body || undefined,
connectTimeout: config.timeout || this.timeout,
readTimeout: (config.timeout || this.timeout) * 2
}
const request = http.createHttp()
try {
const response = await new Promise<http.HttpResponse>((resolve, reject) => {
request.request(fullUrl, requestOptions, (err, data) => {
if (err) reject(err)
else resolve(data)
})
})
if (response.responseCode < 200 || response.responseCode >= 300) {
throw new ApiError(response.responseCode, `HTTP ${response.responseCode}`)
}
const result = JSON.parse(response.result as string) as ApiResponse<T>
// 业务层面的错误处理
if (result.code !== 0) {
throw new ApiError(result.code, result.message)
}
return result.data
} catch (e) {
if (e instanceof ApiError) throw e
throw new ApiError(-1, `网络请求失败: ${JSON.stringify(e)}`)
} finally {
request.destroy()
}
}
// GET 请求
async get<T>(url: string, params?: Record<string, string | number>): Promise<T> {
return this.request<T>({ url, method: http.RequestMethod.GET, params })
}
// POST 请求
async post<T>(url: string, body?: object): Promise<T> {
return this.request<T>({ url, method: http.RequestMethod.POST, body })
}
// PUT 请求
async put<T>(url: string, body?: object): Promise<T> {
return this.request<T>({ url, method: http.RequestMethod.PUT, body })
}
// DELETE 请求
async delete<T>(url: string): Promise<T> {
return this.request<T>({ url, method: http.RequestMethod.DELETE })
}
private buildUrl(url: string, params?: Record<string, string | number>): string {
if (!params) return `${this.baseUrl}${url}`
const queryString = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
return `${this.baseUrl}${url}?${queryString}`
}
}
class ApiError extends Error {
constructor(
public code: number,
public message: string
) {
super(message)
this.name = 'ApiError'
}
}
export const api = new Request()
export { ApiError }

这段封装解决了几个实际问题。Token 的注入在 getHeaders 里统一处理,所有请求自动带上身份标识,不需要在每个业务接口里重复写。错误处理分了两层——HTTP 错误(状态码非 2xx)和业务错误(接口返回 code 非 0),分别抛出不同类型的异常,上层可以根据类型做不同的处理。request.destroy() 在 finally 里调用,确保无论成功还是失败,HTTP 连接都会被正确释放,避免内存泄漏。
2.2 拦截器与错误重试
有些场景需要更复杂的请求逻辑——比如 token 过期时自动刷新令牌并重试失败的请求。这种"先尝试、失败后补救"的模式用拦截器实现最合适:
class RequestWithRetry extends Request {
private maxRetries: number = 2
private retryDelay: number = 1000
async requestWithRetry<T>(config: RequestConfig): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await this.request<T>(config)
} catch (e) {
lastError = e as Error
// 网络错误才重试,业务错误不重试
if (e instanceof ApiError) {
if (e.code === 401) {
// token 过期:先刷新 token 再重试
console.info('[Request] Token 过期,尝试刷新')
const refreshed = await this.refreshToken()
if (refreshed) {
// token 刷新成功,替换 header 中的 token 后重试
if (config.headers) {
config.headers['Authorization'] = `Bearer ${AppStorage.get('auth_token')}`
}
continue
}
}
// 其他业务错误:不重试,直接抛出
throw e
}
// 网络错误:满足重试条件时等待后重试
if (attempt < this.maxRetries) {
const delay = this.retryDelay * Math.pow(2, attempt) // 指数退避
console.info(`[Request] 网络错误,第 ${attempt + 1} 次重试,等待 ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError
}
private async refreshToken(): Promise<boolean> {
try {
const refreshToken = AppStorage.get<string>('refresh_token')
if (!refreshToken) return false
const response = await this.request<{ access_token: string, refresh_token: string }>({
url: '/auth/refresh',
method: http.RequestMethod.POST,
body: { refresh_token: refreshToken }
})
AppStorage.setOrCreate('auth_token', response.access_token)
AppStorage.setOrCreate('refresh_token', response.refresh_token)
return true
} catch (e) {
console.error('[Request] Token 刷新失败')
// 刷新失败:清除本地 token,用户需要重新登录
AppStorage.delete('auth_token')
AppStorage.delete('refresh_token')
return false
}
}
}
指数退避策略(每次重试等待时间是上次的 2 倍)是标准做法——网络抖动时快速重试一次,如果问题持续存在则逐步增加等待间隔,避免对服务器造成压力。token 刷新的逻辑放在网络错误处理之前,因为 401 的本质是"身份过期",不等同于"网络问题"。
三、数据持久化方案对比
鸿蒙提供了三种本地存储方案,各有适用场景:
用户首选项(Preferences)——适合存储配置、登录态、用户偏好等小数据。API 极简,键值对形式,持久化到本地文件,读写速度最快。
轻量级数据库(LightWeight KV)——适合存储中等规模的结构化数据(几百到几千条记录)。支持谓词查询,接近关系型数据库的查询能力,但不支持表关联。
关系型数据库(RDB)——适合存储大规模数据、需要多表关联、事务支持的场景。对应标准 SQLite,功能完整。
实际项目中,大多数场景用前两种就够了。只有当数据规模超过几万条、或者需要表关联和事务时,才需要引入 RDB。
四、用户首选项实战
4.1 基础读写
用户首选项的使用方式非常直接——获取实例后直接用 get/set 操作键值对。数据会持久化到本地,App 重启后仍然保留。
import dataPreferences from '@ohos.data.preferences'
// 获取用户首选项实例
// getPreferences 需要传入 context 和名称
// 同一名称的 preferences 实例在全局是单例的
async function getPrefs(): Promise<dataPreferences.Preferences> {
const context = getContext(this)
return await dataPreferences.getPreferences(context, 'app_settings')
}
// 写入数据
async function saveUserSettings(settings: UserSettings): Promise<void> {
const prefs = await getPrefs()
prefs.put('username', settings.username)
prefs.put('theme', settings.theme)
prefs.put('notifications_enabled', settings.notificationsEnabled)
prefs.put('last_login', settings.lastLogin.toISOString())
prefs.put('font_size', settings.fontSize)
// flush 是持久化写入的关键,没有 flush 数据只存在内存
await prefs.flush()
}
// 读取数据
async function loadUserSettings(): Promise<UserSettings> {
const prefs = await getPrefs()
return {
username: prefs.get('username', '默认用户') as string,
theme: prefs.get('theme', 'light') as string,
notificationsEnabled: prefs.get('notifications_enabled', true) as boolean,
lastLogin: new Date(prefs.get('last_login', '') as string),
fontSize: prefs.get('font_size', 14) as number
}
}
// 清除某个键
async function clearSetting(key: string): Promise<void> {
const prefs = await getPrefs()
await prefs.delete(key)
await prefs.flush()
}
flush 是一个容易遗漏的细节。调用 put 时数据先写入内存,如果 App 在 flush 之前崩溃,这部分数据就会丢失。正确的做法是在每次批量写入结束后调用一次 flush,而不是每 put 一次就 flush 一次——后者会显著增加 IO 次数,影响性能。
4.2 与 AppStorage 的双向绑定
用户首选项天然适合和 AppStorage 配合使用——首选项负责持久化,AppStorage 负责在内存中快速访问,两者的结合让数据的读写既快又稳:
// 应用启动时:从首选项恢复到 AppStorage
async function restoreSettingsToAppStorage(): Promise<void> {
const prefs = await getPrefs()
// 一次性把首选项中的所有相关数据读入 AppStorage
AppStorage.setOrCreate('theme', prefs.get('theme', 'light'))
AppStorage.setOrCreate('font_size', prefs.get('font_size', 14))
AppStorage.setOrCreate('auth_token', prefs.get('auth_token', ''))
AppStorage.setOrCreate('last_sync_time', prefs.get('last_sync_time', 0))
console.info('[Settings] 配置已从首选项恢复')
}
// 用户修改设置时:同时更新 AppStorage 和首选项
async function updateTheme(newTheme: string): Promise<void> {
// 立即更新 AppStorage,UI 立即响应
AppStorage.set('theme', newTheme)
// 异步持久化到首选项
const prefs = await getPrefs()
prefs.put('theme', newTheme)
prefs.flush()
}
// 登录成功时:保存 token
async function saveAuthToken(token: string, refreshToken: string): Promise<void> {
AppStorage.setOrCreate('auth_token', token)
AppStorage.setOrCreate('refresh_token', refreshToken)
const prefs = await getPrefs()
prefs.put('auth_token', token)
prefs.put('refresh_token', refreshToken)
await prefs.flush()
}
// 退出登录时:清除 token
async function clearAuth(): Promise<void> {
AppStorage.delete('auth_token')
AppStorage.delete('refresh_token')
const prefs = await getPrefs()
await prefs.delete('auth_token')
await prefs.delete('refresh_token')
await prefs.flush()
}
这种"内存优先、持久化兜底"的模式在实际开发中非常高效。UI 层只和 AppStorage 交互,响应是同步的、没有 IO 延迟;数据层在后台默默做持久化,用户完全不感知。整个架构干净,代码也好维护。
五、轻量级 KV 数据库实战
5.1 建表与基础操作
轻量级 KV 数据库(RDBStore)比 Preferences 更适合存储结构化列表数据,比如笔记列表、商品列表、聊天记录。它的核心概念是"数据库 → 数据表 → 数据行",API 设计接近 SQLite:
import relationalStore from '@ohos.data.relationalStore'
const DB_NAME = 'app_database.db'
const TABLE_NAME = 'notes'
// 建表 SQL
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
tags TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_favorite INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0
)
`
class NoteDatabase {
private store: relationalStore.RdbStore | null = null
async init(context: Context): Promise<void> {
const config: relationalStore.StoreConfig = {
name: DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1 // S1 是基础安全级别
}
this.store = await relationalStore.getRdbStore(context, config)
// 建表
await this.store.executeSql(CREATE_TABLE_SQL)
console.info('[DB] 数据库初始化完成')
}
// 插入一条笔记
async insert(note: NoteRecord): Promise<void> {
if (!this.store) throw new Error('数据库未初始化')
const values: relationalStore.ValuesBucket = {
id: note.id,
title: note.title,
content: note.content,
tags: JSON.stringify(note.tags || []),
created_at: note.createdAt,
updated_at: note.updatedAt,
is_favorite: note.isFavorite ? 1 : 0,
is_deleted: note.isDeleted ? 1 : 0
}
await this.store.insert(TABLE_NAME, values)
}
// 根据 ID 查询单条
async queryById(id: string): Promise<NoteRecord | null> {
if (!this.store) return null
const condition = new relationalStore.RdbPredicates(TABLE_NAME).equalTo('id', id)
const resultSet = await this.store.query(condition)
if (!resultSet.goToFirstRow()) return null
return this.resultSetToNote(resultSet)
}
// 查询所有未删除的笔记
async queryAll(): Promise<NoteRecord[]> {
if (!this.store) return []
const condition = new relationalStore.RdbPredicates(TABLE_NAME)
.equalTo('is_deleted', 0)
.orderByDesc('updated_at')
const resultSet = await this.store.query(condition)
const notes: NoteRecord[] = []
while (resultSet.goToNextRow()) {
notes.push(this.resultSetToNote(resultSet))
}
resultSet.close()
return notes
}
// 批量插入(用于首次同步)
async batchInsert(notes: NoteRecord[]): Promise<void> {
if (!this.store) return
for (const note of notes) {
await this.insert(note)
}
console.info(`[DB] 批量写入 ${notes.length} 条笔记`)
}
// 软删除(标记 is_deleted = 1)
async softDelete(id: string): Promise<void> {
if (!this.store) return
const condition = new relationalStore.RdbPredicates(TABLE_NAME).equalTo('id', id)
const values: relationalStore.ValuesBucket = {
is_deleted: 1,
updated_at: Date.now()
}
await this.store.update(TABLE_NAME, values, condition)
}
// 彻底删除
async hardDelete(id: string): Promise<void> {
if (!this.store) return
const condition = new relationalStore.RdbPredicates(TABLE_NAME).equalTo('id', id)
await this.store.delete(TABLE_NAME, condition)
}
// 搜索笔记
async search(keyword: string): Promise<NoteRecord[]> {
if (!this.store) return []
const condition = new relationalStore.RdbPredicates(TABLE_NAME)
.equalTo('is_deleted', 0)
.and()
.contain('title', keyword)
.or()
.contain('content', keyword)
.orderByDesc('updated_at')
const resultSet = await this.store.query(condition)
const notes: NoteRecord[] = []
while (resultSet.goToNextRow()) {
notes.push(this.resultSetToNote(resultSet))
}
resultSet.close()
return notes
}
private resultSetToNote(resultSet: relationalStore.ResultSet): NoteRecord {
const tagsStr = resultSet.getString(resultSet.getColumnIndex('tags'))
return {
id: resultSet.getString(resultSet.getColumnIndex('id')),
title: resultSet.getString(resultSet.getColumnIndex('title')),
content: resultSet.getString(resultSet.getColumnIndex('content')),
tags: tagsStr ? JSON.parse(tagsStr) : [],
createdAt: resultSet.getLong(resultSet.getColumnIndex('created_at')),
updatedAt: resultSet.getLong(resultSet.getColumnIndex('updated_at')),
isFavorite: resultSet.getLong(resultSet.getColumnIndex('is_favorite')) === 1,
isDeleted: resultSet.getLong(resultSet.getColumnIndex('is_deleted')) === 1
}
}
}
interface NoteRecord {
id: string
title: string
content: string
tags: string[]
createdAt: number
updatedAt: number
isFavorite: boolean
isDeleted: boolean
}
export const noteDb = new NoteDatabase()
软删除(is_deleted 字段标记)是一个值得养成的习惯。直接物理删除的数据无法恢复,如果用户误删了一条笔记,后续想找回就完全没有办法。软删除保留原始数据,可以提供一个"回收站"功能,用户可以手动恢复或者等待一段时间后再彻底清理。
5.2 分页查询
当数据量变大后,一次性加载所有数据会显著拖慢应用启动速度。分页查询是解决这个问题的标准做法:
// 分页查询笔记
async queryPage(page: number, pageSize: number = 20): Promise<{
items: NoteRecord[]
total: number
hasMore: boolean
}> {
if (!this.store) return { items: [], total: 0, hasMore: false }
// 先查总数
const countCondition = new relationalStore.RdbPredicates(TABLE_NAME).equalTo('is_deleted', 0)
const totalResult = await this.store.query(countCondition)
totalResult.goToFirstRow()
const total = totalResult.rowCount
totalResult.close()
// 再查分页数据
const dataCondition = new relationalStore.RdbPredicates(TABLE_NAME)
.equalTo('is_deleted', 0)
.orderByDesc('updated_at')
.limitAs(pageSize)
.offsetAs(page * pageSize)
const dataResult = await this.store.query(dataCondition)
const items: NoteRecord[] = []
while (dataResult.goToNextRow()) {
items.push(this.resultSetToNote(dataResult))
}
dataResult.close()
return {
items,
total,
hasMore: (page + 1) * pageSize < total
}
}
分页查询还有一个优化点:首次加载时只需要显示第一页的数据和总数,后续滚动到页面底部时再加载下一页。这样应用的启动速度完全不受数据总量的影响,用户感觉到的响应时间只取决于第一页的加载速度。
六、数据同步:服务端与本地的一致性
6.1 拉取远端数据并合并
数据持久化最复杂的部分不是存,而是同步——远端数据会变化,本地数据也会变化,两个变化源合在一起,就产生了冲突:远端有了新数据、本地修改了一条尚未上传的记录,这种情况下谁的数据优先?
一个实用的策略是"远端拉取优先,冲突保留本地":以远端数据为准更新本地库,同时把本地的未同步修改标记为"冲突",留给用户手动处理。
class NoteSyncService {
private db = noteDb
// 从服务端拉取最新数据并同步到本地
async pullFromServer(): Promise<SyncResult> {
try {
const serverNotes = await api.get<NoteRecord[]>('/notes')
const localNotes = await this.db.queryAll()
const localMap = new Map(localNotes.map(n => [n.id, n]))
const serverMap = new Map(serverNotes.map(n => [n.id, n]))
let added = 0
let updated = 0
let unchanged = 0
// 遍历服务端数据,处理新增和更新
for (const serverNote of serverNotes) {
const localNote = localMap.get(serverNote.id)
if (!localNote) {
// 服务端有、本地没有:新增
await this.db.insert(serverNote)
added++
} else if (serverNote.updatedAt > localNote.updatedAt) {
// 服务端更新更新本地
await this.db.insert(serverNote) // insert 实际上是 upsert
updated++
} else {
unchanged++
}
}
// 找出本地有但服务端没有的(可能已被服务端删除)
// 暂时保留,不自动删除(防止服务端误删导致数据丢失)
const localOnly = localNotes.filter(n => !serverMap.has(n.id))
const conflictIds = localOnly.map(n => n.id)
// 更新最后同步时间
const lastSync = Date.now()
AppStorage.set('last_sync_time', lastSync)
return { added, updated, unchanged, conflicts: conflictIds.length }
} catch (e) {
console.error('[Sync] 拉取失败:', JSON.stringify(e))
throw e
}
}
// 上传本地新增或修改的笔记
async pushToServer(): Promise<void> {
const localNotes = await this.db.queryAll()
const unsynced = localNotes.filter(n => n.localModified)
for (const note of unsynced) {
try {
await api.post('/notes/sync', note)
// 上传成功后清除本地修改标记
note.localModified = false
await this.db.insert(note)
} catch (e) {
console.error(`[Sync] 上传笔记 ${note.id} 失败`)
}
}
}
}
interface SyncResult {
added: number
updated: number
unchanged: number
conflicts: number
}
这个同步策略假设服务端数据是最新的权威版本。在大多数 C 端应用(新闻、社交、信息流)中这是对的,因为服务端数据可能被多人修改,以服务端为准最安全。但如果应用是纯个人工具(笔记、待办),用户可能离线修改了大量数据后再联网,此时"以本地为准"是更合理的策略。两种策略没有绝对的好坏,取决于业务模型。
6.2 增量同步
每次全量拉取所有数据在数据量大时会很低效。更好的做法是"增量同步"——只拉取上次同步之后发生变化的数据。这需要一个"增量标记",通常用时间戳或版本号:
async pullIncremental(): Promise<void> {
const lastSyncTime = AppStorage.get<number>('last_sync_time') || 0
// 只请求更新时间晚于 lastSyncTime 的数据
const changedNotes = await api.get<NoteRecord[]>(
'/notes/changes',
{ since: lastSyncTime }
)
for (const note of changedNotes) {
await this.db.insert(note)
}
// 更新同步时间戳
AppStorage.set('last_sync_time', Date.now())
}
服务端需要支持 /notes/changes?since=timestamp 这样的接口,按时间范围返回变化的记录。大多数成熟的 REST API 都会实现类似的能力。
七、综合实战:笔记应用数据层
把网络请求、数据库、用户首选项组合起来,构建一个完整的数据层:
// entry/src/main/ets/repository/NoteRepository.ets
import { api, ApiError } from '../utils/Request'
import { noteDb } from '../database/NoteDatabase'
import { noteSyncService } from '../service/NoteSyncService'
// 仓库模式:数据层的统一入口
// 上层组件只和仓库交互,不关心数据来自网络还是本地
class NoteRepository {
async init(context: Context): Promise<void> {
await noteDb.init(context)
}
// 获取笔记列表(优先本地,网络请求在后台进行)
async getNotes(): Promise<NoteRecord[]> {
return await noteDb.queryAll()
}
// 刷新数据(强制从网络拉取最新数据)
async refreshNotes(): Promise<void> {
await noteSyncService.pullFromServer()
}
// 搜索笔记(仅本地)
async searchNotes(keyword: string): Promise<NoteRecord[]> {
return await noteDb.search(keyword)
}
// 创建笔记
async createNote(title: string, content: string, tags: string[] = []): Promise<NoteRecord> {
const now = Date.now()
const note: NoteRecord = {
id: this.generateId(),
title,
content,
tags,
createdAt: now,
updatedAt: now,
isFavorite: false,
isDeleted: false,
localModified: true // 标记为本地修改,等待上传
}
await noteDb.insert(note)
return note
}
// 更新笔记
async updateNote(id: string, updates: Partial<NoteRecord>): Promise<void> {
const existing = await noteDb.queryById(id)
if (!existing) throw new Error('笔记不存在')
const updated: NoteRecord = {
...existing,
...updates,
updatedAt: Date.now(),
localModified: true
}
await noteDb.insert(updated)
}
// 删除笔记(软删除)
async deleteNote(id: string): Promise<void> {
await noteDb.softDelete(id)
}
// 彻底删除
async permanentlyDeleteNote(id: string): Promise<void> {
await noteDb.hardDelete(id)
}
// 分页加载
async getNotesPage(page: number): Promise<{
items: NoteRecord[]
total: number
hasMore: boolean
}> {
return await noteDb.queryPage(page, 20)
}
private generateId(): string {
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
}
}
export const noteRepository = new NoteRepository()
仓库模式的好处是数据来源对上层透明。UI 组件调用 getNotes() 获取列表,不需要知道数据是从本地数据库返回的还是从网络请求的。如果将来要把本地数据库换成云端存储,只需要修改 NoteRepository 的实现,UI 层代码完全不需要改动。
八、网络层与数据层常见问题
8.1 离线可用性
断网时应用应该仍然可用。实现方式是:应用启动时先从本地数据库读取数据展示,然后再发起网络请求更新。如果网络请求成功,用新数据覆盖本地数据;如果请求失败,UI 已经展示了本地数据,用户感受不到网络问题。
async function loadNotesWithOfflineSupport(): Promise<NoteRecord[]> {
// 第一步:立即展示本地数据(快速,无网络依赖)
const localNotes = await noteDb.queryAll()
// 第二步:后台尝试拉取最新数据
try {
await noteSyncService.pullFromServer()
} catch (e) {
console.warn('[App] 离线模式,网络不可用')
// 不抛出错误,UI 继续展示本地数据
}
// 第三步:如果拉取成功,刷新本地数据
return await noteDb.queryAll()
}
8.2 请求取消与重复点击防护
用户快速连续点击一个按钮时,可能在短时间内发出多个相同的请求。如果不做处理,后端可能会创建多条重复的记录。解决方案是给每个请求生成唯一 ID,相同 ID 的请求在发送前检查是否已有相同请求在途中:
const pendingRequests = new Map<string, AbortController>()
async function requestWithDeduplication<T>(
requestId: string,
requestFn: () => Promise<T>
): Promise<T> {
if (pendingRequests.has(requestId)) {
console.info(`[Request] 请求 ${requestId} 已在进行中,跳过重复提交`)
return pendingRequests.get(requestId)!.promise as Promise<T>
}
let resolve!: (v: T) => void
let reject!: (e: Error) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
pendingRequests.set(requestId, { promise, abort: () => {
reject(new Error('请求被取消'))
pendingRequests.delete(requestId)
}})
try {
const result = await requestFn()
pendingRequests.delete(requestId)
resolve(result)
return result
} catch (e) {
pendingRequests.delete(requestId)
reject(e as Error)
throw e
}
}
8.3 敏感数据加密
用户首选项和本地数据库默认是明文存储的。如果应用涉及身份证号、银行卡号、密码等敏感数据,需要做加密处理。鸿蒙的 cryptoFramework 模块提供了 AES、RSA 等加密算法:
import cryptoFramework from '@ohos.security.cryptoFramework'
// AES-GCM 加密
async function encryptData(plainText: string, key: string): Promise<string> {
const cipher = cryptoFramework.createCipher('AES|GCM|PKCS7')
const keyBlob = { data: new Uint8Array(key.split('').map(c => c.charCodeAt(0))) }
await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, keyBlob, null)
const plainBlob = { data: new TextEncoder().encode(plainText) }
const encryptedBlob = await cipher.doFinal(plainBlob)
// 将加密结果转为 Base64 字符串存储
return this.arrayBufferToBase64(encryptedBlob.data)
}
private arrayBufferToBase64(buffer: Uint8Array): string {
let binary = ''
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary)
}
对于绝大多数应用来说,用系统提供的 Keychain(密钥库)存储加密密钥,配合上面的加密算法来处理敏感字段就够了。不建议自己实现加密算法或者把密钥硬编码在代码里。
九、实战建议
第一条:网络请求必须做超时控制。 不设置超时的请求在网络异常时会永久挂起主线程,导致 App 完全卡死。合理的超时时间是 10-15 秒,超时后给出明确的错误提示,而不是让用户无止境地等待。
第二条:敏感数据不要存明文。 token、用户信息、配置数据,凡是不想让其他应用读取的数据,都应该加密存储。Preferences 默认是明文的,在处理敏感数据时一定要额外加密。
第三条:分页加载是性能的关键。 不做分页的应用在数据量超过几百条后就会出现明显的性能问题——启动慢、列表滚动卡、搜索延迟高。在设计数据层之初就把分页考虑进去,比后期打补丁容易得多。
第四条:仓库模式隔离数据来源。 UI 层不应该知道数据是来自网络还是本地。统一通过仓库接口访问数据,仓库内部决定使用哪个数据源。将来切换存储方案时,只需要改仓库实现,不需要改动 UI。
第五条:离线场景要先展示后更新。 用户打开 App 时不应该等待网络请求完成才能看到内容。先从本地加载上一次的数据展示给用户,后台再静默拉取更新。这是现代应用的标准体验模式。
第六条:写操作后本地立即更新。 用户点击保存按钮后,UI 应该立刻反映变化(列表中出现新笔记),不需要等网络请求返回。如果网络请求失败了,再用错误提示告知用户并回滚状态。这样用户体验最好,但要注意处理好竞态条件。
基于 HarmonyOS NEXT(API 12+)。轻量级 KV 数据库的分页查询需要建索引以保证性能。加密存储的密钥建议使用 Keychain 机制管理,不要硬编码在代码中。
更多推荐



所有评论(0)