外面是原生应用,里面是 Web 编辑器
文档 SDK 参与宿主的导航、账号、权限、离线存储、分析和系统界面,但编辑器本身是随客户端打包、运行在 WKWebView 里的 Web 应用。富文本、选区、输入法、历史和实时协作都由同一编辑模型承担,这种选择可以在多个客户端间复用复杂逻辑。
困难不在于嵌入一个 WebView,而在于两个运行时都拥有重要状态,如何划清它们的边界。
先区分它与 React Native
当时的 React Native 架构由 JavaScript 描述原生视图树,通过异步、可序列化、可批量发送的桥接调用修改 UIView。这里则由 WebKit 管理 DOM、布局、文字输入与渲染,UIKit 看到的是一个 WebView。
前者跨桥修改原生视图,后者在 WebKit 内渲染,只把领域命令交给原生服务。
两者仍有共通成本:跨运行时调用需要调度,序列化载荷通常要复制,异步不能伪装成同步,大量小消息会积压,顺序、取消和生命周期必须明确。
Bridge 是协议,不是回调的集合
早期很容易从几个随意的方法开始:
func openLink(_ url: String)
func editorDidChange(_ json: String)
func uploadImage(_ payload: [String: Any])
后来方法名变成隐形 API,载荷结构逐渐分叉,文档关闭后旧回调仍然返回,每个功能各自定义错误处理。我们改为版本化消息 envelope:
type BridgeEnvelope = {
protocolVersion: number
sessionID: string
sequence: number
id?: string
kind: 'request' | 'response' | 'event'
method: string
payload?: unknown
error?: {
code: string
message: string
retryable: boolean
}
}
协议版本用于兼容;session ID 拒绝旧文档或旧账号消息;递增 sequence 便于诊断顺序;需要响应时使用 request ID;method 与 payload 经过 schema 验证;错误采用结构化 code、message 和 retryable。
先交换宿主上下文、协商协议能力,再恢复文档状态,最后进入 ready。
HTML 加载完不等于编辑器已就绪,JavaScript 也不能假设所有宿主能力都存在。业务命令必须等待握手建立真实环境。
桥接停留在领域层
| 原生发往 JavaScript | JavaScript 发往原生 |
|---|---|
| document.open / restore | editor.ready / document.changed |
| appearance.update | selection.changed |
| connectivity.update | collaboration.stateChanged |
| permission.update | permission.requested |
| lifecycle.suspend / resume | attachment.uploadRequested / navigation.openRequested |
键盘输入、光标绘制、DOM 修改和转换后的协作操作留在编辑器内部。原生接收持久检查点、用户可见状态和宿主能力请求。
如果每次按键都跨桥,桥接就进入编辑热路径;如果 Swift 再解释编辑操作,两种语言就会维护同一套脆弱规则。Bridge 负责控制与协调,编辑器和协同引擎负责文档数据处理。
让异步无法被忽略
WKScriptMessageHandler 与 evaluateJavaScript 提供传输入口,却不是完整 RPC。封装需要补齐:一次请求一个响应、超时与取消、重复响应拒绝、会话失效、schema 检查、队列归属,以及跨运行时追踪。
struct BridgeRequest<Payload: Encodable>: Encodable {
let protocolVersion: Int
let sessionID: String
let sequence: UInt64
let id: String
let method: String
let payload: Payload
}
final class EditorBridge {
private weak var webView: WKWebView?
private let sessionID: String
private let encodingQueue: DispatchQueue
private var nextSequence: UInt64 = 0
private var pending: [String: PendingCall] = [:]
func send<Payload: Encodable>(
method: String,
payload: Payload,
completion: @escaping (Result<BridgeValue, BridgeError>) -> Void
) {
// Encode off the main thread, register the timeout, then invoke
// JavaScript on the main thread with a validated JSON envelope.
}
}
大快照编码与校验放在主线程之外,最终 WebKit 调用和 UIKit 操作回到主队列。跨边界 API 始终异步,即使测试替身能立即返回,也不改变这个约定。
顺序与背压是产品语义
| 消息类型 | 示例 | 交付策略 |
|---|---|---|
| 持久消息 | 提交、检查点、权限变化 | 有序、确认,不能静默丢弃 |
| 会话控制 | 打开、关闭、挂起、恢复 | 有序且检查 session ID |
| 交互状态 | 选区、工具栏、视口 | 保留最新值 |
| 短暂状态 | 协作者光标、输入中状态 | 尽力交付,允许过期 |
| 诊断信息 | 时序标记、计数 | 批量与采样 |
接收者跟不上时,发送者不能无限产生持久消息。应等待确认、合并兼容操作,或持久化等待恢复。这样快速输入或多人协作不会让队列充满已经过期的 UI 状态。
实时协作首先要保证本地响应
输入一个字符不能等待服务器确认。本地先应用,再异步同步;并发操作因此可能基于同一个旧版本:
Revision 42: "AC"
User A inserts "B" at index 1 → "ABC"
User B deletes "C" at index 1 → "A"
直接按原始索引执行,可能修改错误内容。我们围绕集中式 Operational Transformation 的模型设计:带版本的操作、本地立即应用、服务端确认,以及并发操作转换。这属于 Ellis 与 Gibbs、Jupiter 等早期协同系统所讨论的算法家族,但真实富文本引擎是基于产品文档模型的工程实现,并非纯字符串教科书样例。
Jupiter 风格的客户端状态机
最多一个本地操作等待确认,后续编辑合并进缓冲区,确认后继续发送。
存在待确认本地操作时,远端操作到来,需要双向转换:
(local′, remote′) = transform(local, remote)
apply(remote′) to the local document
keep local′ as the outstanding operation
将转换后的远端操作应用到本地,同时保留转换后的本地待确认操作。若还有 buffer,远端操作还要经过 buffer,两个本地操作也沿相反方向更新。
服务端将基于旧 revision 的操作依次转换到当前上下文,接受后推进版本、确认发送者并广播。这样本地输入仍然即时,服务端提供权威顺序,客户端也能明确哪些操作已确认、哪些仍待恢复。
富文本把转换变成领域问题
文档不只是字符串,还有段落、列表、链接、样式、表格、嵌入对象、批注、块拆分与合并,以及只读变化。操作需要稳定块身份和明确语义:
type DocumentOperation =
| { type: 'insertText'; blockID: string; offset: number; text: string }
| { type: 'deleteRange'; blockID: string; start: number; end: number }
| { type: 'splitBlock'; blockID: string; offset: number; newBlockID: string }
| { type: 'mergeBlocks'; leadingID: string; trailingID: string }
| { type: 'setAttributes'; targetID: string; patch: AttributePatch }
| { type: 'insertEmbed'; parentID: string; index: number; embed: Embed }
“拆分块时另一个人插入文字”“删除块时另一个人改属性”“移动内容时批注范围变化”都需要定义转换规则。编辑器与协同引擎共享同一套文档模型,将其复制进原生 SDK 会让最脆弱的部分维护两次。
输入法也必须区分组合文本与已提交操作。中文、日文候选输入的中间态不应逐次当作持久协作操作发送;应等待 composition 提交。协作撤销也不是恢复上一份全局快照,而是转换后撤销当前用户的意图。光标与在线状态可以关联 revision,但属于短暂信息,不进入持久历史。
原生负责环境,不接管协同算法
原生提供凭据刷新、网络与生命周期、快照和待提交操作持久化、进程崩溃恢复、账号与会话清理,以及诊断信息;操作转换由编辑器中的协同引擎负责。
宿主、SDK、Bridge、编辑器、协同引擎与服务端分别拥有一层职责。
离线恢复需要两份历史
一份是客户端知道的最新服务端版本,一份是此后产生的本地操作。只保存 HTML 能恢复外观,却无法正确合并;只保存操作则可能需要无限重放。
type EditorCheckpoint = {
documentID: string
accountID: string
baseRevision: number
snapshot: Uint8Array
pendingOperations: DocumentOperation[]
editorSchemaVersion: number
createdAt: number
}
检查点包含快照、base revision、待提交队列、账号、文档和 schema 版本。重连后获取远端操作或新快照,转换或 rebase 本地队列,再继续发送。超出支持窗口时,回到权威快照并重放可恢复的本地意图。
内容正确但账号错误的检查点,仍然是错误状态。会话身份必须贯穿恢复链路。
性能要跨两个运行时测量
原生看到 WebView 迟迟未就绪,JavaScript 看到初始化数据来得太晚,单看一侧都无法解释完整过程。我们用同一个 trace ID 串起启动:
从 WebView 获取、资源加载到握手、检查点恢复、首屏与协同连接,共享一条时间线。
由此可以判断是否应本地打包资源、有限预热 WebView、只渲染首屏块、避免全量快照挤占交互通道、改发增量或检查点,以及批量采样诊断。
预热也有成本,会消耗宿主共同的内存与 CPU 预算。需要明确何时准备、保留、降级和丢弃,而不是无条件预加载。
安全也是协议的一部分
Bridge 不能提供“按字符串任意调用原生方法”的入口。只允许受信任的内置编辑器来源,拦截外部导航,方法显式注册,载荷类型化解码,账号与文档匹配当前 session,敏感操作再次执行原生权限检查,附件路径不直接信任 JavaScript。
WKUserContentController 会持有 message handler。使用弱转发器与显式 teardown,避免直接注册 view controller 形成引用环。编辑器可以请求,原生仍负责判断能否执行。
最终形成三份明确权威:原生拥有宿主环境与持久生命周期,Web 编辑器拥有渲染、输入和文档模型,协同引擎拥有版本操作与收敛。Bridge 连接它们,而不把它们混成一个系统。