一、决策:引擎先行,剪贴板是壳

复制粘贴看似是"读写一下系统剪贴板"的小事,ADR-0004 开篇就把它的真实复杂度列了出来:编码、解码、平台 API、多格式、粘贴时的 ID 重生成——五个子问题,而 HarmonyOS 的剪贴板还自带权限模型。如果把 @ohos.pasteboard 的调用直接写进业务逻辑,立刻收获两个后果:没有真机就无法测试;换平台就要大动干戈。

ADR 的决策一句话:**复制、粘贴、导入、导出共用一个平台无关的传输引擎;鸿蒙剪贴板只是它的一个适配器。**分层后各组件的职责:

TransferEngine(框架所有,零平台依赖)
  ├── TransferObject        中立交换格式
  ├── ArkBlocksJsonFormat   无损格式(application/arkblocks+json)
  ├── PlainTextFormat       有损格式(text/plain)
  └── 导入时强制重生成 ID

HarmonyClipboardAdapter(平台所有,薄壳)
  └── 全框架唯一 import @ohos.pasteboard 的文件

这个决策的回报立刻可以列举:传输引擎的全部逻辑(格式编解码、ID 重生成、优先级选择)在没有设备的环境下完整可测;将来加 HTML / Markdown 格式只是新增一个策略实现,引擎与适配器都不用动;真要做跨平台,换一个适配器完事。

二、TransferObject:为四个消费者设计的中立格式

传输的核心数据结构刻意起名叫 TransferObject 而不是 ClipboardData,因为它的注释里写着四个消费者:

the neutral interchange format for moving document fragments between contexts (clipboard, drag, AI, export)

剪贴板只是第一位用户。拖拽数据、AI 的"选中改写"上下文、文档导出,都是"把一块文档搬家"的同构问题——统一成一个带版本的信封:

export interface TransferObject {
  version: number;            // 前后向兼容的锚点
  type: 'blocks' | 'text';    // 无损块树 or 有损文本
  blocks: Block[];
  metadata: TransferMetadata; // timestamp / source / blockCount
}

三条设计约束直接写在注释里,值得逐条品味:必须 JSON 可序列化(决定它能去任何地方);必须带版本(格式演进的退路);解码时未知字段必须安全忽略(旧版本读新数据的自保)。第三条在实现里几乎是免费的——JSON.parse 映射到类型化接口时天然丢弃未声明字段——但把它写成显式约束,是告诉后来者"这是契约,不是巧合"。

信封之外还有一个重要的"搬运工契约":ClipboardEntry { mimeType, data } 的注释里写明适配层只操作条目数组、永不解释 data 字段。格式语义归引擎,字节搬运归适配器——这条线划清之后,适配器才可能保持"薄"。

三、多格式策略:写与读的优先级故意不对称

格式是标准的策略接口:每个实现负责一个 MIME 类型,decode 遇到坏数据返回 undefined 而不抛错——格式层对脏数据的礼貌,让上层可以用统一的方式做降级尝试。

引擎的编组逻辑各只有几行,但方向感截然不同:

// 写方向:一次复制,两种格式都给
encodeForClipboard(obj: TransferObject): ClipboardEntry[] {
  return [
    { mimeType: this.jsonFormat.mimeType,      data: this.jsonFormat.encode(obj) },
    { mimeType: this.plainTextFormat.mimeType, data: this.plainTextFormat.encode(obj) },
  ];
}

// 读方向:无损优先,逐级降级
decodeFromClipboard(entries: ClipboardEntry[]): TransferObject | undefined {
  const jsonEntry = entries.find(e => e.mimeType === this.jsonFormat.mimeType);
  if (jsonEntry) {
    const obj = this.jsonFormat.decode(jsonEntry.data);
    if (obj) return obj;                    // 无损格式优先
  }
  const textEntry = entries.find(e => e.mimeType === this.plainTextFormat.mimeType);
  if (textEntry) return this.plainTextFormat.decode(textEntry.data);
  return undefined;                         // 都不是我的菜
}

读方向的逻辑好懂:同为 ArkBlocks 编辑器之间粘贴,JSON 完整还原块树;从外部应用粘贴,只有纯文本可读,降级为段落。有意思的是写方向在适配器里的落盘顺序——HarmonyClipboardAdapter 把纯文本写为主记录、JSON 作为附加记录。为什么写的时候纯文本优先?因为写方向要照顾的是外部消费者:别的应用读剪贴板时通常只认主记录,给它们的应是干净的文本;而 JSON 藏在附加记录里等自己的应用回来取。写为别人优化,读为自己优化——同一个多格式机制,两个方向的目标函数不同。

四、无损与有损:两种格式各自的诚实

**ArkBlocksJsonFormat(无损)**保留块层级、全部 props、行内内容与样式、乃至 ID 本身——注释里特意写明"IDs preserved, caller is responsible for regeneration on paste":格式层负责保真,身份策略归引擎,各管一段。它的 decode 做两道硬校验(version 必须是数字、blocks 必须是数组),不满足直接返回 undefinedmetadata 则相反地宽容,缺啥补默认值。结构字段严格,元数据宽容——前者错了数据不可信,后者错了无伤大雅,校验的严格程度与字段的重要性对齐。

**PlainTextFormat(有损)**的两个方向都值得看:

// 编码:树遍历,子块每层缩进两空格,分隔线渲染为 ---
private encodeBlocks(blocks: Block[], depth: number, lines: string[]): void {
  const indent = INDENT_UNIT.repeat(depth);            // '  '.repeat(depth)
  for (const block of blocks) {
    const text = this.extractText(block);              // 剥掉所有样式
    if (text.length > 0 || block.type === 'divider') {
      lines.push(indent + (block.type === 'divider' ? '---' : text));
    }
    if (block.children.length > 0) {
      this.encodeBlocks(block.children, depth + 1, lines);
    }
  }
}

// 解码:一行一段,缩进修剪掉,全部平铺为 paragraph

编码时它尽力保留了层级信息(缩进),但解码时明确不还原——缩进被 trimStart() 修剪,所有行平铺成段落,注释原话:"Indentation is not preserved as hierarchy on decode"。这是个反直觉却正确的取舍:空格缩进的层级语义太弱(列表标记、手写缩进、对齐空格都会污染它),按缩进猜层级,猜错一次就是一次静默的数据扭曲。宁可信息降级,不猜结构——有损格式要的是可预测的损失,不是碰运气的还原。

五、身份铁律:重生成发生在引擎,不在适配器

第 02 篇立过规矩:剪贴板里的 ID 只属于剪贴板,入文档必须换新。这条规矩的执法点在这里:

// 导入 = 递归重生成全部 ID(含整棵子树)
private regenerateIds(blocks: Block[]): Block[] {
  return blocks.map(block => ({
    id: IdGenerator.generate(),                        // ★ 无条件新 UUID
    type: block.type,
    props: this.deepCloneProps(block.props),
    content: block.content ? this.deepCloneContent(block.content) : undefined,
    children: this.regenerateIds(block.children),      // 子树递归
  }));
}

ADR-0004 把它列为五原则之四,并特意强调执法位置:"This is enforced by the Transfer Engine, not the adapter"——身份策略是框架语义,平台适配器无权也无需关心。这条铁律是复制粘贴安全性的全部根基:两个编辑器之间来回粘贴一百次,文档里不会有任何 ID 冲突(否则第 02 篇的 S5 不变量会让事务当场拒绝,粘贴直接失败)。顺带一提,块的克隆(Duplicate)功能复用的也是这条"深拷贝 + 新 ID"路径——同一条铁律服务两个功能。

六、适配层的真面目:权限、妥协与静默降级

HarmonyClipboardAdapter 的头注释自带三条平台知识,每条都值得抄:

