news 2026/6/11 11:52:18

聊天历史从 Preferences 搬到关系型数据库(RDB):为什么换、怎么换、踩了什么坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
聊天历史从 Preferences 搬到关系型数据库(RDB):为什么换、怎么换、踩了什么坑

聊天历史从 Preferences 搬到关系型数据库(RDB):为什么换、怎么换、踩了什么坑

项目:MyApplication(AI 助手 demo)
目标文件:chat/src/main/ets/utils/ChatRdb.ets(新建) +controller/ChatSessionController.ets+controller/ChatHistoryController.ets+EntryAbility.ets
主题:早期 demo 用Preferences + JSON.stringify(整个 sessions 列表)存聊天记录,会话数一上来就开始卡。今天把它换成关系型数据库(@kit.ArkData / relationalStore),建两张表chat_session/chat_message+ 一个索引。本篇把 ArkTS 里用 RDB 的最小闭环 + 这次替换的设计决策记下来。


一、为什么 Preferences 顶不住了

旧实现(ChatPersist.ets):

constSTORE_NAME='chat_history'constKEY_SESSIONS='sessions'exportfunctionsaveSession(ctx,session:ChatSession):void{conststore=preferences.getPreferencesSync(ctx,{name:STORE_NAME})constraw=store.getSync(KEY_SESSIONS,'[]')asstringconstlist=JSON.parse(raw)asChatSession[]// 1. 把全部会话读出来constidx=list.findIndex(s=>s.id===session.id)if(idx>=0)list[idx]=session// 2. 在内存里改elselist.push(session)store.putSync(KEY_SESSIONS,JSON.stringify(list))// 3. 整个列表 stringify 写回store.flush()}

这是经典的KV 持久化(Key-Value)套路:一个 key 装下所有数据。问题:

维度Preferences 版现实情况
写一次的成本序列化整个 sessions 列表 → 写整张 KV100 个会话 × 平均 30 条消息 ≈ 几百 KB JSON 每次都要全量重写
读单条必须先JSON.parse全部 → 找到 id 那条想看一个会话,要把无关的 99 个也解析出来
查询没有 query,只能加载全量后在内存 filter / sort历史页要"按 updateTime 倒序" → 全表读 + sort
崩溃恢复如果中途 stringify 出错,要么全成功要么全失败一条脏数据可能让全部历史读不出来
并发flush 是异步的,多次 put 间隔写可能错位流式生成边写边来,时序难保证

KV 是整存整取,对会话这种"按 id 检索 + 排序 + 增量更新"的数据完全不合身。

RDB(SQLite 封装)才是聊天记录这种数据应该用的存储

维度RDB
写一次单条 insert / update / delete
读单条WHERE id = ?索引命中,毫秒级
查询SQL / RdbPredicates 支持 where / order / limit / join
崩溃恢复事务、WAL,原子性有保障
并发RDB 自带行锁 / 文件锁

二、ArkTS 里用 RDB 的最小五步

来自@kit.ArkDatarelationalStore模块。最小闭环:

2.1getRdbStore拿 store

import{relationalStore}from'@kit.ArkData'import{common}from'@kit.AbilityKit'constconfig:relationalStore.StoreConfig={name:'chat.db',securityLevel:relationalStore.SecurityLevel.S1}conststore=awaitrelationalStore.getRdbStore(ctx,config)
  • name—— SQLite 文件名,每个 App 私有目录下一个
  • securityLevel—— 数据敏感等级,决定加密 / 访问控制策略。S1 是最低,适合本地缓存类数据。如果存账户 token / 用户隐私走 S3+
SecurityLevel.S1 低敏感(demo 数据、本地缓存) SecurityLevel.S2 低敏感但有一定隐私(昵称、头像 URL) SecurityLevel.S3 中敏感(手机号、地理位置) SecurityLevel.S4 高敏感(身份证、银行卡)

一旦选定,升级 securityLevel 是兼容的,降级是不兼容的。第一次选高一点不会错。

2.2executeSql建表 + 建索引

constSQL_CREATE_SESSION=`CREATE TABLE IF NOT EXISTS chat_session ( id TEXT PRIMARY KEY, title TEXT NOT NULL, preview TEXT, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL )`constSQL_CREATE_MESSAGE=`CREATE TABLE IF NOT EXISTS chat_message ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, create_time INTEGER NOT NULL, card_json TEXT )`constSQL_CREATE_INDEX=`CREATE INDEX IF NOT EXISTS idx_msg_session ON chat_message(session_id, create_time)`awaitstore.executeSql(SQL_CREATE_SESSION)awaitstore.executeSql(SQL_CREATE_MESSAGE)awaitstore.executeSql(SQL_CREATE_INDEX)

IF NOT EXISTS让这段在每次 init 时都能安全跑 —— 已存在就跳过。

2.3RdbPredicates+query

constpred=newrelationalStore.RdbPredicates('chat_session')pred.orderByDesc('update_time')// ORDER BY update_time DESCconstrs=awaitstore.query(pred,['id','title','preview','create_time','update_time'])while(rs.goToNextRow()){consts=newChatSession()s.id=rs.getString(rs.getColumnIndex('id'))s.title=rs.getString(rs.getColumnIndex('title'))// ...}rs.close()// 必须 close,否则游标泄漏

RdbPredicates是用链式 API 拼WHERE / ORDER / LIMIT子句的对象,可读性比手拼 SQL 字符串高,也避免注入。

2.4ValuesBucket+insert

constsValues:relationalStore.ValuesBucket={'id':session.id,'title':session.title,'preview':session.preview,'create_time':session.createTime,'update_time':session.updateTime}awaitstore.insert('chat_session',sValues)

ValuesBucket就是{ 列名: 值 }的字典,按列名插值。注意 ArkTS 里 key 必须是字符串字面量(有引号),这是 strict type 的要求。

2.5delete

constpred=newrelationalStore.RdbPredicates('chat_session')pred.equalTo('id',sessionId)awaitstore.delete(pred)

equalTo / notEqualTo / greaterThan / lessThan / between / like / in全套都有,跟 SQL WHERE 一一对应。


三、这次的表设计

3.1 拆成 session + message 两张表

旧的 Preferences 把ChatSession.messages: ChatMessage[]整段塞 JSON 里。RDB 设计的核心是把"一对多关系拆开成两张表 + 外键列"

chat_session (元数据,一行一会话) ├─ id 会话主键,时间戳字符串 ├─ title 会话标题(第一条 user 消息前 20 字) ├─ preview 预览(最后一条消息前 30 字) ├─ create_time └─ update_time chat_message (内容,一行一消息) ├─ id 消息主键 ├─ session_id ← 外键指向 chat_session.id ├─ role 'user' | 'assistant' ├─ content 文本内容 ├─ create_time └─ card_json 卡片对象 JSON.stringify(PickupCard) idx_msg_session (chat_message 的复合索引) ON chat_message(session_id, create_time)

3.2 为什么加idx_msg_session

聊天最常见的查询是“按 session_id 拉某个会话的所有消息,按时间正序”

SELECT*FROMchat_messageWHEREsession_id=?ORDERBYcreate_timeASC

如果没有索引,会全表扫 + 排序;加上(session_id, create_time)这个复合索引:

  • session_id等值过滤直接命中索引
  • create_time在索引上已经排好序,不需要额外的 sort 操作

代价:每条 insert 多写一次索引(很小)。对读多写少的场景,索引是必加的

3.3 卡片字段:card_json用 JSON 兜底

ChatMessagePlain.card是个PickupCard | null,PickupCard 里面套着PickupPoint[]等嵌套结构。不想为这一个字段再拆出第三张表,干脆:

card_json:m.card!==null?JSON.stringify(m.card):''

读出来时反序列化:

constcardJson=rs.getString(rs.getColumnIndex('card_json'))m.card=cardJson.length>0?(JSON.parse(cardJson)asObject):null

判断"是否有卡片"用length > 0而不是!== null—— RDB 的 TEXT 列空值取出来是空串而不是 null,写代码时要适配。


四、最关键的转换:ChatMessageChatMessagePlain

ArkUI 响应式带来一个隐藏麻烦:@ObservedV2 / @Trace装饰的对象,JSON.stringify 拿不到完整字段。装饰器会改写属性描述符(getter/setter + 内部存值),stringify走的是默认 enumerable 字段路径,Trace 字段会丢

解决方案:存储用普通对象,UI 用 ObservedV2 对象,两者之间做转换

// UI 用:ChatMessage(@ObservedV2 / @Trace 装饰,驱动气泡实时更新)@ObservedV2exportclassChatMessage{@Tracecontent:string=''@Tracerole:string=''// ...}// 存储用:ChatMessagePlain(普通 class,字段裸露)exportclassChatMessagePlain{content:string=''role:string=''// ...}

写入时ChatMessage → ChatMessagePlain

privateconvertToPlain():ChatMessagePlain[]{returnthis.vm.historyMessage.map((source:ChatMessage,i:number)=>{constplain=newChatMessagePlain()plain.id=source.id?source.id:`${this.vm.sessionId}_${i}`plain.role=source.role?source.role:'assistant'plain.content=source.content?source.content:''plain.createTime=source.createTime?source.createTime:Date.now()plain.sessionId=source.sessionId?source.sessionId:this.vm.sessionId plain.card=source.card?source.card:nullreturnplain})}

读出来时ChatMessagePlain → ChatMessage

privateconvertToObservable(plains:ChatMessagePlain[],sessionId:string):ChatMessage[]{returnplains.map((plain,i)=>{constmsg=newChatMessage()msg.id=plain.id?plain.id:`${sessionId}_${i}`msg.role=plain.role?plain.role:'assistant'msg.content=plain.content?plain.content:''// ...returnmsg})}

每个字段都加? : fallback——不信任老数据。这一层是为了过去版本写下的脏数据 / 字段缺失的情况能正常加载。

这是 ArkTS 响应式 + 持久化的标准模式:Observable 类负责 UI 响应,Plain 类负责 IO 序列化,两者之间显式 mapper。不要试图给 ObservedV2 类直接 stringify。


五、初始化时机:EntryAbility.onCreate 调一次

数据库要在 App 一启动就准备好,不能等到第一次 saveSession 才 init —— 那时候已经晚了(可能正在生成回复,等不起 init 的 await)。

// entry/src/main/ets/entryability/EntryAbility.etsimport{ChatRdb}from'chat'exportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{HMRouterMgr.init({context:this.context})// 数据库初始化(一次)ChatRdb.init(this.context)HMRouterMgr.registerGlobalInterceptor({...})// ...}}

ChatRdb.init内部用静态 store 字段 + 短路:

privatestaticstore:relationalStore.RdbStore|null=nullstaticasyncinit(ctx:common.UIAbilityContext):Promise<void>{if(ChatRdb.store!==null)return// 已 init 过直接返回constconfig:relationalStore.StoreConfig={name:'chat.db',securityLevel:relationalStore.SecurityLevel.S1}try{ChatRdb.store=awaitrelationalStore.getRdbStore(ctx,config)awaitChatRdb.store.executeSql(SQL_CREATE_SESSION)awaitChatRdb.store.executeSql(SQL_CREATE_MESSAGE)awaitChatRdb.store.executeSql(SQL_CREATE_INDEX)LogUtil.i('ChatRdb','init success')}catch(e){LogUtil.e('ChatRdb','init failed: '+JSON.stringify(e))}}

后续所有方法都守一道空检查 + 静默返回

staticasyncloadSessions():Promise<ChatSession[]>{if(ChatRdb.store===null)return[]// ← init 还没跑完直接返回空// ...}

为什么不抛错而是返回空:聊天页加载历史失败时,给用户看[](“暂无会话”)比给个红屏好。崩了反而让用户怀疑 App 坏了,错日志去 LogUtil.e 留着开发自查。


六、跨模块导出:把 ChatRdb 挂在 chat HAR 的对外 API 上

chat/Index.ets

// === Chat HAR 对外公开 API ===// 唯一对外暴露的 UI 组件:让 entry 的 HomePage 嵌入 Chat Tabexport{ChatTabComp}from'./src/main/ets/components/ChatTabComp'// 路由常量export{ChatRoutes}from'./src/main/ets/constants/ChatRoutes'// 跨页面通信export{ChatLoadState,getChatLoadState}from'./src/main/ets/viewmodel/ChatLoadState'// 关系型数据库,聊天记录存入数据库中,调用这个方法export{ChatRdb}from'./src/main/ets/utils/ChatRdb'

entry 模块只需要 import 一个ChatRdb就能在 EntryAbility 里调 init。别让 entry 直接 import chat 模块的内部路径—— 那样就破坏 HAR 边界了。


七、保存时机:流式结束才写一次

聊天 UI 里 AI 是边生成边追加 token 的,不能每个 token 都写库(写放大很离谱)。ChatTabComp里用@Monitor监听isLoading的变化:

@Monitor('vm.isLoading')onLoadingChange():void{if(this.vm.isLoading){return// 流式开始,不写}// 流式结束(isLoading: true → false),把完整会话写一次constctx=this.getUIContext().getHostContext()ascommon.UIAbilityContextthis.sessionController.persistSession(ctx)}

persistSession内部走"先 delete 再 insert"的最简实现:

// 1. 删 session 行constsPred=newrelationalStore.RdbPredicates('chat_session')sPred.equalTo('id',session.id)awaitChatRdb.store.delete(sPred)// 2. 插 session 行awaitChatRdb.store.insert('chat_session',sValues)// 3. 删该 sessionId 下的所有 messageconstmPred=newrelationalStore.RdbPredicates('chat_message')mPred.equalTo('session_id',session.id)awaitChatRdb.store.delete(mPred)// 4. 批量插 messagefor(leti=0;i<session.messages.length;i++){awaitChatRdb.store.insert('chat_message',mValues)}

没有用事务 / batchInsert 是有意为之—— demo 阶段每次保存就是 1 个 session + 几十条 message 量级,单条 insert 跑得动。等会话规模再上一个量级(>500 条 msg / session)再换batchInsert不要为了"看起来更专业"提前优化


八、数据流:四个角色协作

┌────────────────────┐ │ EntryAbility │ onCreate 调 ChatRdb.init └─────────┬──────────┘ │ ▼ ┌────────────────────┐ query / orderByDesc / delete / insert │ ChatRdb │ ───────────────────────────────────────┐ │ (chat/utils) │ │ └─────────┬──────────┘ │ │ │ ┌──────┴───────┐ │ │ │ │ ▼ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ ChatHistoryController│ 历史页 load / delete │ chat.db (SQLite) │ │ │ │ chat_session │ └──────────────────────┘ │ chat_message │ ┌──────────────────────┐ 流式结束 saveSession │ idx_msg_session │ │ ChatSessionController│ ─────────────────────────│ │ │ │ ChatMessage↔Plain 双向 └──────────────────────┘ └──────────────────────┘
  • EntryAbility.onCreate—— 全局 init 一次
  • ChatRdb—— 唯一的 RDB 入口,所有 SQL / Predicates 在这里
  • ChatSessionController—— 流式结束触发 saveSession + 双向转换 ObservedV2 ↔ Plain
  • ChatHistoryController—— 历史页加载列表 + 删除单条

业务层完全不感知 SQL / RdbPredicates—— 它们只调ChatRdb.loadMessages(id)这种语义化方法。


九、几个意外发现

9.1 RdbPredicates 上的 column name 是 SQL 列名,不是 ArkTS 属性名

// ✅ SQL 列名(下划线风格)pred.orderByDesc('update_time')// ❌ 不是 ArkTS 属性名(camelCase)pred.orderByDesc('updateTime')// 查不到

ChatSession.updateTime是 ArkTS 属性,chat_session.update_time是数据库列名。RdbPredicates 操作的是数据库,永远用列名。这是 ORM 缺位下手写映射要小心的地方。

9.2 resultSet 必须close

constrs=awaitstore.query(pred)while(rs.goToNextRow()){...}rs.close()// ← 不写这一行会泄漏文件句柄

ArkTS 没有 RAII / using,资源释放靠程序员自觉。漏 close 单看不出问题,但在循环里反复查询会很快耗尽句柄。

9.3 PRIMARY KEY 冲突时 insert 是抛异常的

我一开始想"既然 id 是主键,insert 同 id 时应该会失败 / 覆盖",结果是抛异常。所以这次 saveSession 走的是“先 delete 再 insert”而不是 “INSERT OR REPLACE”(后者需要拼原始 SQL)。

更优雅的写法是INSERT OR REPLACE INTO ... ON CONFLICT,但 demo 阶段两步走读起来清楚。


十、一句话心智模型

KV 是「整存整取」,RDB 是「按列检索 + 增量更新」。 聊天记录这种"按 id 拉 + 按时间排"的数据,必须用 RDB。 五步走:getRdbStore → executeSql 建表 → Predicates 查 → ValuesBucket 写 → delete 删。 ObservedV2 不能直接 stringify,加一层 Plain 做 IO 双向 mapper。 Init 在 EntryAbility.onCreate 调一次,业务层只走语义化接口,不碰 SQL。

十一、顺口溜

KV 整存整取慢,每改全表写一遍; RDB 列检索、查询快,索引加上飞起来。 ObservedV2 别 stringify,Trace 字段会消失; Plain 类做 IO 映,双向 mapper 保字段。 init 放在 onCreate,store 静态短路开; resultSet 用完要 close,否则句柄泄不开。

十二、参考

  • relationalStore API(@kit.ArkData)
  • RDB 数据持久化指南
  • Preferences API(@kit.ArkData)(旧实现对照)
  • 本系列相关:04-chat-local-storage / 08-chat-history-persistence-bugfix / 23-arkts-hilog-logutil-replace-console
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/11 11:51:57

视频硬字幕提取技术深度解析:如何用本地OCR实现95%去重准确率

视频硬字幕提取技术深度解析&#xff1a;如何用本地OCR实现95%去重准确率 【免费下载链接】video-subtitle-extractor 视频硬字幕提取&#xff0c;生成srt文件。无需申请第三方API&#xff0c;本地实现文本识别。基于深度学习的视频字幕提取框架&#xff0c;包含字幕区域检测、…

作者头像 李华
网站建设 2026/6/11 11:50:12

极致响应速度背后,Gemini 3.5 Flash 存在哪些取舍?

概要2026年5月19日Google I/O大会上&#xff0c;Gemini 3.5 Flash正式上线&#xff0c;直接成为Gemini App和搜索服务的默认模型。输出速率289 tokens/s&#xff0c;比GPT-5.5和Claude Opus 4.7快4倍以上&#xff0c;成本不到对手一半。但跑分背后&#xff0c;长上下文召回率暴…

作者头像 李华
网站建设 2026/6/11 11:47:54

Maccy终极指南:如何在macOS上实现高效剪贴板管理

Maccy终极指南&#xff1a;如何在macOS上实现高效剪贴板管理 【免费下载链接】Maccy Lightweight clipboard manager for macOS 项目地址: https://gitcode.com/gh_mirrors/ma/Maccy Maccy是一款专为macOS设计的轻量级剪贴板管理器&#xff0c;它能智能记录您复制的所有…

作者头像 李华
网站建设 2026/6/11 11:46:53

NoC(片上网络)架构探析:从拓扑结构到性能优化

1. NoC架构基础&#xff1a;从总线瓶颈到片上网络革命 第一次接触NoC&#xff08;Network on Chip&#xff09;这个概念时&#xff0c;我正被一个多核处理器项目折磨得焦头烂额。当时我们使用的传统总线架构就像早高峰的地铁1号线&#xff0c;所有核心都要挤在同一条数据通道上…

作者头像 李华
网站建设 2026/6/11 11:46:53

【技术解析】FSD V2:如何用虚拟体素破解3D稀疏目标检测的泛化难题

1. 从稀疏检测的困境到虚拟体素革命 第一次接触激光雷达点云数据时&#xff0c;我被它的稀疏性震撼到了——那些漂浮在空中的离散光点&#xff0c;就像夜空中若隐若现的星星。这种稀疏性给3D目标检测带来了巨大挑战&#xff0c;特别是在处理远距离物体或遮挡场景时。传统完全稀…

作者头像 李华
网站建设 2026/6/11 11:46:20

深入解析MCU Flash模块:中断、ECC与安全机制实战指南

1. 项目概述&#xff1a;为什么我们需要深入理解MCU的Flash模块&#xff1f;在嵌入式开发的日常工作中&#xff0c;我们常常把Flash当作一个“黑盒”——写个程序&#xff0c;编译、烧录、运行&#xff0c;只要不出错&#xff0c;就很少去关心它内部是怎么工作的。直到有一天&a…

作者头像 李华