在这里插入图片描述

一、这篇文章要解决什么问题

一款真实的应用,离不开两个基础能力:从远端拉取数据把数据存在本地。前者决定了应用能提供什么内容,后者决定了应用能在没有网络时做什么、能在多大程度上减少重复请求。

鸿蒙提供了完整的网络和数据层能力,但文档分散、示例零碎,很多开发者在实际项目中遇到的问题是:知道有这些 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 机制管理,不要硬编码在代码中。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