[鸿蒙从零到一] 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,需要多步一致性时使用事务,结构变化时维护迁移版本。
真正可维护的实现,还需要把初始化、模型转换、查询条件、错误处理和资源释放集中在数据层。页面只操作业务对象并响应状态变化,后续切换存储策略、增加索引或支持离线同步时,改动范围会更可控。

浙公网安备 33010602011771号