其一,全框架唯一的 pasteboard 进口。"This is the ONLY file in the framework that imports @ohos.pasteboard."——第 01 篇说的"平台 API 只住在 Adapter 层",这里是最严格的执行样本。

其二,权限不对称。写入剪贴板不需要任何权限;读取则要求 ohos.permission.READ_PASTEBOARD 或者用户通过 PasteButton 安全组件的显式交互——且注释明确"由调用方(UI 层)负责门控读取时机"。适配器把这个现实原样写进文档,而不是悄悄替上层做决定。

其三,一次有趣的平台妥协。写方向的实现里,JSON 载荷没有被塞进某个自定义 MIME——它借道了 MIMETYPE_TEXT_HTML 的记录位

pasteData.addRecord(pasteboard.MIMETYPE_TEXT_HTML, jsonEntry.data);  // JSON 住进 HTML 位
pasteData.setProperty({ ..., shareOption: pasteboard.ShareOption.LOCALDEVICE, tag: 'ArkBlocks' });

读取时对称地把 TEXT_HTML 记录映射回 MIME_ARKBLOCKS_JSON 条目。这是一个典型的平台适配让步:当平台的多记录/自定义 MIME 支持不能满足需求时,借一个语义宽松的槽位携带自己的载荷,进出的映射收敛在适配器内,引擎与格式层毫不知情。shareOption: LOCALDEVICE(限本机)加 tag: 'ArkBlocks' 则是配套的自我标识。这类妥协的正确姿势恰恰是它现在的样子——离平台越近的代码越脏,离平台越远的代码越干净。

最后是失败策略:读取全程 try/catch,权限被拒或剪贴板为空时返回空数组——剪贴板读不到东西不是错误,是常态的一部分。

七、ADR 的负面清单:代价也写下来

ADR-0004 难得地同时写明了决策的代价:简单的一次"复制所选"也要穿过 引擎 → 格式编码 → 适配器 两层,比直接写剪贴板代码多;格式增加后,解码优先级的判定逻辑会更复杂。以及三个被否决的替代方案(直接访问剪贴板、以剪贴板为主引擎为壳、以 HTML 为主格式)——前两个的问题在于把平台耦合和格式语义重新搅在一起,第三个则放弃了无损通道。把"多写的两层"列进负面清单是诚实的:这个架构不是免费的,它买的是可测试性、无损往返和格式可扩展性,账算得过来。

八、小结

  1. 引擎先行,剪贴板是壳:格式、身份、优先级全部在平台无关层解决,适配器只剩搬运与权限。
  2. 中立格式服务多消费者:TransferObject 是剪贴板、拖拽、AI、导出共用的信封;版本号与"未知字段安全忽略"是格式演进的保险。
  3. 写读优先级故意不对称:写为外部消费者优化(纯文本主记录),读为自己优化(无损 JSON 优先)。
  4. 有损格式要可预测的损失:编码保留缩进、解码明确不还原层级——宁降级,不猜结构。
  5. ID 重生成由引擎强制执行:递归新 UUID 是粘贴安全与 S5 不变量的会合点,适配器无权绕过。
  6. 妥协留在离平台最近处:JSON 借道 HTML 记录位、权限门控交还 UI 层、读取失败静默降级——脏活全部封在唯一的 pasteboard 进口文件里。

下一篇我们看这套框架的另一条生命线:用户写了一小时的东西,凭什么相信它还在?自动保存、崩溃安全写、以及 el2 加密沙箱里那些官方文档不会主动告诉你的文件系统行为。

下一篇:《鸿蒙编辑器框架的文档运行时:持久化、自动保存与 el2 沙箱》——dirty 标志 + 空闲定时器 + 最小间隔节流的保存策略、tmp-then-rename 崩溃安全写、fs.access 虚假成功与 fs.mkdir 递归陷阱的真机记录。

Logo

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

更多推荐