【HarmonyOS学习笔记】2026-07-26 | @Trace 序列化 _ob 前缀陷阱与消息持久化
date: 2026-07-26
tags: HarmonyOS, ArkTS, @Trace, @ObservedV2, JSON.stringify, toJSON, 持久化, fileIo, preferences
type: 踩坑实录
现象
消息列表持久化后,save() 日志显示写入成功,但 load() 返回 0 条。每次重启 App,界面上显示的都是硬编码的 Mock 数据,用户输入的消息全部丢失。
排查
在 load() 中加了一行日志打印第一条数据的 keys:
arkts
if (i === 0) {
hilog.info(this.logDomain, this.logTag,
'load item0 keys: %{public}s', JSON.stringify(Object.keys(item)))
}
输出:
load item0 keys: ["__ob_id","__ob_timestamp","__ob_sender","__ob_content","__ob_xp"]
所有字段前面被加了 __ob_ 前缀。 load() 中用 record['id'] 取值,自然取到 undefined,全部跳过,返回空数组。
根因
消息模型用了 @ObservedV2 + @Trace 装饰器:
arkts
@ObservedV2
export class ChatMessage {
@Trace id: string = ''
@Trace timestamp: number = 0
@Trace sender: string = 'user'
@Trace content: string = ''
@Trace xp: number = 0
}
@Trace 装饰器的工作原理是属性劫持 ------它把原始属性替换成带 __ob_ 前缀的内部属性,外部访问时通过 getter/setter 拦截以实现变化追踪。
JSON.stringify() 序列化时遍历对象自身可枚举属性,拿到的是被劫持后的内部键名 __ob_id、__ob_timestamp 等,而不是代码中写的 id、timestamp。
一句话:@Trace 为了观察属性变化劫持了属性名,JSON.stringify 看到的是劫持后的内部键名。
修复
1. ChatMessage 加 toJSON()
JSON.stringify() 发现对象有 toJSON() 方法时,会用其返回值替代对象本身进行序列化:
arkts
@ObservedV2
export class ChatMessage {
@Trace id: string = ''
@Trace timestamp: number = 0
@Trace sender: string = 'user'
@Trace content: string = ''
@Trace xp: number = 0
static of(id: string, timestamp: number, sender: string, content: string, xp: number): ChatMessage {
const msg = new ChatMessage()
msg.id = id
msg.timestamp = timestamp
msg.sender = sender
msg.content = content
msg.xp = xp
return msg
}
toJSON(): Record<string, Object> {
return {
'id': this.id as Object,
'timestamp': this.timestamp as Object,
'sender': this.sender as Object,
'content': this.content as Object,
'xp': this.xp as Object
} as Record<string, Object>
}
}
toJSON() 通过 this.id 等 getter 读取属性值(getter 返回的是正确的值),然后以正确的键名组装成普通对象返回。JSON.stringify 拿到这个普通对象,序列化出 "id" 而不是 "__ob_id"。
2. MessageStore load 加 _ob 降级读取
已经存入磁盘的旧数据仍然是 __ob_ 前缀的键名,不能直接丢弃。load 时先尝试正常键名,取不到再降级读 __ob_ 前缀:
arkts
let id = record['id']
let timestamp = record['timestamp']
let sender = record['sender']
let content = record['content']
let xp = record['xp']
if (id === undefined) { id = record['__ob_id'] }
if (timestamp === undefined) { timestamp = record['__ob_timestamp'] }
if (sender === undefined) { sender = record['__ob_sender'] }
if (content === undefined) { content = record['__ob_content'] }
if (xp === undefined) { xp = record['__ob_xp'] }
if (id !== undefined && timestamp !== undefined && sender !== undefined &&
content !== undefined && xp !== undefined) {
result.push(ChatMessage.of(
id as string, timestamp as number, sender as string,
content as string, xp as number
))
}
这样旧数据能正常加载,新数据(toJSON 修复后)也能正常加载。下次 save 时会以正确键名写回,逐渐完成数据迁移。
fileIo vs preferences:为什么选同步写入
消息持久化有两种方案可选:
| 维度 | preferences | fileIo |
|---|---|---|
| 写入方式 | put() + flush() |
writeTextSync() |
| flush 时效 | 异步,不保证立即写盘 | 同步,写入即生效 |
| App 异常退出 | flush 可能没执行完,数据丢失 | 已写入,不丢失 |
| 适合场景 | 偏好设置(丢了也不致命) | 业务数据(丢了影响用户) |
之前用 preferences 存消息,App 退出时 flush() 还没写完就进程终止了,消息丢失。换成 fileIo.writeTextSync() 后写入是同步的,代码执行完数据已在磁盘上。
arkts
save(messages: ChatMessage[]): void {
const json: string = JSON.stringify(messages)
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC)
fs.writeSync(file.fd, json)
fs.closeSync(file)
}
三条同步调用:openSync → writeSync → closeSync,没有异步间隙,不怕进程意外终止。
未踩的坑预告
项目中另一个模型也用了 @ObservedV2 + @Trace:
arkts
@ObservedV2
export class TaskItem {
@Trace id: string = ''
@Trace name: string = ''
@Trace category: string = ''
@Trace xp: number = 0
@Trace completed: boolean = false
@Trace source: string = ''
}
没有 toJSON()。 目前 TaskItem 没有持久化需求所以没踩到,但将来如果要 JSON.stringify 存盘,会一模一样地踩坑。
通用教训
| 陷阱 | 表现 | 防御 |
|---|---|---|
| @Trace 劫持属性名 | JSON.stringify 输出 __ob_ 前缀 |
加 toJSON() 返回正确键名 |
| 旧数据兼容 | 修复后已存盘的旧数据无法读取 | load 时加 __ob_ 降级读取 |
| flush 异步丢数据 | App 退出时 flush 未完成 | 业务数据用 fileIo 同步写入 |
| 其他 @Trace 类 | 没 toJSON() 将来踩同样的坑 | 所有 @Trace 类统一加 toJSON() |
一条规则:凡是用 @Trace 装饰的类,只要需要 JSON.stringify 序列化(持久化、网络传输、日志打印),就必须自定义 toJSON()。 没有例外。
学习小结 :
@Trace装饰器通过属性劫持实现变化追踪,副作用是JSON.stringify输出__ob_前缀键名而非原始键名。修复方案是自定义toJSON()返回正确键名的普通对象,并在 load 时加降级读取兼容旧数据。业务数据持久化选fileIo同步写入,避免preferences.flush()异步丢失。
懿路向前 · AI辅助整理
2026-07-26