鸿蒙本地存储三剑客:Preferences vs 关系型数据库 vs 文件,选哪个?
做鸿蒙App开发,绕不开一个问题:数据存哪?
用户关了App,下次打开还得看到上次的数据------历史记录、设置选项、聊天消息......这些都不能丢。鸿蒙提供了三种本地存储方案,各有各的本事。今天我把三种方案全讲透,看完你就知道该选谁。
一、三种方案速览
| Preferences | 关系型数据库 | 文件 | |
|---|---|---|---|
| 存什么 | 键值对(key-value) | 结构化表格数据(行和列) | 任意内容(文本、图片、音频) |
| 比喻 | 一个大字典 | 一个Excel表格 | 一个txt文件 |
| 查询能力 | 只能按key取 | 支持条件查询、排序、分页 | 无,只能整体读写 |
| 适合数据量 | 小(建议不超过1万条,总大小不超过2MB) | 大 | 任意 |
| 底层引擎 | 轻量KV存储 | SQLite | 系统文件IO |
打个比方:存一个人的名字,用Preferences就像写在便利贴上;存一个班的学生成绩,用关系型数据库就像用Excel;写一篇日志,用文件就像用记事本。
二、Preferences --- 便利贴级别的轻量存储
是什么?
Preferences是鸿蒙提供的轻量级键值对存储。你给它一个key,它帮你存一个value,简单粗暴。官方文档明确说了:适用于轻量级数据的存储和读取。
怎么用?
第一步:导入模块
typescript
import { preferences } from '@kit.ArkData'
第二步:获取Preferences实例
typescript
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
let prefs = await preferences.getPreferences(context, 'my_prefs')
getPreferences需要两个参数:context(告诉系统文件存哪)和name(文件名)。
第三步:写数据
typescript
prefs.putSync('username', '张三')
prefs.putSync('fontSize', 16)
prefs.putSync('darkMode', true)
prefs.flush() // 别忘了flush!
putSync是同步写入内存,但不会立刻存盘 。必须调flush()才会真正写入磁盘,否则App一关数据就丢了。这和C语言的fflush一个道理。
第四步:读数据
typescript
let name = prefs.getSync('username', '默认值') as string
let size = prefs.getSync('fontSize', 14) as number
getSync的第二个参数是默认值------如果key不存在,就返回这个默认值,不会报错。
第五步:删数据
typescript
prefs.deleteSync('username')
prefs.flush() // 删完也要flush
和AppStorage怎么配合?
这是很多人困惑的地方。AppStorage也是全局键值对,和Preferences啥区别?
| AppStorage | Preferences | |
|---|---|---|
| 位置 | 内存 | 磁盘 |
| 速度 | 快 | 相对慢 |
| 关App还在吗 | 不在 | 在 |
| 作用 | 驱动UI刷新 | 持久化存盘 |
最佳实践:AppStorage管UI实时刷新,Preferences管存盘。数据变化时同时写两边:
typescript
// 写:AppStorage立刻刷新UI,Preferences存盘保命
AppStorage.setOrCreate('username', '张三')
prefs.putSync('username', '张三')
prefs.flush()
// 读:启动时从Preferences恢复到AppStorage
let savedName = prefs.getSync('username', '') as string
AppStorage.setOrCreate('username', savedName)
更优雅的做法是用**@StorageLink装饰器**,它让组件变量和AppStorage双向绑定,修改变量自动同步到AppStorage:
typescript
@Component
struct SettingsPage {
@StorageLink('username') username: string = '默认值' // 双向绑定,改this.username自动同步到AppStorage
@StorageProp('fontSize') fontSize: number = 14 // 单向绑定,只能读不能写回
build() {
Column() {
Text(`你好,${this.username}`)
Button('改名').onClick(() => {
this.username = '李四' // 自动同步到AppStorage,UI立即刷新
})
}
}
}
- @StorageLink:双向绑定,组件修改→AppStorage,AppStorage修改→组件(适合设置页、输入框)
- @StorageProp:单向绑定,AppStorage→组件,组件改了不影响AppStorage(适合展示型组件)
配合Preferences,就形成了完整的闭环:Preferences存盘 → AppStorage中转 → @StorageLink驱动UI。
适用场景
- 用户设置(字体大小、夜间模式、语言选择)
- 少量配置项(登录token、上次选择的城市)
- 计算器历史记录(数据量小时够用)
局限
- 只能按key取值,不能条件查询(比如"查所有年龄大于18的"做不到)
- 不适合大量数据(官方建议不超过1万条key,总大小不宜超过2MB------因为加载时是整体读入内存的)
- value支持number/string/boolean/Array等基本类型,复杂对象需要JSON.stringify转字符串
三、关系型数据库 --- Excel级别的结构化存储
是什么?
关系型数据库基于SQLite引擎,适合存有结构的数据。就像Excel表格,有表头(列名),有行(数据),还能按条件查询。
官方文档的定义:适用于存储包含复杂关系数据的场景,比如一个班级的学生信息,需要包括姓名、学号、各科成绩等。
核心概念(先搞懂这5个)
| 概念 | 是什么 | 比喻 |
|---|---|---|
| RdbStore | 数据库实例,所有操作都靠它 | 整个Excel文件 |
| 表(Table) | 数据的容器 | Excel里的一个Sheet |
| ValuesBucket | 一行数据的打包 | Excel里的一行 |
| RdbPredicates | 查询/删除的条件构造器 | Excel的筛选功能 |
| ResultSet | 查询结果的游标 | 筛选后的结果列表 |
怎么用?
第一步:导入模块
typescript
import { relationalStore } from '@kit.ArkData'
第二步:建库建表
typescript
const STORE_CONFIG: relationalStore.StoreConfig = {
name: 'MyDB.db', // 数据库文件名
securityLevel: relationalStore.SecurityLevel.S1 // 安全等级
}
const SQL_CREATE_TABLE =
'CREATE TABLE IF NOT EXISTS STUDENT (ID INTEGER PRIMARY KEY AUTOINCREMENT, NAME TEXT NOT NULL, AGE INTEGER)'
// 获取数据库实例
let rdbStore = await relationalStore.getRdbStore(context, STORE_CONFIG)
// 首次创建时建表(version=0说明是新建的数据库)
if (rdbStore.version === 0) {
rdbStore.executeSql(SQL_CREATE_TABLE)
rdbStore.version = 1
}
几个关键点:
-
securityLevel:S1最低(日常够用),S4最高(性能消耗大),一般用S1
-
version机制:数据库第一次创建时version=0,建完表设为1,下次打开就不会重复建表
-
生产环境的升级方式 :上面用
if (rdbStore.version === 0)判断是为了入门好理解。实际项目中,更规范的做法是在StoreConfig中指定version,通过upgrade回调处理版本迁移(比如V1→V2时用ALTER TABLE加字段),避免删表丢数据。初学阶段先掌握基础用法,后面再学升级机制 -
建表SQL:只需要记这一条模板就够了------
CREATE TABLE IF NOT EXISTS 表名 (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
列名1 TEXT, -- 文本类型
列名2 INTEGER, -- 整数类型
列名3 REAL -- 小数类型
) -
IF NOT EXISTS:表已存在不报错 -
PRIMARY KEY AUTOINCREMENT:自动编号,不用你手动填ID
第三步:增(insert)
typescript
let valueBucket: relationalStore.ValuesBucket = {
NAME: '张三',
AGE: 20
}
let rowId = await rdbStore.insert('STUDENT', valueBucket)
ValuesBucket就是一个 { 列名: 值 } 的对象,ID不用填,自动递增。
第四步:查(query)
typescript
let predicates = new relationalStore.RdbPredicates('STUDENT')
// 不加条件 = 查全部
// 也可以加条件:
// predicates.equalTo('NAME', '张三') // 查姓名=张三的
// predicates.greaterThan('AGE', 18) // 查年龄>18的
// predicates.orderByDesc('AGE') // 按年龄降序
let resultSet = await rdbStore.query(predicates, ['ID', 'NAME', 'AGE'])
let result = ''
while (resultSet.goToNextRow()) {
let id = resultSet.getLong(0)
let name = resultSet.getString(1)
let age = resultSet.getLong(2)
result += `ID=${id} 姓名=${name} 年龄=${age}\n`
}
resultSet.close() // 必须关闭!释放资源
goToNextRow()逐行遍历,getString(列索引)按列位置取值(从0开始)。
第五步:改(update)
typescript
let updateBucket: relationalStore.ValuesBucket = { AGE: 21 }
let predicates = new relationalStore.RdbPredicates('STUDENT')
predicates.equalTo('NAME', '张三') // 只改张三的
let changedRows = await rdbStore.update(updateBucket, predicates)
第六步:删(delete)
typescript
let predicates = new relationalStore.RdbPredicates('STUDENT')
predicates.equalTo('NAME', '张三') // 只删张三
// 不加条件 = 删全部
let deletedRows = await rdbStore.delete(predicates)
适用场景
- 聊天消息(需要按时间排序、按聊天对象筛选)
- 学生信息、员工档案(有结构、需要条件查询)
- 订单记录(需要按状态筛选、按时间排序)
- 任何"表格式"的数据
注意事项
- ResultSet必须close():不然会内存泄漏,就像C语言里malloc了不free
- 单次查询建议不超过5000条:官方文档说的,大数据量分批查
- 同一时间只能有一个写操作:SQLite的限制
- 批量操作用事务:如果你要一次性插入100条数据,别一条一条insert------每条insert都会触发一次磁盘I/O,慢得要命。用事务(createTransaction)包起来,所有操作攒一起最后一次性写入磁盘,性能能提升几十倍:
typescript
let transaction = await rdbStore.createTransaction()
try {
for (let item of dataList) {
await transaction.execute(`INSERT INTO STUDENT (NAME, AGE) VALUES ('${item.name}', ${item.age})`)
}
await transaction.commit() // 全部成功,提交
} catch (e) {
await transaction.rollback() // 任何一条失败,全部回滚
}
事务还有一个好处:要么全成功,要么全失败。不会出现插入50条后崩溃、只剩半截数据的情况。
四、文件管理 --- 记事本级别的自由存储
是什么?
直接在App沙箱目录里创建、读写、删除文件。和C语言的fopen/fwrite/fread/fclose一模一样,没有任何查询能力,就是最原始的文件操作。
怎么用?
第一步:导入模块
typescript
import { fileIo as fs } from '@kit.CoreFileKit'
注意!不是import { fs },是import { fileIo as fs }。fileIo才是模块名,fs是我们起的别名。这是很多人踩的坑。
第二步:写入文件
typescript
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
let filePath = context.filesDir + '/test.txt' // 沙箱目录下
let file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
fs.writeSync(file.fd, '你好,鸿蒙!')
fs.closeSync(file) // 必须关!
context.filesDir:应用持久化文件目录,App卸载才删context.cacheDir:缓存目录,系统可能清理fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE:读写模式,不存在就创建
第三步:读取文件
typescript
// 先检查文件是否存在,防止闪退!
if (!fs.accessSync(filePath)) {
console.info('文件不存在')
return
}
let file = fs.openSync(filePath, fs.OpenMode.READ_ONLY)
let buf = new ArrayBuffer(1024)
let readLen = fs.readSync(file.fd, buf)
let decoder = util.TextDecoder.create('utf-8')
let content = decoder.decodeToString(new Uint8Array(buf, 0, readLen))
fs.closeSync(file)
读取比写入复杂一点:readSync读出来的是ArrayBuffer(字节缓冲区),需要用util.TextDecoder解码成字符串。千万别用String.fromCharCode逐字节转------遇到中文(UTF-8多字节字符)会乱码!官方文档推荐的正是TextDecoder方案:
typescript
import { util } from '@kit.ArkTS' // TextDecoder在这个模块里
let textDecoder = util.TextDecoder.create('utf-8')
let readString = textDecoder.decodeToString(new Uint8Array(buffer))
decodeToString是新版API(替代了旧版decodeWithStream),支持ignoreBOM选项,更规范。
第四步:删除文件
typescript
if (fs.accessSync(filePath)) {
fs.unlinkSync(filePath)
}
适用场景
- 日志文件(纯文本追加写入)
- 导出数据(生成CSV、JSON文件)
- 图片/音频缓存
- 配置文件(不需要查询,整体读写就行)
注意事项
- 操作前用accessSync检查文件存在:文件不存在时openSync会报错闪退
- closeSync必须调:和C语言一样,打开不关会资源泄漏
- 没有查询能力:要查"第50行到第100行的内容"?对不起,做不到
- Sync后缀是同步操作:简单直接,会阻塞线程;异步版本不带Sync,用callback/await
五、对比总结:到底选哪个?
| 维度 | Preferences | 关系型数据库 | 文件 |
|---|---|---|---|
| 存储形式 | 键值对 | 表格(行+列) | 任意二进制/文本 |
| 查询能力 | 按key取值 | 条件查询、排序、分页 | 无 |
| 数据量 | 小(<1万条,<2MB) | 大 | 任意 |
| 学习成本 | 最低 | 中等(需了解SQL和几个概念) | 低(会C语言就会) |
| import | @kit.ArkData |
@kit.ArkData |
@kit.CoreFileKit |
| 持久化 | 需手动flush | 自动持久化 | 自动持久化 |
选型口诀:
- 少量设置项 → Preferences(便利贴够用)
- 有结构的数据 → 关系型数据库(Excel才好查)
- 任意内容/不需要查询 → 文件(记事本最自由)
六、实战:不同场景怎么选?
场景1:计算器历史记录
需求:存表达式和结果,支持单个删除和全部删除。
分析:数据量不大(用户不会算几万次),结构简单(表达式+结果+时间),不需要复杂查询。
选择:Preferences。把历史记录数组JSON.stringify后存一个key就行。数据量小的时候够用,代码也简单。
场景2:聊天室消息
需求:存聊天消息,按时间排序,按聊天对象筛选,支持分页加载。
分析:数据量大,有结构(发送者+内容+时间戳),需要条件查询和排序。
选择:关系型数据库。建一个MESSAGE表,用RdbPredicates查询条件,分页加载用limit+offset。
场景3:App运行日志
需求:记录运行时的日志信息,追加写入,不需要查询,出问题时整体查看。
分析:纯文本,追加写入,不需要结构化查询。
选择:文件。openSync打开,writeSync追加,日志文件天然适合文件存储。
三种存储方案没有优劣之分,只有场景之分。就像你不能拿便利贴去记账,也不能拿Excel去写日记------选对工具,事半功倍。
我自己踩过的坑总结一下:
- Preferences写完必须flush,否则关App数据就没了
- ResultSet查完必须close,否则内存泄漏
- 文件操作前必须accessSync检查存在,否则闪退
- import fileIo的时候要写
import { fileIo as fs },不是import { fs } - struct成员变量用private不用let,@State只给UI需要响应的变量用
- 文件读取用TextDecoder解码,别用String.fromCharCode------中文会乱码
希望这篇文章能帮你少走弯路。如果你也在学鸿蒙,欢迎交流!
参考资料: