鸿蒙本地存储三剑客

鸿蒙本地存储三剑客: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去写日记------选对工具,事半功倍

我自己踩过的坑总结一下:

  1. Preferences写完必须flush,否则关App数据就没了
  2. ResultSet查完必须close,否则内存泄漏
  3. 文件操作前必须accessSync检查存在,否则闪退
  4. import fileIo的时候要写import { fileIo as fs },不是import { fs }
  5. struct成员变量用private不用let,@State只给UI需要响应的变量用
  6. 文件读取用TextDecoder解码,别用String.fromCharCode------中文会乱码

希望这篇文章能帮你少走弯路。如果你也在学鸿蒙,欢迎交流!


参考资料:

相关推荐
网络工程小王2 小时前
【HCIE-AI】10.pytorch模型迁移分析
人工智能·学习·华为·llama
ZENERGY-众壹2 小时前
跨品牌逆变器升级:华为锦浪德业 API 对接的 7 个坑
分布式·华为·数据归一化·光伏运维·逆变器api
●VON2 小时前
鸿蒙 PC Markdown 编辑器系统浏览器与图片查看器独立验收
华为·编辑器·harmonyos·鸿蒙
红烧大青虫2 小时前
HarmonyOS开发实战:小分享-WaterFlow 瀑布流布局实现模板墙
华为·harmonyos·鸿蒙
程序员黑豆3 小时前
鸿蒙应用开发:Stack堆叠组件实战——实现微信消息角标效果
前端·harmonyos
Catrice03 小时前
HarmonyOS ArkTS 实战:实现一个心情日记与情绪追踪应用
华为·harmonyos
●VON4 小时前
鸿蒙 PC Markdown 编辑器通信架构:受限 ArkTS-JavaScript Bridge
华为·架构·编辑器·harmonyos·鸿蒙
一缕清烟在人间4 小时前
HarmonyOS开发实战:小分享-TextEditPage文字编辑器——Header+TextArea+工具栏
后端·华为·harmonyos·鸿蒙
2501_918582374 小时前
HarmonyOS应用开发实战:小事记 - 应用包结构:HAP/HSP/HAR 的三层架构与 deliveryWithInstall 策略
华为·架构·harmonyos·鸿蒙