一篇寫給「想做跨端產品,但不想把業務邏輯在每個平台重寫一遍」的工程筆記。主角是 SwarmNote:桌面端用 Tauri + React,行動端用 Expo + React Native,底層共享同一份 Rust 核心。
SwarmNote:你的筆記,在你自己的裝置之間群聚流動。
SwarmNote 的跨端方案不是「桌面一套、手機一套、業務邏輯靠複製貼上同步」,而是把系統拆成三層:
React + Tauri WebView,行動端是 Expo + React Native。#[tauri::command] 暴露能力,行動端用 uniffi-bindgen-react-native 產生 JSI/Turbo Module。swarmnote-core 是平台無關的 Rust crate,負責工作區、文件、Yjs/yrs 狀態、P2P 配對與同步。換句話說,桌面和行動不是兩個產品,而是同一個 Rust 核心外面套了兩個不同的殼。
一開始我也很想要一個特別漂亮的答案:Tauri v2 既能做桌面,也能做行動,那是不是直接一套 Web + Rust 走到底就好了?
這個想法很誘人。Web 技術寫 UI,Rust 寫核心,桌面包體小,系統能力強;到了行動端,理論上也只是把 Tauri 初始化到 Android / iOS 專案裡。聽起來像是「跨端開發終於不用再做選擇題了」。
但真正寫到檔案系統、系統分享、行動端權限、手勢、鍵盤、安全區這些地方,事情就開始變得沒有那麼童話了。
維度Tauri mobileReact Native行動端 UI 手感WebView 為主,需要自己處理大量行動互動細節原生視圖,手勢、導覽、鍵盤、安全區更自然生態Tauri 外掛 + Web 生態Expo / RN 生態,行動能力覆蓋更完整Rust 呼叫WebView IPC,JSON 序列化JSI 直調 C++/Rust 綁定,型別生成編輯器複用很適合 Web 編輯器需要 WebView 承載 CodeMirror產品定位「把 Web app 帶到行動端」很快「做一個真正手機 app」更順所以後來的方向變成:桌面繼續用 Tauri,行動端改用 React Native,但 Rust 核心繼續復用。
SwarmNote 不是憑空長出來的架構。前面還有一個探路專案:SwarmDrop。
SwarmDrop 做的是 P2P 檔案傳輸,可以理解成「跨網路版 LocalSend」。它很適合拿來驗證幾件硬骨頭:
早期做行動端驗證時,SwarmDrop 用過 Tauri mobile。這個階段很重要,因為它證明了「Rust 核心上行動端」這條路是通的。Tauri 的 #[tauri::command]、event、channel 這套模型,對已經熟悉桌面端的人來說非常自然:前端 invoke(),後端 Rust async 處理,進度再推回前端。
但它也暴露了一個現實:能跑起來 和 適合長期做行動產品 是兩件事。
SwarmDrop 裡最典型的痛點是檔案系統。桌面端拿到路徑後,很多事情就是 std::fs / tokio::fs。Android 上則不一樣:使用者選中的可能是 content:// URI,公開下載目錄涉及 Scoped Storage,寫入要繞 SAF 或 MediaStore,目錄遍歷、權限持久化、暫存快取、大檔串流讀取都要單獨處理。
這些不是 Tauri 的錯,而是行動端系統本來就複雜。只是當時 Tauri mobile 生態還比較薄,遇到這種偏底層的行動檔案系統需求,很難找到一個像 Expo/RN 生態裡那樣順手、成體系、案例足夠多的解法。最後就會變成:你名義上在寫跨端 app,實際上在一邊寫 WebView UI,一邊寫 Android 原生外掛,一邊維護 Rust 傳輸層,一邊補權限和生命週期膠水。
這個階段給了 SwarmNote 一個很重要的教訓:Rust 核心值得保留,但行動端的殼不一定非要繼續用 Tauri。
這段需要說得公允一點:Tauri v2 mobile 是有價值的。官方已經把 Android / iOS 支援納入 v2,也提供行動外掛能力;外掛可以用 Kotlin / Swift 寫原生實作,再暴露給 WebView 前端。對很多「Web 產品加一點行動殼」的場景,它是很有吸引力的。
但對 SwarmNote / SwarmDrop 這種專案,幾個限制會變得很明顯:
問題在專案裡表現出來的影響行動外掛生態還不夠厚官方也明確說並非所有外掛都支援行動端;遇到細分能力時,常常要自己寫外掛檔案系統不是「一個 API 解決所有平台」App 私有目錄還好,公開目錄、檔案選擇、SAF URI、MediaStore、大檔串流讀寫會迅速複雜WebView UI 要自己補行動細節鍵盤避讓、安全區、手勢導覽、bottom sheet、觸控回饋都要額外經營行動端社群案例少複雜問題搜尋到的經驗少,很多坑只能自己踩呼叫鏈仍是 WebView IPC對高頻、型別複雜的核心呼叫來說,JSON IPC 不如 JSI 直調舒服所以後面寫 SwarmNote 行動端時,我換了一個問題問自己:
如果 Rust 核心已經證明可行,行動端為什麼不直接用一個真正成熟的行動 UI 生態?
答案就是 React Native + Expo + UniFFI。
一開始這只是一次嘗試:RN 負責行動端體驗,Rust 繼續負責核心邏輯,中間用 uniffi-bindgen-react-native 接起來。結果有點出乎意料:它不是「退而求其次」,反而把兩邊的長處都放大了。
最後這條路線也反過來影響了 SwarmDrop。SwarmDrop 早期負責驗證 libp2p、NAT 穿透、配對、傳輸等底層能力;SwarmNote 在此基礎上把「共享 Rust 核心 + Host 適配層」整理成更清晰的模式;現在 SwarmDrop 也遷移到同一套架構:桌面端薄 Tauri host,行動端 Expo/RN host,中間共享 swarmdrop-core / swarm-p2p-core。
Tauri 在 SwarmNote 裡不是業務核心,而是桌面 host。它做這些事:
invoke() 呼叫轉成 Rust 命令emit() 推給前端SwarmNote 的 src-tauri/src/lib.rs 裡可以看到典型入口:註冊外掛、註冊 commands,在 setup 階段建立 AppCore,再把桌面專屬能力注入進去。
rust 体验AI代码助手 代码解读复制代码let keychain = Arc::new(platform::DesktopKeychain::new());
let event_bus = Arc::new(platform::TauriEventBus::new(app.handle().clone()));
let app_core = AppCoreBuilder::new(keychain, event_bus, app_data_dir)
.with_watcher_factory(|p| Arc::new(platform::NotifyFileWatcher::new(p)))
.build()
.await?;
前端看到的仍然是熟悉的 Tauri 呼叫:
ts 体验AI代码助手 代码解读复制代码import { invoke } from "@tauri-apps/api/core";
await invoke("apply_ydoc_update", {
docUuid,
update,
});
這裡的關鍵不是 invoke 本身,而是邊界:Tauri command 只做參數接收、錯誤轉換和事件轉發,真正的業務規則盡量下沉到 swarmnote-core。
行動端倉庫 swarmnote-mobile 是 Expo + React Native。它負責手機上該有的東西:
expo-secure-store、expo-file-system 等行動端 host 能力但行動端沒有重寫工作區、文件、配對、Yjs 狀態機。它透過 react-native-swarmnote-core 這個 workspace 包,把 Rust 暴露成 RN 可以直接呼叫的 Turbo Module。
在 Rust 端,行動端 wrapper 很薄。它定義 #[derive(uniffi::Object)] 的物件,把 WorkspaceCore 包起來,然後導出 async 方法:
rust 体验AI代码助手 代码解读复制代码#[derive(uniffi::Object)]
pub struct UniffiWorkspaceCore {
inner: Arc<WorkspaceCore>,
}
#[uniffi::export(async_runtime = "tokio")]
impl UniffiWorkspaceCore {
pub async fn open_doc(&self, rel_path: String) -> Result<UniffiOpenDocResult, FfiError> {
let result = self.inner.ydoc().open_doc(&rel_path).await?;
Ok(result.into())
}
}
它和 Tauri command 的關係很像:
桌面端 Tauri行動端 UniFFI#[tauri::command]``#[uniffi::export]``invoke("cmd", args)直接呼叫產生的 TS 函式/物件方法app.emit("event")callback interface / event adapterJSON IPCJSI / C++ 綁定執行時參數匹配產生 TypeScript 型別這就是這套架構最舒服的地方:開發體驗像 Tauri,但行動端執行時更貼近原生。
這裡有一個容易誤解的點:行動端用了 React Native,不代表所有東西都必須變成 RN 原生元件。
SwarmNote 的編輯器是 CodeMirror 6。它依賴 DOM、Selection、MutationObserver、CSS 佈局等 Web 能力,很適合桌面 Tauri WebView,但不能直接塞進 RN 原生渲染樹。為了解決這個問題,編輯器後來被獨立成 swarmnote-editor monorepo,並發布成 npm 套件,讓桌面端、行動端和未來其他 host 都能重用同一個 Markdown live-preview 核心。
所以行動端採用「兩條橋」:
這看起來多了一層,但換來了非常現實的收益:
行動端這條鏈路的本質,是載入一個自包含的 editor WebView bundle,再由 RN WebView 承載。早期在 swarmnote-mobile/packages/editor-web 裡維護這層入口;現在它已經在 swarmnote-editor 裡沉澱為 @swarmnote/editor-react-native/webview 這樣的 npm subpath。RN 和 WebView 之間用 Comlink 把 postMessage 包裝成「像本地函式一樣呼叫」的 RPC。
swarmnote-editor 不是 SwarmNote 倉庫裡的一個私有目錄,而是獨立發布的編輯器工程。它目前拆成三個公開 npm 套件:
包用途@swarmnote/editor-coreCodeMirror 6 核心、Markdown live-preview、Plugin SDK,以及 math / table / mermaid / slash / wikilink 等外掛@swarmnote/editor-reactReact host 的薄適配,提供 EditorView 和 I18nProvider``@swarmnote/editor-react-nativeReact Native host 的橋接層,提供 useEditorBridge、Comlink adapter 和 WebView HTML bundle這裡的拆法也延續了 SwarmNote 的跨端思路:執行時核心走 npm,容易被重用;UI primitives 走 shadcn 風格 registry,方便 host 複製後按自己的產品體驗改。
如果只想在自己的 Tauri / Electron / Web 專案裡嵌一個 Markdown 編輯器,可以從最小安裝開始:
bash 体验AI代码助手 代码解读复制代码pnpm add @swarmnote/editor-core @swarmnote/editor-react
如果是 React Native / Expo,則是:
bash 体验AI代码助手 代码解读复制代码pnpm add @swarmnote/editor-core @swarmnote/editor-react-native react-native-webview comlink
這也是我覺得 SwarmNote 架構比較值得寫出來的原因:不是只把產品做成跨端,而是把過程中沉澱出來的「可重用零件」也順手開源、發布、文件化。swarmnote-core 解決本地優先和 P2P 同步,swarmnote-editor 則解決 Markdown 編輯體驗復用。
跨端最容易失敗的地方,是一開始把 Tauri、RN、檔案系統、通知、金鑰儲存混在業務邏輯裡。SwarmNote 的做法是讓 swarmnote-core 保持平台無關:
桌面端實作這些 trait:
能力桌面實作金鑰keyring,對接 macOS Keychain / Windows Credential Manager / Linux Secret Service事件TauriEventBus,內部呼叫 AppHandle::emit檔案監看notify + debouncer本地檔案桌面檔案系統行動端則換成另一套實作:
能力行動實作金鑰RN 端 expo-secure-store,Rust 端透過 callback/adapter 使用事件UniFFI callback interface,推到 RN store檔案監看行動沙盒內通常不需要桌面式 watcher本地檔案App sandbox / Expo FileSystem 路徑業務核心不問「我現在是不是 Tauri」,只問「誰實作了這個 trait」。這就是跨端復用真正成立的原因。
SwarmNote 不是普通 Markdown 編輯器。它的產品目標是:
這類能力如果分別用 JS、Kotlin、Swift、Rust 寫四遍,很快就會進入維護地獄。Rust core 的價值在這裡變得很明確:
Rust 這層同時持有:
桌面和行動共享它,意味著同一個 bug 只修一次,同一套同步協議不會因為平台不同而悄悄分叉。
桌面倉庫:
text 体验AI代码助手 代码解读复制代码swarmnote/
├── src/ # React 桌面前端
├── src-tauri/ # Tauri host:commands / plugins / desktop adapters
├── crates/
│ ├── core/ # swarmnote-core:平台無關業務核心
│ ├── entity/ # SeaORM entities
│ └── migration/ # SQLite migrations
├── libs/core/ # swarm-p2p-core:libp2p 封裝
└── dev-notes/blog/ # 技術文章和架構筆記
行動倉庫:
text 体验AI代码助手 代码解读复制代码swarmnote-mobile/
├── src/ # Expo Router / RN screens / stores
├── packages/
│ ├── editor-web/ # 早期 WebView 編輯器入口;可遷移到 @swarmnote/editor-react-native
│ └── swarmnote-core/ # react-native-swarmnote-core
│ ├── rust/mobile-core/ # UniFFI wrapper crate
│ ├── src/generated/ # 生成的 TS 綁定
│ └── cpp/generated/ # 生成的 C++ JSI 綁定
└── plugins/ # Expo config plugins
共享編輯器倉庫:
text 体验AI代码助手 代码解读复制代码swarmnote-editor/
├── packages/editor-core/ # @swarmnote/editor-core
├── packages/editor-react/ # @swarmnote/editor-react
├── packages/editor-react-native/ # @swarmnote/editor-react-native
└── registry/ # shadcn 風格 UI primitives
把上面的圖合起來,看一條使用者操作鏈路會更直觀。
桌面端鏈路幾乎一樣,只是 RN -> UniFFI 換成了 React -> Tauri invoke,WebView CodeMirror 就是 Tauri 視窗裡的前端編輯器。
第一,業務一致性更強。
配對、同步、文件狀態、衝突處理都在 Rust core,同一套測試和同一套狀態機覆蓋桌面與行動。
第二,平台體驗不妥協。
桌面端繼續享受 Tauri 的系統整合、系統匣、自動更新、小包體;行動端使用 RN/Expo 做導覽、手勢、鍵盤、安全區和行動原生能力。
第三,遷移路徑自然。
SwarmDrop 先驗證 libp2p,再抽出 swarm-p2p-core;SwarmNote 進一步抽出 swarmnote-core;現在 SwarmDrop 也可以沿同樣邊界遷移。這不是一次性重寫,而是把已經跑通的能力逐步「下沉成核心」。
第四,編輯器復用現實可行。
CodeMirror 不硬改成 RN 原生元件,而是讓 WebView 做它擅長的事。RN 透過 Comlink 拿到型別化 API,編輯器體驗保持一致。
這套架構也不是免費午餐。
坑解決方式行動端不能用 Expo Go必須用 development build,因為有原生 Rust Turbo ModuleRust 改動後需要重新產生綁定pnpm --filter react-native-swarmnote-core ubrn:android 或 ubrn:iosWebView 編輯器可能載入舊 bundle改 editor-core 或 editor-react-native/webview 後重建對應 npm 套件 / WebView bundle生成程式碼很大明確約定 src/generated / cpp/generated 不手改平台能力邊界容易滑坡新功能先判斷:業務規則進 core,平台能力進 host adapter事件鏈路更長用統一 AppEvent + Tauri emit / UniFFI callback 做映射一個實用判斷標準:
可以按這個順序思考,而不是一上來就選框架:
use tauri::*,也不要讓它依賴 RN 套件。平台差異透過 trait 或 wrapper 注入。SwarmNote 正在做的是一個本地優先、P2P 同步的 Markdown 筆記工具:
.md 檔案如果你對「沒有雲端帳號、沒有中心伺服器、自己的裝置直接同步筆記」感興趣,可以關注: