[鸿蒙从零到一] HarmonyOS 数据持久化实战:Preferences 与 relationalStore 的选型与封装

[鸿蒙从零到一] HarmonyOS 数据持久化实战:Preferences 与 relationalStore 的选型与封装

前言

只要应用需要记住用户的主题偏好、登录状态、草稿、订单或离线数据,就会遇到数据持久化。鸿蒙应用中,常见选择是 Preferences 和 relationalStore:前者适合保存少量键值配置,后者适合保存结构化记录并进行查询。

两者的差别不只是 API 名称不同。存储方式选错,后续会出现查询困难、数据迁移麻烦、并发访问混乱等问题。本文以一个“阅读清单”场景为例,从需求拆分开始,介绍两种存储方案的适用边界、ArkTS 封装方式、生命周期管理和常见坑点。

先按数据形态做选择

可以先问自己三个问题:数据是否只有一个值、是否需要按条件查询、是否需要保存多条有关系的记录。

Preferences 更像应用级配置表,适合以下数据:

  • 是否首次启动。
  • 当前主题、语言和展示模式。
  • 用户最近一次选择的筛选条件。
  • 少量、结构简单的本地开关。

relationalStore 更适合以下数据:

  • 阅读清单、收藏夹、历史记录等多条业务记录。
  • 需要按照状态、时间或关键字查询的数据。
  • 需要新增、更新、删除,以及排序和分页的数据。
  • 多个字段共同描述一条业务对象的数据。

不要把一组复杂对象直接序列化成一个很大的 Preferences 字符串。这样虽然初期代码少,但每次修改一条记录都需要读出、解析、改动再整体写回,查询和数据迁移也会越来越脆弱。

用 Preferences 保存应用配置

Preferences 的典型用法是先通过 preferences.getPreferences 获取实例,再读写键值。下面封装一个设置存储类,只负责应用配置,不把业务记录塞进来。

import { preferences } from '@kit.ArkData'
import { context } from '@kit.AbilityKit'

export class SettingsStore {
  private static readonly FILE_NAME: string = 'app_settings'
  private preferencesStore?: preferences.Preferences

  async init(appContext: context.Context): Promise<void> {
    this.preferencesStore = await preferences.getPreferences(appContext, {
      name: SettingsStore.FILE_NAME
    })
  }

  private get store(): preferences.Preferences {
    if (this.preferencesStore === undefined) {
      throw new Error('SettingsStore has not been initialized')
    }
    return this.preferencesStore
  }

  async isDarkMode(): Promise<boolean> {
    return await this.store.get('darkMode', false)
  }

  async setDarkMode(enabled: boolean): Promise<void> {
    await this.store.put('darkMode', enabled)
    await this.store.flush()
  }

  async getLastCategory(): Promise<string> {
    return await this.store.get('lastCategory', '全部')
  }

  async setLastCategory(category: string): Promise<void> {
    await this.store.put('lastCategory', category)
    await this.store.flush()
  }
}

这里有几个工程要点:

  • 初始化依赖 Context,应传入应用能够长期使用的上下文,而不是随页面销毁的临时对象。
  • 通过 getter 检查初始化状态,避免在启动流程遗漏时得到难以定位的空对象错误。
  • put 修改内存中的键值,flush 将变更持久化。对重要配置可以在写入后显式调用 flush
  • 默认值要和业务含义一致。读取不存在的配置时,页面应该得到可用的默认状态。

如果一次要写入多项配置,可以连续 put,最后统一 flush,减少不必要的落盘次数。

relationalStore 的表结构设计

阅读清单中,每条数据至少包含标题、作者、阅读状态和更新时间。可以设计为如下表:

reading_item
├── id          INTEGER PRIMARY KEY AUTOINCREMENT
├── title       TEXT NOT NULL
├── author      TEXT NOT NULL
├── is_finished INTEGER NOT NULL DEFAULT 0
└── updated_at  INTEGER NOT NULL

表结构要先服务于查询场景。比如首页需要展示最近修改的内容,就应该保存可排序的时间戳;如果需要按完成状态筛选,就不要把状态埋在一个 JSON 字段里。

接着定义数据库配置和版本号:

import { relationalStore } from '@kit.ArkData'
import { context } from '@kit.AbilityKit'

