全部文章

为协同 Web 编辑器设计原生桥接协议

在 iOS SDK、WebKit 编辑器与实时协同引擎之间,建立类型明确、异步可控、能够恢复的协议边界。

外面是原生应用,里面是 Web 编辑器

文档 SDK 参与宿主的导航、账号、权限、离线存储、分析和系统界面,但编辑器本身是随客户端打包、运行在 WKWebView 里的 Web 应用。富文本、选区、输入法、历史和实时协作都由同一编辑模型承担,这种选择可以在多个客户端间复用复杂逻辑。

困难不在于嵌入一个 WebView,而在于两个运行时都拥有重要状态,如何划清它们的边界。

先区分它与 React Native

当时的 React Native 架构由 JavaScript 描述原生视图树,通过异步、可序列化、可批量发送的桥接调用修改 UIView。这里则由 WebKit 管理 DOM、布局、文字输入与渲染,UIKit 看到的是一个 WebView。

React Native 与内嵌 Web 编辑器的运行模型前者跨桥修改原生视图,后者在 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 也不能假设所有宿主能力都存在。业务命令必须等待握手建立真实环境。

桥接停留在领域层

原生发往 JavaScriptJavaScript 发往原生
document.open / restoreeditor.ready / document.changed
appearance.updateselection.changed
connectivity.updatecollaboration.stateChanged
permission.updatepermission.requested
lifecycle.suspend / resumeattachment.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 串起启动:

跨原生与 JavaScript 的启动追踪从 WebView 获取、资源加载到握手、检查点恢复、首屏与协同连接,共享一条时间线。

由此可以判断是否应本地打包资源、有限预热 WebView、只渲染首屏块、避免全量快照挤占交互通道、改发增量或检查点,以及批量采样诊断。

预热也有成本,会消耗宿主共同的内存与 CPU 预算。需要明确何时准备、保留、降级和丢弃,而不是无条件预加载。

安全也是协议的一部分

Bridge 不能提供“按字符串任意调用原生方法”的入口。只允许受信任的内置编辑器来源,拦截外部导航,方法显式注册,载荷类型化解码,账号与文档匹配当前 session,敏感操作再次执行原生权限检查,附件路径不直接信任 JavaScript。

WKUserContentController 会持有 message handler。使用弱转发器与显式 teardown,避免直接注册 view controller 形成引用环。编辑器可以请求,原生仍负责判断能否执行。

最终形成三份明确权威:原生拥有宿主环境与持久生命周期,Web 编辑器拥有渲染、输入和文档模型,协同引擎拥有版本操作与收敛。Bridge 连接它们,而不把它们混成一个系统。

参考资料