鸿蒙编辑器框架的传输引擎:与平台剪贴板解耦的多格式复制粘贴
一、决策:引擎先行,剪贴板是壳
复制粘贴看似是"读写一下系统剪贴板"的小事,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 必须是数组),不满足直接返回 undefined;metadata 则相反地宽容,缺啥补默认值。结构字段严格,元数据宽容——前者错了数据不可信,后者错了无伤大雅,校验的严格程度与字段的重要性对齐。
**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 为主格式)——前两个的问题在于把平台耦合和格式语义重新搅在一起,第三个则放弃了无损通道。把"多写的两层"列进负面清单是诚实的:这个架构不是免费的,它买的是可测试性、无损往返和格式可扩展性,账算得过来。
八、小结
- 引擎先行,剪贴板是壳:格式、身份、优先级全部在平台无关层解决,适配器只剩搬运与权限。
- 中立格式服务多消费者:TransferObject 是剪贴板、拖拽、AI、导出共用的信封;版本号与"未知字段安全忽略"是格式演进的保险。
- 写读优先级故意不对称:写为外部消费者优化(纯文本主记录),读为自己优化(无损 JSON 优先)。
- 有损格式要可预测的损失:编码保留缩进、解码明确不还原层级——宁降级,不猜结构。
- ID 重生成由引擎强制执行:递归新 UUID 是粘贴安全与 S5 不变量的会合点,适配器无权绕过。
- 妥协留在离平台最近处:JSON 借道 HTML 记录位、权限门控交还 UI 层、读取失败静默降级——脏活全部封在唯一的 pasteboard 进口文件里。
下一篇我们看这套框架的另一条生命线:用户写了一小时的东西,凭什么相信它还在?自动保存、崩溃安全写、以及 el2 加密沙箱里那些官方文档不会主动告诉你的文件系统行为。
下一篇:《鸿蒙编辑器框架的文档运行时:持久化、自动保存与 el2 沙箱》——dirty 标志 + 空闲定时器 + 最小间隔节流的保存策略、tmp-then-rename 崩溃安全写、
fs.access虚假成功与fs.mkdir递归陷阱的真机记录。
更多推荐





所有评论(0)