我们面临一个棘手的需求:在一个成熟的 iOS 原生应用中,嵌入一个高度动态、需要频繁迭代的“项目看板”模块。完全用 SwiftUI 或 UIKit 重写真实项目中的迭代速度跟不上业务需求,每次微小的文案或逻辑调整都意味着完整的 App Store 审核周期。初步的构想是使用 WKWebView
加载一个 Svelte 构建的单页应用(SPA),通过 API 与后端通信。这个方案在原型阶段看似可行,但很快就暴露了生产环境中的致命缺陷:网络延迟导致 UI 卡顿、状态不一致,并且完全无法离线工作。用户在地铁里打开看板,看到的要么是加载失败的白屏,要么是过时的数据。
传统的解决方案,如在原生代码中做复杂的缓存和请求重试逻辑,会将 Web 与 Native 的边界搞得一团糟,形成一个难以维护的混合体。问题的核心不在于 UI 的渲染技术,而在于状态同步。我们需要一个能在 Native Shell 和 Web Core 之间、以及客户端与服务器之间,实现无缝、低延迟、且支持离线修改的状态同步机制。这把我们引向了一个不那么常规的技术选型:无冲突复制数据类型(CRDTs)。
我们的目标架构是:
- Svelte UI: 负责渲染和交互,其状态完全由一个 CRDT 文档(Y.Doc)驱动。
- iOS Native Shell: 作为 Svelte 应用的容器,通过
WKWebView
的 JavaScript Bridge 监听并有限地修改 CRDT 状态,同时为 Web 环境提供原生能力(如离线存储)。 - NoSQL Backend (MongoDB): 存储 CRDT 文档的持久化状态。
- WebSocket Server: 在客户端和服务器之间实时广播 CRDT 的增量更新。
这个方案的本质,是将 Svelte 组件从一个简单的“视图”层,提升为一个自洽的、可离线工作的“状态引擎”,而原生 iOS 应用则为其提供运行环境与持久化支持。
后端基石:WebSocket 与 MongoDB 持久化
后端的核心职责不是处理复杂的业务逻辑,而是成为一个高效的 CRDT 更新广播器和持久化存储器。我们选择 Node.js、ws
库和 y-leveldb
结合 y-mongodb
的思路来实现。在真实项目中,直接使用 y-leveldb
可能不适合分布式部署,但 y-mongodb-provider
的核心逻辑是相似的,这里我们手动勾勒出关键部分。
// server.js
// A simplified WebSocket server for broadcasting CRDT updates.
// In a real project, you'd want more robust error handling, authentication, and scalability.
import { WebSocketServer } from 'ws';
import * as Y from 'yjs';
import { MongodbPersistence } from 'y-mongodb-provider';
import { setAwareness } from 'y-protocols/awareness.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/crdt_sync_db';
const PORT = process.env.PORT || 8080;
// Setup MongoDB persistence
// This handles fetching the initial document state and persisting updates.
const mdb = new MongodbPersistence(MONGODB_URI, {
collectionName: 'yjs-docs',
flushSize: 100,
multipleCollections: true,
});
const wss = new WebSocketServer({ port: PORT });
// A map to hold all documents currently active in memory.
// Key: document ID (e.g., 'project-board-alpha')
// Value: { doc: Y.Doc, conns: Set<WebSocket> }
const docs = new Map();
// When a client connects...
wss.on('connection', (conn, req) => {
// Extract document ID from the URL, e.g., ws://localhost:8080/project-board-alpha
const docId = req.url.slice(1).split('?')[0];
let docData = docs.get(docId);
if (!docData) {
// If the document is not in memory, create it.
const doc = new Y.Doc();
docData = { doc, conns: new Set() };
docs.set(docId, docData);
// Fetch the document's content from MongoDB.
mdb.getYDoc(docId).then((persistedDoc) => {
// Apply the persisted state to our in-memory document.
Y.applyUpdate(doc, Y.encodeStateAsUpdate(persistedDoc));
});
// Listen for updates on the in-memory doc and persist them.
doc.on('update', (update) => {
mdb.storeUpdate(docId, update);
});
}
docData.conns.add(conn);
// Handle messages from the client.
conn.on('message', (message) => {
try {
const update = new Uint8Array(message);
// Apply the client's update to the server's version of the document.
Y.applyUpdate(docData.doc, update, conn);
// Broadcast the update to all other connected clients for this document.
docData.conns.forEach(client => {
if (client !== conn && client.readyState === client.OPEN) {
client.send(update);
}
});
} catch (err) {
// Proper logging is crucial here.
console.error(`Failed to process message for docId: ${docId}`, err);
}
});
// When a client disconnects...
conn.on('close', () => {
docData.conns.delete(conn);
// If no clients are connected to this document, we can remove it from memory
// to conserve resources. It's already persisted in MongoDB.
if (docData.conns.size === 0) {
docs.delete(docId);
console.log(`Document ${docId} removed from memory.`);
}
});
// Send the initial state of the document to the newly connected client.
const stateVector = Y.encodeStateVector(docData.doc);
const initialState = Y.encodeStateAsUpdate(docData.doc);
conn.send(initialState);
});
console.log(`WebSocket server running on port ${PORT}`);
这个后端实现非常精炼,但包含了核心逻辑:
- 文档隔离: 通过 URL (
/docId
) 来区分不同的协作文档。 - 内存与持久化: 活跃的文档保留在内存中以实现低延迟广播,同时通过
y-mongodb-provider
的逻辑将更新异步写入 MongoDB。 - 增量同步:
Y.applyUpdate
和doc.on('update', ...)
是 CRDT 的精髓,只交换和存储增量变化,而不是整个文档。
Svelte 前端:状态管理的革命
在 Svelte 侧,我们将彻底抛弃传统的 useState
或 fetch
模式。UI 的状态将直接绑定到 Y.Doc
实例。我们使用 yjs
和 y-websocket
库来处理这一切。
<!-- src/components/ProjectBoard.svelte -->
<script>
import { onMount } from 'svelte';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// The core of our state management: a Yjs document.
const ydoc = new Y.Doc();
let provider;
// Create a Yjs Map to store our board data.
// This is a CRDT-aware Map structure.
const boardData = ydoc.getMap('boardData');
let columns = [];
let tasks = {}; // An object to hold tasks, keyed by column id
onMount(() => {
// The docId should be passed from the native side or determined dynamically.
const docId = 'project-board-alpha';
provider = new WebsocketProvider('ws://localhost:8080', docId, ydoc);
provider.on('status', event => {
console.log('WebSocket Connection Status:', event.status); // 'connected' or 'disconnected'
// The native shell can listen for these logs to display connection status.
});
// Observe changes on the 'boardData' map.
boardData.observe(event => {
console.log('Board data changed, re-rendering...');
// A simple but effective way to trigger Svelte's reactivity.
updateLocalState();
});
// Initialize the local state from the Y.Doc
updateLocalState();
// A one-time setup for initial board structure if it doesn't exist.
if (!boardData.get('columns')) {
boardData.set('columns', [
{ id: 'col-1', title: 'To Do' },
{ id: 'col-2', title: 'In Progress' },
{ id: 'col-3', title: 'Done' }
]);
boardData.set('tasks', {
'task-1': { id: 'task-1', content: 'Setup CRDT backend' },
'task-2': { id: 'task-2', content: 'Integrate Svelte with Yjs' },
});
boardData.set('columnOrder', ['col-1', 'col-2', 'col-3']);
boardData.set('taskOrder', {
'col-1': ['task-1', 'task-2'],
'col-2': [],
'col-3': []
});
}
return () => {
// Cleanup on component destruction.
provider.disconnect();
ydoc.destroy();
};
});
function updateLocalState() {
// This function synchronizes the Svelte component's local variables
// with the state in the Y.Doc. It's called whenever the Y.Doc changes.
const data = boardData.toJSON();
columns = data.columns || [];
tasks = data.tasks || {};
// ... update other state variables like columnOrder etc.
}
function handleAddTask(columnId) {
const newTaskId = `task-${Date.now()}`;
const newTaskContent = `New Task ${Object.keys(tasks).length + 1}`;
// All state modifications are transactions on the Y.Doc.
// This is an atomic operation.
ydoc.transact(() => {
const currentTasks = boardData.get('tasks');
boardData.set('tasks', { ...currentTasks, [newTaskId]: { id: newTaskId, content: newTaskContent } });
const currentTaskOrder = boardData.get('taskOrder');
const columnTasks = currentTaskOrder[columnId] || [];
currentTaskOrder[columnId] = [...columnTasks, newTaskId];
boardData.set('taskOrder', { ...currentTaskOrder });
});
}
// Drag-and-drop logic would also manipulate the Y.Doc via transactions.
// ... (Implementation for drag and drop handlers omitted for brevity)
</script>
<div class="board">
{#each columns as column}
<div class="column">
<h3>{column.title}</h3>
<div class="task-list">
<!-- Render tasks based on tasks and taskOrder state -->
</div>
<button on:click={() => handleAddTask(column.id)}>Add Task</button>
</div>
{/each}
</div>
<style>
/* Basic styling for the board */
.board { display: flex; gap: 16px; }
.column { background: #f4f4f4; padding: 8px; border-radius: 4px; min-width: 250px; }
.task-list { min-height: 100px; }
</style>
Svelte 代码的关键点在于:
- 单一数据源:
ydoc
是唯一的状态来源。所有用户交互(如添加任务)都通过ydoc.transact
来修改状态。 - 响应式绑定: 通过
boardData.observe
监听 CRDT 的变化,然后手动调用一个函数updateLocalState
来更新 Svelte 的局部变量,从而触发 UI 重新渲染。这虽然不是 Svelte Stores 那样的全自动响应,但在 CRDT 场景下是一种清晰且高效的模式。 - 离线能力:
y-websocket
自动处理网络断开和重连。当离线时,所有对ydoc
的修改都会被记录在本地。一旦网络恢复,它会自动将本地的变更发送给服务器,并接收服务器端的变更,CRDT 的算法保证了最终状态的一致性。
iOS 原生桥:WKWebView
与 CRDT 状态的交互
这是整个架构中最具挑战性的部分。iOS 原生代码需要与 WebView 中的 Svelte/Yjs 环境进行双向通信。我们不直接传递整个状态树,而是通过一个定义好的协议来交换信息和命令。
sequenceDiagram participant iOS_Native as iOS (Swift) participant WebView_Bridge as JavaScript Bridge participant Svelte_App as Svelte (Yjs) participant WebSocket_Server as Server iOS_Native->>WebView_Bridge: setupMessageHandler("crdtBridge") Note right of Svelte_App: Svelte App Initializes Svelte_App->>WebSocket_Server: Connects & Syncs Y.Doc Svelte_App->>WebView_Bridge: postMessage({ type: "docLoaded", payload: { ... } }) WebView_Bridge->>iOS_Native: userContentController(didReceive message) iOS_Native->>iOS_Native: Update native UI (e.g., task count badge) Note over iOS_Native, Svelte_App: User interacts with native button iOS_Native->>WebView_Bridge: evaluateJavaScript("window.crdtAPI.addTask('col-1')") WebView_Bridge->>Svelte_App: Executes addTask function Svelte_App->>Svelte_App: ydoc.transact(...) Svelte_App->>WebSocket_Server: Broadcasts CRDT update
Swift 端实现:
// HybridWebViewController.swift
import UIKit
import WebKit
// Define the contract for messages sent from JS to Swift
struct ScriptMessage: Decodable {
let type: String
// Using a generic dictionary for payload for flexibility
let payload: [String: Any]?
// Custom decoder to handle dynamic payload
private struct DynamicCodingKeys: CodingKey {
var stringValue: String
init?(stringValue: String) { self.stringValue = stringValue }
var intValue: Int?
init?(intValue: Int) { return nil }
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
type = try container.decode(String.self, forKey: DynamicCodingKeys(stringValue: "type")!)
payload = try container.decodeIfPresent([String: Any].self, forKey: DynamicCodingKeys(stringValue: "payload")!)
}
}
class HybridWebViewController: UIViewController, WKScriptMessageHandler {
private var webView: WKWebView!
private let messageHandlerName = "crdtBridge"
override func viewDidLoad() {
super.viewDidLoad()
setupWebView()
loadSvelteApp()
}
private func setupWebView() {
let contentController = WKUserContentController()
// Register the message handler. This is how JS communicates with Swift.
contentController.add(self, name: messageHandlerName)
let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
// Enable file access for local development. In production, this might differ.
configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
webView = WKWebView(frame: view.bounds, configuration: configuration)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
}
private func loadSvelteApp() {
// In a real app, the Svelte build output (index.html, js, css) would be bundled.
guard let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "svelte-dist") else {
// Critical error: The web app is missing from the bundle.
fatalError("Could not find Svelte app in bundle.")
}
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
}
// This method is the entry point for all messages from the WebView.
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == messageHandlerName else { return }
guard let bodyString = message.body as? String,
let bodyData = bodyString.data(using: .utf8) else {
print("Error: Could not parse message body from WebView.")
return
}
do {
let scriptMessage = try JSONDecoder().decode(ScriptMessage.self, from: bodyData)
handleScriptMessage(scriptMessage)
} catch {
print("Error decoding message from JS: \(error)")
}
}
private func handleScriptMessage(_ message: ScriptMessage) {
// Here we route messages based on their type.
// This acts as a mini-API endpoint within the native app.
switch message.type {
case "docLoaded":
print("Svelte app reported document loaded.")
if let taskCount = (message.payload?["taskCount"] as? NSNumber)?.intValue {
// Example: update a native UI element based on web state.
self.navigationItem.title = "Project Board (\(taskCount) tasks)"
}
case "syncStatusChanged":
if let status = message.payload?["status"] as? String {
print("Sync status is now: \(status)") // e.g., "connected", "disconnected"
// Update a native connection status indicator.
}
default:
print("Received unknown message type: \(message.type)")
}
}
// --- Methods for Swift to call JS ---
// Example: A native button press triggers this function to add a task.
@objc func didTapAddTaskButton() {
let columnId = "col-1" // Determined by native logic
let jsCode = "window.crdtAPI.addTask('\(columnId)');"
evaluateJavaScript(jsCode)
}
private func evaluateJavaScript(_ script: String) {
webView.evaluateJavaScript(script) { result, error in
if let error = error {
// In production, this should go to a proper logging service.
print("JavaScript evaluation error: \(error)")
}
if let result = result {
print("JavaScript evaluation result: \(result)")
}
}
}
}
// Helper to decode [String: Any]
extension KeyedDecodingContainer {
func decode(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any] {
var dictionary = [String: Any]()
for key in allKeys {
if let intValue = try? decode(Int.self, forKey: key) {
dictionary[key.stringValue] = intValue
} else if let stringValue = try? decode(String.self, forKey: key) {
dictionary[key.stringValue] = stringValue
} else if let boolValue = try? decode(Bool.self, forKey: key) {
dictionary[key.stringValue] = boolValue
} else if let nestedDictionary = try? decode([String: Any].self, forKey: key) {
dictionary[key.stringValue] = nestedDictionary
} else if let nestedArray = try? decode([Any].self, forKey: key) {
dictionary[key.stringValue] = nestedArray
}
}
return dictionary
}
func decodeIfPresent(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any]? {
return try? decode(type, forKey: key)
}
func decode(_ type: [Any].Type, forKey key: K) throws -> [Any] {
var container = try nestedUnkeyedContainer(forKey: key)
return try container.decode(type)
}
}
extension UnkeyedDecodingContainer {
mutating func decode(_ type: [Any].Type) throws -> [Any] {
var array: [Any] = []
while isAtEnd == false {
if let value = try? decode(String.self) {
array.append(value)
} else if let value = try? decode(Int.self) {
array.append(value)
} else if let value = try? decode(Bool.self) {
array.append(value)
} else if let nestedDictionary = try? decode([String: Any].self) {
array.append(nestedDictionary)
} else if let nestedArray = try? decode([Any].self) {
array.append(nestedArray)
}
}
return array
}
}
JavaScript Bridge 在 Svelte 端:
// This code would live in your Svelte app, probably in the main App.svelte or a dedicated service.
// It exposes a global API for Swift to call and posts messages back to Swift.
function postToNative(type, payload) {
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.crdtBridge) {
const message = JSON.stringify({ type, payload });
window.webkit.messageHandlers.crdtBridge.postMessage(message);
} else {
console.warn('Native message handler (crdtBridge) is not available.');
}
}
// Expose a global API for the native shell to interact with.
window.crdtAPI = {
addTask: (columnId) => {
// This assumes `ydoc` is accessible in this scope.
// In a real Svelte app, you'd get this from a store or context.
const boardData = ydoc.getMap('boardData');
const newTaskId = `task-${Date.now()}`;
const newTaskContent = `Task from Native`;
ydoc.transact(() => {
const currentTasks = boardData.get('tasks');
boardData.set('tasks', { ...currentTasks, [newTaskId]: { id: newTaskId, content: newTaskContent } });
const currentTaskOrder = boardData.get('taskOrder');
const columnTasks = currentTaskOrder[columnId] || [];
currentTaskOrder[columnId] = [...columnTasks, newTaskId];
boardData.set('taskOrder', { ...currentTaskOrder });
});
console.log(`Task added to column ${columnId} via native call.`);
},
// Other methods like `deleteTask`, `moveTask` could be exposed here.
};
// Example of sending data to native when the document loads/changes
boardData.observe(event => {
const tasks = boardData.get('tasks');
const taskCount = tasks ? Object.keys(tasks).length : 0;
postToNative('docLoaded', { taskCount });
});
provider.on('status', ({ status }) => {
postToNative('syncStatusChanged', { status });
});
这套桥接机制的健壮性在于其清晰的边界。Swift 不关心 DOM,JavaScript 也不关心 UIKit。它们通过一个定义良好的、基于消息的 API (crdtBridge
) 和一个命令 API (window.crdtAPI
) 进行通信,而底层的 CRDT 状态同步则由 Yjs 自动处理。
局限性与未来路径
这套架构并非没有成本。它的主要复杂度在于引入了 CRDT 这一概念,团队需要对其工作原理有所了解。同时,原生与 Web 之间的通信桥需要精心设计和维护,避免成为性能瓶颈或出现难以调试的竞态条件。一个常见的错误是在这个桥上发送大量或频繁的数据,我们的设计通过只发送事件通知和高级命令来规避了这一点,真正的状态同步由 Yjs 的高效二进制协议处理。
当前的实现中,CRDT 的“权威”实例存在于 WebView 的 JavaScript 环境中。原生代码通过执行 JS 来间接修改它。一个更深度的集成方案可能是在 Swift 中实现一个原生的 CRDT 库(或绑定一个 Rust/C++ 的核心实现),让原生代码和 WebView 共享同一个 CRDT 状态模型。这将消除 JS Bridge 的部分开销,并提供更强的类型安全,但这同样也带来了更高的实现和维护成本。
此外,对于极其复杂的文档结构,Yjs 在内存中的对象图也可能占用可观的内存。需要针对具体场景进行性能分析,确保其在移动设备的资源限制下表现良好。