ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS 7 Preferences:折叠屏批注草稿检查点回放【鸿蒙心迹】

HarmonyOS 7 Preferences:折叠屏批注草稿检查点回放【鸿蒙心迹】 一份批注草稿最难处理的并不是屏幕展开后画布要变宽而是页面已经销毁过一次、用户又回来时我们究竟应该相信哪份数据。界面上的笔迹可能看起来完整持久化文件却停在上一个检查点反过来某个操作已经写入存储新的页面又把它当成未提交事件执行一遍。两种情况都不会在普通的静态布局预览里出现。这里构造一个ProofDraftDock演示工程任务编号PRF-1010-13文稿proof_214。用36条已提交批注作为基线再将3条带有稳定操作ID的修改保存为待回放操作。为了专门观察重复事件把其中一条事件投递两遍所以恢复器收到的是4次投递、3个唯一操作。场景只模拟窗口宽度从420vp变成780vp以及页面实例重建不声称模拟了操作系统杀进程或获得了真机崩溃恢复保证。一、把“页面还在”与“修改已经落盘”分开设计这类草稿功能时最容易犯的错误是把一个State数组当成持久数据源新增批注时直接push页面切回来时再读一次Preferences。问题是这两个动作属于两种不同的生命周期。State确保当前组件的显示可以随着值变化刷新却不能告诉后续新建的组件哪些操作已经可靠地执行过持久化。这次示例约定baseRevision24的基线有36条编辑会话产生op_037、op_038、op_039组成revision25的未合并操作区。模拟器显示的JOURNAL_FLUSHED仅表示在预设的持久化调用返回后应用把这份待恢复信封标为已提交而不是声称操作系统对任意掉电时刻都提供了数据库级事务。把当前显示数量、已提交数量、待恢复数量做成三个不同字段会多写一些状态转换却少了关键的猜测。比如显示39条并不等于检查点已经从24推进到25如果最后一次flush()未返回成功就只能记录“正在写入”而不该向用户表现为已恢复成功。批注类产品宁可留一条待确认提示也不应该伪造已经存好的承诺。这里的“幂等”限定在应用模型同一个opId不应该使批注集合增加两次。它不能扩展解释为系统文件写入具备跨进程事务更不能说明不同应用进程对同一Preferences文件的竞争已经安全。华为最新Preferences参考明确提示该能力不保证进程并发安全多进程同时访问存在文件损坏和数据丢失风险。因此本文把Demo收敛到单进程、单写入队列并在设计边界里保留升级存储方案的条件。二、工程把什么交给Preferences什么留在业务层先看工程职责。ProofDraftPage.ets负责画面和按钮RecoveryAuditPage.ets负责展示事件水位DraftCheckpointStore.ets把信封读写收在一处ReplayReducer.ets只做纯业务去重和顺序回放。图里的代码与界面属于示意不是某次真实DevEco运行证据。约定一个文稿使用一个JSON字符串键proof_214_envelope。字符串内部含baseRevision、baseCount、pendingOps和acknowledgedIds。此方案的优势是读取端看到的是一个完整的应用对象不必分别读取“笔迹数量”“进度水位”“操作数组”之后再试图拼出同一个版本但这并不把put和flush变成原子数据库事务掉电级持久性仍应通过实际设备测试确认。特别注意acknowledgedIds的职责是记录已确认应用过的操作它不是网络幂等码也不是跨设备全局事务ID。在我们这个很小的本地示例里以字符串集合检查足够直观。真实文稿若允许多设备同编需要额外的冲突版本、作者身份和服务端确认水位单一的本地revision不具备分布式排序意义。恢复模型的关键行为如下。示例结构完全属于应用自建类型ReplayReducer不是任何系统SDK类。为了方便核对不把图像笔迹坐标、压感点阵或序列化大对象混进这段逻辑后者可以在工程里另外保存这里只检验“操作是否重复被执行”。interface DraftOp { id: string; delta: number; } interface DraftEnvelope { baseRevision: number; baseCount: number; pendingOps: DraftOp[]; acknowledgedIds: string[]; } interface ReplayResult { total: number; applied: number; skipped: number; revision: number; } class ReplayReducer { replay(input: DraftEnvelope): ReplayResult { const seen: Setstring new Setstring(input.acknowledgedIds); let total: number input.baseCount; let applied: number 0; let skipped: number 0; for (const op of input.pendingOps) { if (seen.has(op.id)) { skipped; continue; } seen.add(op.id); total op.delta; applied; } return { total, applied, skipped, revision: input.baseRevision 1 }; } }模型规定三个唯一操作的delta都是1事件数组是[op_037, op_038, op_038, op_039]。这样baseCount36applied3skipped1最终total39。我们故意把重复的op_038放在中间不放在数组末尾是为了避免一个只去重连续尾部事件的错误实现侥幸通过。不过上面这段纯函数仅计算最终数量尚未展示操作内容冲突。实际业务要在处理delta之前验证目标图层是否存在、操作类型是否合法、目标笔迹是否已删除以及来源文稿是否匹配。不同操作ID但语义完全相同未必应该去重同一个操作ID的内容发生变化则应该视为协议冲突而不是静默采用最后一份。本文把这一层写进验收条件避免误把Set当成完整的事件溯源系统。三、单键检查点先等flush再更新可见水位Preferences接口文档确认了getPreferences、get、put与flush。读取Preferences实例时它会被缓存如果复现的仅是组件重建重复获取对象可能仍命中同一个内存实例。这也正是演示场景必须标注为“页面实例重建”的原因不能用它证明应用重启一定从磁盘读到了全新数据。第二段代码将读写路径封装起来。为了保持示例接口与官方文档一致使用kit.ArkData导入preferences并传入Stage模型的UIAbilityContext。字符串键所存的JSON必须由业务自己做结构校验JSON.parse成功并不代表里面的数据版本和操作数组都安全。import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; class DraftCheckpointStore { private prefs?: preferences.Preferences; private readonly key: string proof_214_envelope; async open(ctx: common.UIAbilityContext): Promisevoid { this.prefs await preferences.getPreferences(ctx, proof_214_store); } async save(envelope: DraftEnvelope): Promisevoid { if (!this.prefs) { throw new Error(store not open); } const payload: string JSON.stringify(envelope); await this.prefs.put(this.key, payload); await this.prefs.flush(); } async load(): PromiseDraftEnvelope | undefined { if (!this.prefs) { throw new Error(store not open); } const raw await this.prefs.get(this.key, ); if (typeof raw ! string || raw.length 0) { return undefined; } return JSON.parse(raw) as DraftEnvelope; } }示例中save()只有等到flush()成功返回才允许业务把状态从FLUSH_IN_PROGRESS更新为JOURNAL_FLUSHED。如果写入失败应保留内存编辑态、展示可重试状态并避免再次将原操作添加到队列造成额外重复。网络断开和本地Preferences的失败原因也不应该混为一谈这里没有服务端通信。单键JSON简化了状态对应关系也产生了需要正视的成本。文稿很大时每次编辑都序列化整份信封会增大写放大和UI卡顿风险。实际产品可以选择以时间或操作数量节流并分批保存但每次节流都扩展了最近编辑尚未落盘的窗口。Preferences官方文档还给出值长度限制不能把它当成无限容量的画布数据库。操作达到阈值宜迁移到RelationalStore或文件日志再独立设计事务和资源回收。另一个细节是序列化数据可能带入无效结构。比如旧版本将pendingOps从数组改成对象直接as DraftEnvelope并不能提供运行时校验。读取端要逐项检查baseRevision是否为非负整数、baseCount是否处于允许范围、每个id是否唯一且符合格式、delta是否来自白名单。任何检查失败都应进入RECOVERY_REVIEW不要自动写回损坏对象让异常从一份数据扩散成永久状态。四、折叠屏的变化不应该冒充一条编辑事件页面宽从420vp切到780vp开发者通常会收到布局变化并重新计算笔迹坐标。这个过程和Preferences写入不是同一回事。画布应保留文稿逻辑坐标布局仅更新显示矩阵。否则用户没有画任何新笔onAreaChange就可能误触“修改草稿”逻辑将一次视图重排写成新业务修订。本例将layoutEpoch从7推进到8但baseRevision仍保持24待回放修订仍是25。也就是说同一个操作可以在不同布局下被重算显示坐标却只能按ID被合并到业务集合一次。代码设计中没有让窗口宽度成为批注主键更没有根据当前Grid列数反推出批注顺序。页面横竖变化频繁时这个隔离比简单保存State数组更重要。UI重建时可能先触发aboutToAppear随即异步执行open()与load()此时窗口已经完成一次或多次尺寸变化。若新旧恢复请求没有额外代次判断早发出的读取结果可能反过来覆盖已经计算好的新布局。本文在页面设置局部restoreTicket后到的旧票据不更新界面。这个票据的作用只是阻断异步UI回写并不代替持久化revision。第三段代码是页面层的最小接线重点展示“恢复”和“布局”两种代次。它刻意不提供真实绘画引擎也不将onAreaChange的尺寸值写入Preferences图中展示的是演示布局数据不是设备自动测量报告。Entry Component struct ProofDraftPage { State total: number 36; State stateText: string CHECKPOINT_24; State layoutEpoch: number 7; private restoreTicket: number 0; private store: DraftCheckpointStore new DraftCheckpointStore(); aboutToAppear(): void { void this.restoreDraft(); } async restoreDraft(): Promisevoid { const ticket this.restoreTicket; try { await this.store.open(getContext(this) as common.UIAbilityContext); const saved await this.store.load(); if (!saved || ticket ! this.restoreTicket) { return; } const result new ReplayReducer().replay(saved); this.total result.total; this.stateText RECOVERY_READY; } catch (e) { this.stateText RECOVERY_REVIEW; } } aboutToDisappear(): void { this.restoreTicket; } build() { Column({ space: 12 }) { Text(批注 ${this.total} 条) Text(this.stateText) }.width(100%) } }这段页面代码没有自动保存按钮事件的实现因此不能单凭显示RECOVERY_READY推断用户的每次编辑都已落盘。生产代码应该把save()和按钮的disabled状态绑到单写入队列并在连续点保存时只接受一个在飞提交。对失败保存要保留明确错误原因和重试次数避免反复开启getPreferences实例。aboutToDisappear只能阻断迟到的页面渲染更新并不保证系统一定有时间完成异步flush关键保存应在操作确认时完成。五、演示状态怎样从36变成39为了让文图数据可以逐项比对约定这组模拟操作发生在10:24。界面首先显示基线36条、三个已完成flush()调用模型的独立操作业务恢复器收到四次事件投递。确认的三个操作ID依次是op_037、op_038、op_039重复的是op_038。恢复后的修订为25、总数39、丢弃重复1宽屏视口为780×600vp布局代次8状态RECOVERY_READY。这里说的“恢复后39条”是预置测试向量在纯状态模型中的预期值不是真实手机产生的笔迹统计。合格的演示不应该假装生成了不存在的系统崩溃报告。要证明真实持久性需要在目标版本设备上进行进程被杀、低存储空间、权限边界、后台切换和重启测试并对照持久文件中的实际修订那是后续工程验收不是这个文章里的既有结论。六、把诊断页做成责任清单而不是“成功”的大绿勾RecoveryAuditPage最好直接展示“什么写入过、什么回放过、什么没有验证”。基线与日志区块分别记录baseRevision24/baseCount36和三个唯一操作。重复的op_038不应该再出现在最终列表但应出现在诊断事件表里否则日后看到应用数量正确却无法解释为什么某次回调被忽略。诊断页还需要记录视图代次从7到8说明屏幕变宽不是业务提交。对工程复盘来说“重复忽略1次”比“100%恢复”更有信息量前者能够反推出投递协议存在至少一次重复帮助排查监听器重注册、事件重放和页面实例重复初始化后者既不可定位问题也容易让审核的人误以为真机故障已覆盖。HiLog的演示文案固定为PRF-1010-13 journalFlushOK base36 ops3、PRF-1010-13 restored39 duplicateSkipped1 rev25、PRF-1010-13 layoutEpoch8 viewport780x600 RECOVERY_READY。它们是示例预期日志不是从设备收集的原始记录。真机落地时应增加耗时、持久化错误码、文件版本、校验失败原因、操作ID短哈希与脱敏处理不能把全部笔迹坐标、用户笔记正文输出到日志。七、还要用五组故障输入质疑这个设计第一组是重复事件在待恢复序列中插入第二条op_038总操作投递从3变4。期望恢复仍是39duplicateSkipped增加到1revision不被重复推进。这里既要检查最终数量也要检查业务副作用只执行一次例如缩略图更新、历史索引刷新或同步队列入列次数。只有数量正确却写出两份持久化条目不能算幂等通过。第二组是乱序事件将op_039移到op_037前面。本文三个delta1的简单加法仍会得39但实际画笔修改可能依赖前序操作。正式模型必须为有关联的操作增加依赖水位或显式因果序号无法证明顺序安全时不能通过Set去重后任意排列。可交换的计数模型和不可交换的图层操作应分开实现。第三组是检查点落盘失败强制让put()成功但flush()失败。期望界面停在FLUSH_FAILED而不是显示JOURNAL_FLUSHED恢复流程只能读取上次确认的存储内容。这里尤其要避免“catch之后继续增加业务revision”的实现因为应用会在最需要谨慎的时刻把UI乐观态当成提交事实。第四组是窗口连续改变420vp→600vp→780vp每次都重算布局矩阵并允许旧的restoreDraft()结果在最后一跳后才到达。期望旧restoreTicket无权覆盖新UI持久化内容始终不因布局变化而自动新增操作布局代次推进但业务revision应保持25。这组测试和批注数量无关关注的是状态所有权有没有越界。第五组是非法负载把pendingOps换成字符串把baseCount改成NaN或负数把opId改成空字符串。理想结果不是自动清空文件而是阻断回放、显示RECOVERY_REVIEW并保留失败前原始证据供开发者分析。恢复器不可拿到异常类型就假设数组迭代安全也不可在所有失败场景下统一执行clear()。如果要做一次更贴近真实操作的回归建议分别清空缓存、重启应用、触发窗口旋转再检查本地文件内容。必须注意Preferences在同进程内可能复用缓存因此“新页面能读到”并不天然意味着“杀进程后也能读到”。多进程不能共享这个方案的安全假设如业务确实要求跨进程读写应换成支持该用途的存储架构。八、数据量扩大后的实际取舍少量草稿状态使用单键信封很方便因为读取端不用处理多个键之间的时间差。但是每次序列化一整个草稿对象一旦批注点阵从几百条增长到几十万条时间和内存成本都会被放大。工程上通常应把大体量笔迹放在专门的数据文件或者关系数据库中Preferences只保留最近文稿ID、展示偏好和最新成功检查点的简短索引。另一种常见诱惑是把flush()放到aboutToDisappear()里做一次集中保存认为页面要离开时总会走这个回调。这个保证太强了。系统中止、崩溃、突然掉电或极端内存压力下业务不该依赖一个特定的退出事件一定执行。至少需要在用户明确提交关键编辑时保存在高频输入时选择合适的节流窗口并让用户知道哪些修改还处于待保存状态。同时恢复后的39条是否应该直接呈现还要由业务定义决定。这里假设所有操作合法、目标存在、恢复器可重放才得到RECOVERY_READY。如果某条操作关联的底图已经被用户删除即使计数模型可以算出39也不能简单展示成功。要把素材版本、关联文件可读性和图层引用校验纳入回放入口这样才能避免“JSON完整但画面缺素材”的另一种假成功。对于性能问题不建议把“写入耗时”与“窗口展开耗时”合并成一个数字。单键持久化、回放去重、布局重投影、渲染完成四个阶段都应独立记录。这样才能分辨是磁盘写慢、数据计算慢、主线程解码慢还是折叠形态转换引起的重排。指标命名不要偷换文章里的39条和一次重复过滤属于模型预期不是性能基准数据。九、验收应把哪些结论写清楚能够从本案例直接确认的是纯应用状态模型所遵守的几个约束同ID重复操作被忽略单键信封把基线和待恢复操作装进同一逻辑快照异步恢复更新用局部票据防止旧结果覆盖新视口的layoutEpoch不推动草稿revision。基于给定样本结果为36339重复忽略1次修订24→25。不能从这里推导的包括Preferences在所有掉电时刻的原子性、跨进程写入冲突的正确处理、真机折叠设备上所有窗口回调时序、每种图片笔迹文件的成功恢复率。这些必须在目标设备和真实文件规模下测试。尤其是“存储成功”应由实际flush()返回与读回验证支撑而不是由绿色UI图标证明。最后给这个Demo留一个明确的下一步把ReplayReducer替换为带操作内容校验的真实批注模型并给每次持久化记录版本和失败原因当草稿体量或并发需求越过单进程轻量数据边界时尽快升级存储而不是继续往Preferences里塞更大的JSON。屏幕展开只是变化的表象真正需要守住的是草稿状态究竟由谁负责、何时可以称为已保存。参考华为HarmonyOS官方《ohos.data.preferences用户首选项》API参考2026-09-09https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferences重点核对getPreferences、get/put/flush、缓存机制、ValueType、限制和多进程风险。文中所有数字与UI均为教学模型和示意图未执行真机崩溃实验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进