export class ReadingDatabase {
  private static readonly STORE_CONFIG: relationalStore.StoreConfig = {
    name: 'reading.db',
    securityLevel: relationalStore.SecurityLevel.S1
  }

  private store?: relationalStore.RdbStore

  async init(appContext: context.Context): Promise<void> {
    this.store = await relationalStore.getRdbStore(
      appContext,
      ReadingDatabase.STORE_CONFIG
    )
    await this.store.executeSql(
      `CREATE TABLE IF NOT EXISTS reading_item (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        author TEXT NOT NULL,
        is_finished INTEGER NOT NULL DEFAULT 0,
        updated_at INTEGER NOT NULL
      )`
    )
  }

  private get database(): relationalStore.RdbStore {
    if (this.store === undefined) {
      throw new Error('ReadingDatabase has not been initialized')
    }
    return this.store
  }
}

CREATE TABLE IF NOT EXISTS 适合第一次启动时建立表,但正式项目还要考虑版本升级。不要把所有升级逻辑都藏在页面启动代码里,可以把数据库初始化和迁移集中在数据层,便于测试和审查。

插入、更新和删除记录

使用 ValuesBucket 组织写入字段,避免把业务对象直接传给数据库 API。下面定义数据模型和几个基础操作:

export interface ReadingItem {
  id: number
  title: string
  author: string
  isFinished: boolean
  updatedAt: number
}

export interface ReadingItemInput {
  title: string
  author: string
}

export class ReadingRepository {
  constructor(private readonly database: relationalStore.RdbStore) {}

  async insert(input: ReadingItemInput): Promise<number> {
    const values: relationalStore.ValuesBucket = {
      title: input.title.trim(),
      author: input.author.trim(),
      is_finished: 0,
      updated_at: Date.now()
    }
    return await this.database.insert('reading_item', values)
  }

  async markFinished(id: number, finished: boolean): Promise<void> {
    const values: relationalStore.ValuesBucket = {
      is_finished: finished ? 1 : 0,
      updated_at: Date.now()
    }
    const predicates = new relationalStore.RdbPredicates('reading_item')
    predicates.equalTo('id', id)
    const changed = await this.database.update(values, predicates)
    if (changed === 0) {
      throw new Error(`Reading item not found: ${id}`)
    }
  }

  async remove(id: number): Promise<void> {
    const predicates = new relationalStore.RdbPredicates('reading_item')
    predicates.equalTo('id', id)
    await this.database.delete(predicates)
  }
}

仓储层的职责是把业务模型转换成数据库字段,并统一处理条件和错误。页面不需要知道 isFinished 在表中使用的是整数,也不需要重复拼接更新时间。

查询与读取结果

查询时使用 RdbPredicates 表达条件,再通过 query 得到 ResultSet。读取结束后必须关闭结果集,否则长时间运行的页面可能积累资源。

async list(finished?: boolean): Promise<ReadingItem[]> {
  const predicates = new relationalStore.RdbPredicates('reading_item')
  if (finished !== undefined) {
    predicates.equalTo('is_finished', finished ? 1 : 0)
  }
  predicates.orderByDesc('updated_at')

  const resultSet = await this.database.query(
    predicates,
    ['id', 'title', 'author', 'is_finished', 'updated_at']
  )
  const items: ReadingItem[] = []
  try {
    while (resultSet.goToNextRow()) {
      items.push({
        id: resultSet.getLong(resultSet.getColumnIndex('id')),
        title: resultSet.getString(resultSet.getColumnIndex('title')),
        author: resultSet.getString(resultSet.getColumnIndex('author')),
        isFinished: resultSet.getLong(resultSet.getColumnIndex('is_finished')) === 1,
        updatedAt: resultSet.getLong(resultSet.getColumnIndex('updated_at'))
      })
    }
  } finally {
    resultSet.close()
  }
  return items
}

读取列时不要依赖“第几列”的位置。通过列名获取索引更容易和投影字段同步,也能减少后续修改查询字段顺序带来的错误。对大量数据还应加入分页条件,避免一次把整张表加载到内存。

事务保证多步操作的一致性

有些业务操作不是一条 SQL 就能完成。例如删除阅读记录的同时删除它的标签,或者导入一批数据时需要保证全部成功。此时应使用事务:

