为什么鸿蒙需要一个原生 Block 编辑器框架
一、从一个具体的问题开始
假设你要在鸿蒙上做一个笔记应用。产品经理给你画了这样一页原型:
- 标题、段落、待办清单(能勾选);
- 列表能缩进成多级;
- 图片能半宽两张并排;
- 选中文字能加粗、加链接;
- 任何操作能撤销;
- 杀后台不丢内容;
- 下一期要接 AI:选中一段话,让 AI 改写。
这个需求清单在 2026 年的移动开发里再普通不过。但在 HarmonyOS NEXT 上,你会发现没有任何一个现成组件能承接它。系统给你的 RichEditor 是字符级编辑控件——它能处理"一段带样式的文本",但它不知道什么是"块"、什么是"文档树"、什么是"第 3 级列表项"。你需要的不是一个更好的文本控件,而是一整个缺失的层:文档模型层。
这就是 ArkBlocks 项目存在的原因。它不是一个笔记应用,而是补上这一层的框架:文档模型、事务引擎、撤销重做、序列化、插件系统,全部用 ArkTS 从零实现,渲染走原生 ArkUI。笔记、知识库、AI 写作、Markdown 编辑器、文档工具,都构建在它之上。
在展开架构之前,先老老实实盘点:为什么现有三条路都走不通。
二、三条现成的路,各自死在哪里
路一:WebView + Web 编辑器(Tiptap / Quill 塞进 WebView)。
这是最快出原型的方式,也是技术上最贵的方式。问题不是"能用不能用",而是你从此要把每一个编辑器核心体验都架在一层桥接之上:
- 输入法:中文 composing(拼音串)、下划线预编辑、候选词期间的光标语义,全部发生在 Web 层,宿主应用拿到的事件是二手的。编辑器最核心的 IME 交互被隔离在你控制不了的一层。
- 双端状态同步:Web 层有一份文档,ArkTS 层想持久化、想接 AI、想做协同,就得不断跨越 JS Bridge 搬运数据。每一次搬运都是一次序列化 + 一次跨运行时调用。
- 手势与平台一致性:长按选择、拖拽、滚动回弹、字体缩放,WebView 的行为和原生应用总有微妙差异——用户未必说得出哪里不对,但感受得到。
- 无障碍:屏幕阅读器读的是 ArkUI 组件树,WebView 内部的内容对无障碍体系是半透明。
路二:直接用系统 RichEditor。
它确实是原生的,也确实能打字、加粗、撤键。但把需求清单放上去逐条对,会发现它提供的是"编辑控件"而非"文档模型":
| 需求 | RichEditor 给了什么 | 缺什么 |
|---|---|---|
| 标题/段落/列表 | 不同样式的 span | 没有"块类型"概念,样式即内容,语义无从谈起 |
| 多级嵌套列表 | 无 | 缩进是排版概念,不是树结构 |
| 块级操作(拆分/合并/移动/嵌套) | 无 | Enter 拆块、Backspace 合块、拖拽排序全要自己造 |
| 撤销/重做 | 控件内部黑盒 | 与你自己的数据层对不上,无法和数据变更共享同一套历史 |
| 持久化 | 无 | 拿不到结构化文档,只能导出带样式文本 |
| AI 接入 | 无 | AI 想要的结构化上下文("第 2 节的第 3 个待办")不存在 |
一句话总结:RichEditor 解决的是"字符怎么显示、怎么输入",而你的应用卡住的地方是"文档是什么、怎么变、怎么存"。这中间差的那一层,就是 ArkBlocks 要写的。
路三:每个团队从零自研。
缺什么补什么,看似自由,实则是在没有地图的情况下重走一遍别人走过的三十年:文档模型怎么定、事务边界画在哪、撤销存快照还是存逆操作、剪贴板放几种格式、Markdown 转换怎么处理有损……每一个都是独立的坑位,而且自研件永远排不上重构的优先级——它"能跑",直到有一天不能。
三条路的结论:需要的是一个原生、可复用、以块为中心的编辑器框架。接下来看这个东西长什么样。
三、Block 范式:把文档从"字符串"升级为"树"
传统富文本把文档当作"一长串带样式的字符";Block 编辑器把文档当作一棵由类型化块组成的树。ArkBlocks 里,一个文档在内存中就是这样:
[
{
"id": "b1", "type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "会议纪要", "styles": {} }],
"children": []
},
{
"id": "b2", "type": "bulletListItem",
"props": {},
"content": [{ "type": "text", "text": "确认排期", "styles": {} }],
"children": [
{
"id": "b3", "type": "checkListItem",
"props": { "checked": true },
"content": [{ "type": "text", "text": "联系供应商", "styles": {} }],
"children": []
}
]
}
]
注意这个模型里每一块都有:稳定 ID(UUID v4,创建即冻结)、类型名(受 Schema 校验)、类型化属性、行内内容(文本 + 样式 + 链接的扁平序列)、子块(无限嵌套)。这个看似简单的结构,一次付清了后面所有的账:
- 块级交互免费获得:拆分、合并、嵌套、拖拽、折叠,全是树操作;
- 语义免费获得:"把所有 checked=true 的待办筛出来"是一行查询,对 span 模型这是不可能任务;
- 协同与评论有锚点:外部引用(评论、书签、AI 标注)挂在块 ID 上,块移动、撤销后 ID 不变,引用就不断;
- AI 有操作单元:AI 的上下文是块树、AI 的输出是块树、AI 的流式写入是往树里提交事务——这是"选中改写""继续写作"这类功能的地基;
- 序列化无损:JSON 就是文档本身,不是导出格式。
四、ArkBlocks 是什么、不是什么
不是:笔记应用、UI 组件库、WebView 壳。 是:一个分层引擎 + 一个唯一入口。整张架构图:
┌──────────┐
│ Editor │ ← 唯一外观层 / 公共入口
└────┬─────┘
┌──────────┬─────────┼──────────┬───────────┐
▼ ▼ ▼ ▼ ▼
Document Schema Transaction Selection History
(Block 树) (规格) (唯一变更边界)
│ Plugin Manager
▼ │
Command / Renderer ◄── diff ── Transfer ◄── Adapter ◄── Clipboard
Dispatcher Registry (平台层)
两条规则撑起整个架构:
规则一:Document 是唯一事实来源。 选区、历史、渲染器、剪贴板持有的都只是派生数据——选区只存 (blockId, offset) 坐标,历史只存操作逆序,剪贴板只存复制时刻的快照。文档内容在内存里只存在一个地方,"打了一个字要同步几处状态"这个经典难题被从结构上消灭。
规则二:变更只走一条路。 任何修改——包括撤销、包括插件、包括 AI 写入——都必须经过事务:
输入 → Adapter → Command → Transaction → Document → History → Selection → Renderer
配套的是一张依赖矩阵:核心引擎层(Document / Transaction / History)禁止 import 任何平台 API 与上层模块;所有 @ohos.* 调用只允许出现在 Adapter 层。收益立刻兑现:核心引擎在无真机环境下可完整单测,平台能力替换(比如未来折叠屏形态的输入适配)不动核心。
Editor 是全框架唯一允许跨层 import 的类,聚合 12 个子系统,也是应用唯一需要认识的类型。
五、和 Web 编辑器掰手腕:四个架构级的不同
ArkBlocks 研究过 ProseMirror、Lexical、TipTap、BlockNote,但每个决策独立重做。挑四个最有分量的差异:
1. 单层 Block 树 vs 双层表示。 ProseMirror 内部是一棵低层 node 树,应用层 API 是它的投影,两层之间靠双向转换(BlockNote 里真实存在 blockToNode / nodeToBlock 这样的转换函数)维持一致。转换层是这类架构永久的 bug 温床——两个表示稍有漂移,用户看到的内容和撤销恢复的内容就会不一致。ArkBlocks 没有历史包袱,Block 树本身就是文档模型,零转换。
2. 自建事务系统 vs 复用 PM Transform。 ArkBlocks 的操作集是有限枚举——20 种类型化操作(insert / remove / move / split / merge / indent / outdent / insertInline / deleteInline……),用 ArkTS 联合类型表达,编译器就能检查完备性。换来两个好处:核心零第三方依赖(纯 ArkTS 运行时,无 polyfill 无桥接);逆操作生成规则清晰,撤销重做不依赖任何外部库的内部行为。
3. Adapter 隔离 vs 平台 API 散落。 Web 编辑器的"平台"是浏览器,天然后端统一。鸿蒙的形势复杂得多:RichEditor、pasteboard、振动器、文件沙箱、窗口避让,全是独立子系统。ArkBlocks 把它们全部关进 7 个子适配器(Input / Clipboard / Keyboard / Gesture / Focus / Window / Accessibility),每个都实现核心声明的接口——测试用 Mock 替换,换设备形态换适配器。
4. 渲染走注册表,不走动态加载。 ArkUI 静态编译,不能像 Web 那样运行时动态加载组件。ArkBlocks 用 blockType → 渲染分类 的注册表分发(目前四类:editable / divider / card / image),插件注册类型,渲染层按分类路由到具体组件。官方插件和第三方插件走同一条注册通道——如果官方插件需要开后门才能实现,按项目规定视为 SDK 不完整,补 SDK 而不是开后门。
| 维度 | ProseMirror / BlockNote | ArkBlocks |
|---|---|---|
| 运行平台 | 浏览器 DOM | HarmonyOS NEXT 原生 |
| 文档模型 | 低层 node 树 + Block 投影(双层) | Block 树(单层,零转换) |
| 事务系统 | PM Transform | 自建,20 种类型化操作 |
| 平台依赖 | DOM/浏览器事件 | 全部隔离于 Adapter 层 |
| 插件隔离 | 直接访问编辑器状态 | PluginContext 受控边界 |
| 渲染 | DOM 动态创建 | 静态编译 + 注册表分发 |
六、30 秒看 API:用起来是什么样
import { Editor, EditorConfig } from '../editor';
import { DefaultBlocksPlugin, DefaultStylesPlugin, HistoryPlugin } from '../plugin';
// 1. 创建:注册块类型与插件,引擎自动装配 12 个子系统
const editor = Editor.create({
plugins: [DefaultBlocksPlugin, DefaultStylesPlugin, HistoryPlugin],
initialContent: [{ type: 'paragraph', props: {}, content: [], children: [] }],
} as EditorConfig);
editor.mount();
// 2. 高层变更:命令
editor.exec({
type: 'insertBlock',
blockType: 'paragraph',
position: { type: 'after', referenceId: 'block-1' },
});
// 3. 底层变更:事务(原子提交,一步撤销)
editor.transact((tx) => {
tx.addOperation({ type: 'updateProps', blockId: 'h1', props: { level: 2 } });
tx.addOperation({ type: 'insertBlock', block: newBlock,
position: { type: 'end', parentId: '__root__' } });
});
// 4. 撤销 / 重做
editor.undo();
editor.redo();
// 5. 持久化:JSON 即文档
const json = JSON.stringify(editor.toJSON());
注意第 3 步:一个事务里打包的多个操作要么全部生效、要么全部不生效,撤销时一次回退。这是后面所有高级能力(AI 一步撤销整段生成内容、拖拽跨层移动)的同一块地基。
再用一次回车键的完整生命周期,把整条流水线串起来——这是理解本系列后续所有文章的钥匙:
用户按下 Enter(RichEditor)
→ InputAdapter 捕获,翻译为规范意图
→ CommandDispatcher 生成 splitBlock 命令
→ TransactionManager 开启事务
├─ Phase 1 校验:结构 + Schema + 操作级检查(任一失败,整个事务作废)
├─ Phase 2 执行:前块保留原 ID,新块用预分配的 UUID 拿走后半段内容
└─ 应用操作的同时生成 RendererDiff(哪些块被插入/更新/移动)
→ 逆操作压入 History(这一步撤销的边界就此划定)
→ Selection 清理失效引用
→ 渲染层只重建 diff 中列出的块,其余组件原样复用
一次按键,九步,每步可测、可拦、可撤销。编辑器工程的全部难度,就在于让每一次这样的按键都严格走完这条路,没有捷径。
七、诚实的现状盘点:现在能做什么、不能做什么
写架构文最忌讳画大饼。以下是仓库当前的真实边界。
已落地(Playground 可真机演示):
- 10 种内置块:段落、标题(H1–H3,可作折叠容器)、无序/有序/待办列表、引用、Callout、分隔线、卡片、图片;
- 嵌套与折叠容器、层级引擎(嵌套/取消嵌套)、拖拽排序;
- 有序列表动态序号解析、按层级渲染的列表标记;
- 卡片与图片的半宽并排布局;
- 斜杠菜单、Block 操作面板(左滑)、行内选择工具栏(加粗/删除线/高亮/链接)、移动端插入面板;
- 传输引擎(剪贴板多格式、块树完整复制粘贴)、撤销/重做;
- 文档运行时:本地文件持久化、空闲+退后台自动保存、冷启动恢复;
- 反馈引擎:语义化操作的触感与音效(含失败降级);
- 插件 SDK MVP,Image / Card / Callout 已迁移为官方插件。
未落地(里程碑表中明确未开始,本系列会如实标注):
- 选区引擎(M3):跨块选择、SelectionResolver 尚为骨架;
- 正式渲染器(M5):渲染分发目前由 Playground 演示层承担;
- RichEditor 正式同步协议(M6):当前基于 Playground 集成层经验,同步协议被列为最高优先级开放风险;
- AI 集成(M7)、表格等高级块(M8 后续)。
项目处于 1.0 之前的 API 稳定化阶段,哪些 API 已冻结见仓库 docs/spec/API-STABILITY-v1.md。
八、规格驱动开发:一个框架如何防止腐烂
最后讲工程方法,因为这决定这个项目第五年还在不在。
ArkBlocks 执行严格的规格驱动开发(SDI),任何功能先过这条流水线:
研究 → 架构 → RFC → 规格 → 规格测试 → 实现(PR) → 独立评审 → 评审修复 → 合并
几个关键机制:
- 决策优先级链:冲突时 RFC > 规格 > 规格测试 > 现有实现。实现永远不是事实来源;实现中发现规格有矛盾,正确动作是停下实现、开新 RFC,而不是"顺手改一下"。
- 冻结契约:
docs/research|design|rfc|spec|tests全部冻结,常规开发不许动。架构变更只走正门(新 RFC)。 - 无回归测试,不合并:评审发现的每个 Bug 必须至少引入一个回归测试。
- 每个 PR 只做一个架构目标:20 余个实现 PR 几乎是一条直线,没有返工大潮。
- 平台知识单独沉淀:真机才会暴露的知识(el2 沙箱的文件系统怪癖、RichEditor 同步时序、振动器错误码语义)记录在 MEMORY.md,含每次踩坑的根因、失败尝试与最终方案——下一位工程师(无论人或 AI)动相关代码前必读。
到本文写作时的工程数据:6 份 ADR、3 份 RFC、1 份核心规格、280 个规格测试、20 余个实现 PR、约 689 个真机手工测试用例、0 个未关闭的 Critical 缺陷。
多角色协作上,项目让不同的 AI 会话分工扮演 Architect / Framework Engineer / Reviewer / Maintainer——写代码的会话不审自己的代码,评审发现的每个问题回到规格层面归因。AI 在这个流程里不是替代工程纪律,而是被纪律约束的角色。
九、本系列路线图
接下来 12 篇,按依赖顺序拆解这个框架,每篇独立成文:
- 引擎核心(02–04):Block 树与双 O(1) 索引的文档模型;20 种操作 + 两阶段提交的事务引擎;存逆操作不存快照的撤销栈。
- 扩展体系(05–07):Schema 注册与运行时校验;插件 SDK 的五阶段生命周期与受控边界;属性引擎——插件声明元数据,UI 自动生成。
- 渲染与平台(08–12):ForEach 局部刷新的真机教训;RichEditor 文本同步四规则;剪贴板多格式策略;自动保存与 el2 沙箱踩坑;触感反馈的探测-降级模式。
- 方法论(13):规格驱动 + 多角色 AI 协作的完整复盘。
如果你正在鸿蒙上做任何涉及"结构化内容"的产品——笔记、文档、工单、IM 富卡片、AI 写作——这个系列想让你下次立项时,手里多一张"原生框架"的牌。
下一篇:《文档模型:Block 树是唯一事实来源》——拆解 RFC-0001 的三层数据结构、UUID 身份规则,以及"树 + 双索引"如何在 O(1) 和 O(树深) 之间取得平衡。
更多推荐


所有评论(0)