HarmonyOS时光胶囊——数据库层的持久化设计与数据操作

HarmonyOS时光胶囊——数据库层的持久化设计与数据操作
CapsuleDatabase 是整个 App 的数据心脏——所有页面都通过它读写胶囊数据。它的实现分两层内存层用数组存储 Capsule 对象持久化层用 Preferences 存储 JSON 字符串。写入时先更新内存再同步磁盘读取时只从 Preferences 加载一次。这种内存文件的双层架构是轻量级 App 的标准持久化方案——不需要 SQLite 的重量级支持又能保证数据不丢失。完整效果一、Database.ets 的角色定位它不是什么误解实际情况SQLite 数据库不是——用 Preferences 存 JSONORM 框架不是——手写 CRUD 方法全局状态管理不是——只管胶囊数据它是什么Database.ets 数据模型定义 CRUD 操作函数 Preferences 持久化一个文件承担三个职责——接口定义、业务逻辑、存储引擎。对于小型 App 来说拆分三个文件反而增加理解成本。放在一个文件里用户打开就能看到完整的数据层。二、文件内的模块结构从 import 推断的导出项// model/Database.ets 的导出export{Capsule,UnlockOption,...}// 数据模型从 Capsule.ets 重导出export{getCapsuleState,CapsuleState}// 状态判断从 Capsule.ets 重导出exportclassCapsuleDatabase{// 数据库类exportfunctionsetCurrentCapsuleId(id:number):void// 全局记录exportfunctiongetCurrentCapsuleId():number重导出的动机// Database.ets 顶部export{Capsule,UnlockOption,getCapsuleState,CapsuleState}from./Capsule重导出让其他页面只需要 import Database.ets 一个路径。如果没有重导出首页需要import { Capsule } from ../model/Capsule和import { CapsuleDatabase } from ../model/Database——两个 import。有了重导出只需要import { Capsule, CapsuleDatabase } from ../model/Database——一个 import。三、Capsule 接口的完整定义从使用方式推断的完整接口exportinterfaceCapsule{id:number// 唯一标识content:string// 胶囊内容文字mood:string// 心情 emoji如 unlockAt:number// 解锁时间戳毫秒isOpened:boolean// 是否已开启createdAt:number// 创建时间戳毫秒tags:string[]// 标签数组favorited:boolean// 是否收藏}八个字段的职责字段类型写入时机读取方idnumbercreateCapsule所有页面contentstringcreateCapsule详情页、列表moodstringMoodPicker详情页、列表unlockAtnumbercreateCapsule倒计时、状态判断isOpenedboolean详情页开启状态判断createdAtnumbercreateCapsule排序、月历tagsstring[]标签输入搜索、筛选favoritedboolean详情页收藏收藏筛选createdAt 由系统生成不可修改——保证数据真实性。用户不能把一个3天前创建的胶囊改成今天创建。unlockAt 由用户设定——这是唯一的时间自由度。四、CapsuleDatabase 类的核心属性推断的类结构exportclassCapsuleDatabase{privatecapsules:Capsule[][]// 内存数据privatecontext:UIAbilityContext// Preferences 需要的上下文privateisLoaded:booleanfalse// 是否已加载constructor(context:UIAbilityContext){this.contextcontext}}三个属性的设计意图属性作用为什么需要capsules内存缓存所有页面读取不需要每次解析 JSONcontextUIAbility 上下文Preferences 需要 AbilityContextisLoaded加载状态防止重复加载避免空数据渲染isLoaded 保证只加载一次——避免每次读取都解析 JSON。第一次loadData()后 isLoadedtrue后续直接用内存数组。五、loadData从 Preferences 加载加载流程asyncloadData():Promisevoid{if(this.isLoaded)return// 已加载则跳过try{constprefsawaitpreferences.getPreferences(this.context,capsules)constjsonawaitprefs.get(data,[])this.capsulesJSON.parse(jsonasstring)asCapsule[]this.isLoadedtrue}catch(e){this.capsules[]this.isLoadedtrue}}五步加载1. 检查 isLoaded → 已加载则直接返回 2. 获取 Preferences 实例键名 capsules 3. 读取 data 键的值默认 [] 4. JSON.parse 解析为 Capsule 数组 5. 设置 isLoaded truePreferences 的存储结构Preferences键名: capsules └→ key: data value: [{id:1,content:...,mood:,...},...]整个胶囊数组序列化成一个 JSON 字符串——存为 Preferences 的一个键值对。Preferences 的本质是键值对存储——不适合存结构化数据但可以存 JSON 字符串。为什么默认值是 ‘[]’constjsonawaitprefs.get(data,[])第一次打开 App 时 Preferences 里没有 ‘data’ 键——返回默认值 ‘[]’。JSON.parse(‘[]’) 得到空数组——没有胶囊。这是 App 的初始状态。错误处理catch(e){this.capsules[]this.isLoadedtrue}JSON 解析失败时回退到空数组——不崩溃。如果存储的 JSON 格式损坏比如手动改了 PreferencesApp 不会崩溃只是丢失所有数据。这是优雅降级——数据丢失比 App 崩溃好。六、getAllCapsules获取所有胶囊getAllCapsules():Capsule[]{returnthis.capsules.slice()}返回副本——不暴露内部数组引用。slice()创建新数组——外部修改返回值不会影响内部数据。这是防御性编程——防止外部意外修改数据库状态。为什么不用只读属性// 不用这种方式getallCapsules():Capsule[]{returnthis.capsules}getter 返回的是同一个引用——外部修改会影响内部数据。slice()返回副本——保证数据隔离。七、getCapsuleById按 ID 查询getCapsuleById(id:number):Capsule|undefined{for(leti0;ithis.capsules.length;i){if(this.capsules[i].idid)returnthis.capsules[i]}returnundefined}线性扫描——因为数据量小不需要 Map。胶囊数量通常在几十到几百——线性扫描的性能完全够用。只有数据量上千时才需要考虑 Map 优化。为什么不用 filter// 不用这种getCapsuleById(id:number):Capsule|undefined{returnthis.capsules.find(cc.idid)}filter/find 会遍历完整个数组——即使已经找到了。for 循环在找到目标后立即 return——提前终止遍历。对于大数组性能差距明显。八、addCapsule新增胶囊写入流程asyncaddCapsule(capsule:Capsule):Promisevoid{// 1. 加入内存数组this.capsules.push(capsule)// 2. 同步到 Preferencesawaitthis.save()}save 的实现privateasyncsave():Promisevoid{try{constprefsawaitpreferences.getPreferences(this.context,capsules)awaitprefs.put(data,JSON.stringify(this.capsules))awaitprefs.flush()}catch(e){// 静默失败}}两步持久化——内存更新 磁盘同步。先更新内存让 UI 立即刷新再异步写入 Preferences 保证数据不丢。如果磁盘写入失败内存数据还在——用户操作不受影响但重启后数据会丢失。JSON.stringify 的序列化JSON.stringify(this.capsules)把 Capsule 数组转成 JSON 字符串——Preferences 只能存基本类型。Preferences 不支持直接存对象——只能存 string/number/boolean。JSON 字符串是最简单的序列化方案。九、updateCapsule更新胶囊asyncupdateCapsule(capsule:Capsule):Promisevoid{// 1. 找到目标并替换for(leti0;ithis.capsules.length;i){if(this.capsules[i].idcapsule.id){this.capsules[i]capsulebreak}}// 2. 同步到 Preferencesawaitthis.save()}直接替换引用——不需要逐字段修改。找到目标后用新对象替换旧对象。这种方式简单直接——不需要比较每个字段是否变化。为什么用引用替换而不是字段修改// 不用这种Object.assign(this.capsules[i],capsule)// 用这种this.capsules[i]capsule引用替换更清晰——新对象直接替换旧对象。Object.assign 可能遗漏新增字段——如果 Capsule 接口新增了字段Object.assign 不会删除旧对象的多余字段。引用替换保证对象完全一致。十、deleteCapsule删除胶囊asyncdeleteCapsule(id:number):Promisevoid{this.capsulesthis.capsules.filter((c:Capsule)c.id!id)awaitthis.save()}filter 返回新数组——排除目标 ID。不修改原数组——创建新数组赋值给 this.capsules。这保证了引用安全——如果有其他地方持有旧数组的引用不会被意外修改。十一、getStats统计信息getStats():CapsuleStats{constnowDate.now()lettotalthis.capsules.lengthletopened0letsealed0letready0for(leti0;ithis.capsules.length;i){constcthis.capsules[i]if(c.isOpened){opened}elseif(c.unlockAtnow){sealed}else{ready}}return{total,opened,sealed,ready}}一次遍历统计所有维度——四次 if-else 判断覆盖三种状态。不需要多次循环——一个 for 循环搞定所有统计。状态判断的优先级c.isOpened true → OPENED已开启 ↓ false c.unlockAt now → SEALED封印中 ↓ false READY可开启isOpened 优先判断——已开启的胶囊不管到期没到期都是 OPENED。逻辑上已开启是最强状态——一旦开启即使时间倒流不可能但逻辑上也应该是 OPENED。十二、clearAll清空所有数据asyncclearAll():Promisevoid{this.capsules[]awaitthis.save()}设置空数组 save——删除 Preferences 里的全部数据。这是破坏性操作——所有胶囊永久删除。在设置页触发需要二次确认。十三、setCurrentCapsuleId全局记录为什么需要这个letcurrentCapsuleId:number0exportfunctionsetCurrentCapsuleId(id:number):void{currentCapsuleIdid}exportfunctiongetCurrentCapsuleId():number{returncurrentCapsuleId}解决的问题详情页需要知道用户点击了哪个胶囊。路由参数可以传 ID但 Preferences 的键名冲突——所有页面共用同一个键名。用模块级变量作为临时通道——首页写入详情页读取。为什么不用 Preferences 传递// 不用这种方式awaitprefs.put(currentCapsuleId,id)constidawaitprefs.get(currentCapsuleId,0)Preferences 是异步 API——读写都需要 await。模块级变量是同步的——立即可用。导航是同步操作等 Preferences 异步返回时页面已经跳转了。变量的生命周期用户点击胶囊 → setCurrentCapsuleId(capsule.id) 写入 → router.pushUrl(...) 跳转 → CapsuleDetail 加载 → getCurrentCapsuleId() 读取 → loadData(capsule.id) 加载数据变量在写入后一直存在——直到下一次写入覆盖。如果用户连续查看多个胶囊每次都会覆盖上一次的值。这没问题——详情页只在 aboutToAppear 时读取一次。十四、Preferences 的存储细节键值对结构Preferences(capsules) key: data → [{id:1,...},{id:2,...}]为什么不分成多个键// 不用这种方式 Preferences(capsules) key: capsule_1 → {id:1,...} key: capsule_2 → {id:2,...}一个键存整个数组——读写都是一次操作。分成多个键需要多次读写——性能差。数组整体序列化/反序列化的开销对于百级数据量可以忽略。flush 的重要性awaitprefs.put(data,JSON.stringify(this.capsules))awaitprefs.flush()// 必须调用put 只写入内存缓冲区——flush 才写入磁盘。如果不调用 flush数据在内存里——App 被杀掉后数据丢失。flush 是确认保存的操作。十五、数据层的整体架构UI 层Index/Detail/Shuffle ↓ 读写胶囊 Database.etsCapsuleDatabase ↓ JSON 序列化 Preferences键值对存储 ↓ 文件系统 磁盘文件四层架构——UI→数据库→Preferences→磁盘。每一层只和相邻层交互——UI 不直接读 PreferencesPreferences 不直接操作磁盘系统处理。数据流方向操作数据流向延迟读取磁盘→Preferences→内存→UI首次加载时写入UI→内存→Preferences→磁盘异步写入读取只在首次加载时从磁盘读——后续都用内存。写入先更新内存再异步同步磁盘——UI 立即响应。这是读缓存异步写的经典模式。十六、为什么不用 SQLite特性Preferences JSONSQLite复杂度低一个文件高ORM 迁移查询能力无全量加载SQL 查询数据量上限~1MB无限制适用场景小型 App500 条大型 App1000 条时光胶囊的胶囊数量通常在几十到几百——Preferences JSON 完全够用。引入 SQLite 增加了大量复杂度数据库创建、表结构定义、数据迁移但收益为零——数据量太小SQL 查询的优势体现不出来。Preferences 的 1MB 上限Preferences 的单个键值对大小上限约 1MB。一个 Capsule 对象 JSON 序列化后约 200-300 字节——1MB 可以存约 3000-5000 个胶囊。对于个人使用完全够用。