async importItems(items: ReadingItemInput[]): Promise<void> {
  await this.database.beginTransaction()
  try {
    for (const item of items) {
      await this.database.insert('reading_item', {
        title: item.title.trim(),
        author: item.author.trim(),
        is_finished: 0,
        updated_at: Date.now()
      })
    }
    await this.database.commit()
  } catch (error) {
    await this.database.rollBack()
    throw error
  }
}

事务边界应该放在仓储或数据服务层,而不是由页面决定。这样无论操作来自按钮、后台任务还是同步流程,都能使用同一套一致性规则。

数据库升级与迁移

当应用新版本增加字段时,不能只修改建表 SQL,因为已有数据库不会重新执行 CREATE TABLE。应维护数据库版本,并在升级时执行迁移。迁移脚本要满足两个条件:只处理从旧版本到目标版本的变化,并且可以安全重复执行或明确记录已经执行过的版本。

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'reading.db',
  securityLevel: relationalStore.SecurityLevel.S1,
  // 版本号提升时,按项目实际 API 配置升级回调
}

实际使用时请以当前 DevEco Studio SDK 的 relationalStore.StoreConfig 和升级回调签名为准。不同 API 版本对升级字段的具体写法可能变化,但迁移原则不变:保留旧数据、补齐新字段、验证迁移结果,再让业务层开始读取新结构。

在 Stage 模型中初始化数据层

数据层通常在应用启动阶段初始化一次,再通过依赖注入或模块单例提供给页面。页面进入时只负责订阅和展示,不要每次 aboutToAppear 都重复创建数据库对象。

export class AppDataStore {
  readonly settings = new SettingsStore()
  private readingDatabase?: ReadingDatabase
  private readingRepository?: ReadingRepository

  async init(appContext: context.Context): Promise<void> {
    await this.settings.init(appContext)
    const database = new ReadingDatabase()
    await database.init(appContext)
    this.readingDatabase = database
    this.readingRepository = new ReadingRepository(database['database'])
  }

  get readings(): ReadingRepository {
    if (this.readingRepository === undefined) {
      throw new Error('AppDataStore has not been initialized')
    }
    return this.readingRepository
  }
}

上面的示例用于说明初始化顺序,真实工程中不建议通过索引访问私有字段。可以让 ReadingDatabase 暴露一个明确的 getStore() 方法,或者让数据库初始化方法直接返回 RdbStore,避免破坏封装。

页面调用时保持简单:

@Entry
@Component
struct ReadingPage {
  @State items: ReadingItem[] = []

  private async loadItems(): Promise<void> {
    this.items = await appDataStore.readings.list()
  }

  async aboutToAppear(): Promise<void> {
    await this.loadItems()
  }

  build() {
    List() {
      ForEach(this.items, (item: ReadingItem) => {
        ListItem() {
          Text(item.title)
        }
      }, (item: ReadingItem) => item.id.toString())
    }
  }
}

常见问题

把密钥当普通配置保存

Preferences 不是万能的密钥保险箱。登录令牌、私钥等敏感数据应根据安全要求使用系统提供的安全存储能力,并控制日志、备份和导出行为。

忘记关闭 ResultSet

每次查询都应在 finally 中关闭结果集,即使读取过程中发生异常也不能遗漏。

在 UI 线程做大量同步工作

大批量导入、复杂查询和数据迁移可能影响首屏与交互。应拆分任务、限制查询字段、加入分页,并根据实际性能数据安排异步执行。

让页面直接拼 SQL

页面直接拼接表名、字段和条件,会造成重复代码和注入风险。优先使用 RdbPredicates,将 SQL 和字段映射集中在数据层。

缺少输入校验

数据库约束不能替代业务校验。标题为空、长度超限、非法状态等情况,应在进入仓储层前后分别做必要校验,并把可读错误返回给页面。

小结

鸿蒙应用的数据持久化可以遵循一条清晰的判断路径:少量配置使用 Preferences,多条结构化记录使用 relationalStore,需要多步一致性时使用事务,结构变化时维护迁移版本。

真正可维护的实现,还需要把初始化、模型转换、查询条件、错误处理和资源释放集中在数据层。页面只操作业务对象并响应状态变化,后续切换存储策略、增加索引或支持离线同步时,改动范围会更可控。

posted @ 2026-07-23 11:02  天总会晴的  阅读(4)  评论(0)    收藏  举报