一份批注草稿最难处理的,并不是屏幕展开后画布要变宽,而是页面已经销毁过一次、用户又回来时,我们究竟应该相信哪份数据。界面上的笔迹可能看起来完整,持久化文件却停在上一个检查点;反过来,某个操作已经写入存储,新的页面又把它当成未提交事件执行一遍。两种情况都不会在普通的静态布局预览里出现。

这里构造一个 ProofDraftDock 演示工程,任务编号 PRF-1010-13,文稿 proof_214。用36条已提交批注作为基线,再将3条带有稳定操作ID的修改保存为待回放操作。为了专门观察重复事件,把其中一条事件投递两遍,所以恢复器收到的是4次投递、3个唯一操作。场景只模拟窗口宽度从420vp变成780vp以及页面实例重建;不声称模拟了操作系统杀进程或获得了真机崩溃恢复保证。

一、把“页面还在”与“修改已经落盘”分开

设计这类草稿功能时,最容易犯的错误是把一个@State数组当成持久数据源:新增批注时直接push,页面切回来时再读一次Preferences。问题是这两个动作属于两种不同的生命周期。@State确保当前组件的显示可以随着值变化刷新,却不能告诉后续新建的组件哪些操作已经可靠地执行过持久化。

这次示例约定:baseRevision=24的基线有36条;编辑会话产生op_037、op_038、op_039,组成revision=25的未合并操作区。模拟器显示的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: Set<string> = new Set<string>(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]。这样baseCount=36,applied=3,skipped=1,最终total=39。我们故意把重复的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): Promise<void> {
    this.prefs = await preferences.getPreferences(ctx, 'proof_214_store');
  }

  async save(envelope: DraftEnvelope): Promise<void> {
    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(): Promise<DraftEnvelope | 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(): Promise<void> {
    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最好直接展示“什么写入过、什么回放过、什么没有验证”。基线与日志区块分别记录baseRevision=24/baseCount=36和三个唯一操作。重复的op_038不应该再出现在最终列表,但应出现在诊断事件表里,否则日后看到应用数量正确,却无法解释为什么某次回调被忽略。

诊断页还需要记录视图代次从7到8,说明屏幕变宽不是业务提交。对工程复盘来说,“重复忽略1次”比“100%恢复”更有信息量:前者能够反推出投递协议存在至少一次重复,帮助排查监听器重注册、事件重放和页面实例重复初始化;后者既不可定位问题,也容易让审核的人误以为真机故障已覆盖。

HiLog的演示文案固定为PRF-1010-13 journalFlush=OK base=36 ops=3、PRF-1010-13 restored=39 duplicateSkipped=1 rev=25、PRF-1010-13 layoutEpoch=8 viewport=780x600 RECOVERY_READY。它们是示例预期日志,不是从设备收集的原始记录。真机落地时应增加耗时、持久化错误码、文件版本、校验失败原因、操作ID短哈希与脱敏处理,不能把全部笔迹坐标、用户笔记正文输出到日志。

七、还要用五组故障输入质疑这个设计

第一组是重复事件:在待恢复序列中插入第二条op_038,总操作投递从3变4。期望恢复仍是39,duplicateSkipped增加到1,revision不被重复推进。这里既要检查最终数量,也要检查业务副作用只执行一次,例如缩略图更新、历史索引刷新或同步队列入列次数。只有数量正确却写出两份持久化条目,不能算幂等通过。

第二组是乱序事件:将op_039移到op_037前面。本文三个delta=1的简单加法仍会得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。基于给定样本,结果为36+3=39,重复忽略1次,修订24→25。

不能从这里推导的,包括Preferences在所有掉电时刻的原子性、跨进程写入冲突的正确处理、真机折叠设备上所有窗口回调时序、每种图片笔迹文件的成功恢复率。这些必须在目标设备和真实文件规模下测试。尤其是“存储成功”应由实际flush()返回与读回验证支撑,而不是由绿色UI图标证明。

最后给这个Demo留一个明确的下一步:把ReplayReducer替换为带操作内容校验的真实批注模型,并给每次持久化记录版本和失败原因;当草稿体量或并发需求越过单进程轻量数据边界时,尽快升级存储而不是继续往Preferences里塞更大的JSON。屏幕展开只是变化的表象,真正需要守住的是草稿状态究竟由谁负责、何时可以称为已保存。

参考:华为HarmonyOS官方《@ohos.data.preferences(用户首选项)》API参考(2026-09-09,https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferences),重点核对getPreferences、get/put/flush、缓存机制、ValueType、限制和多进程风险。文中所有数字与UI均为教学模型和示意图,未执行真机崩溃实验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