Appflowy的技術棧

語言: CN / TW / HK

Appflowy是Notion這款筆記軟體的開源替代品,Appflowy用Rust和Flutter打造而成的。有關Appflowy的介紹可以訪問其官網www.appflowy.io檢視。

本文的目標讀者是對Appflowy的技術實現感興趣的黑客和開發者們。Appflowy可作為人們交流思想和共同構建知識體系的工具。本文主要闡述的是Appflowy的幾個大家比較好奇的內容: - Appflowy的DDD設計 - 採用Flutter來支援跨平臺的策略 - Rust在專案中扮演什麼角色 - 程式碼閱讀指南

1. 層級架構

1.1 領域驅動設計(DDD)

AppFlowy的前端遵循領域驅動設計正規化。它由表示層(presentation)、應用層(application)、領域層(domain)和基礎結構層(infrastructure)組成。為了使基礎架構層(infrastructure)更具可移植性,我們決定使用Rust來實現這一層,當然我們也很稀罕它的高效能和記憶體安全特性。除此之外,其他層級我們均使用了Flutter來實現,下面我們會介紹為什麼用Flutter。我們將這4個層分拆為UI和資料兩個元件,以便開發人員更好地理解。

DDD設計.png

1.2 層的定義

本小節介紹的層的概念均來自DDD設計,如果您過去已經瞭解過,您完全可以略過這部分內容。

表示層

負責向用戶呈現資訊並解釋使用者命令。

由widget和widget的狀態組成。

應用程式層

定義軟體應該執行的作業(UI程式碼或網路程式碼不在此處)。

協調應用程式活動並將工作委派給領域層。

不包含任何複雜的業務邏輯,而是在將使用者輸入傳遞到領域層之前對使用者輸入進行的基本驗證。

領域層

負責表示業務概念。

管理業務狀態或委託給基礎結構層

自包含,不依賴於任何其他層。領域層應與其他層很好地隔離。

基礎結構層

提供通用的技術功能用於支援上層的應用。

處理API、永續性、網路等。

實現儲存庫介面並隱藏領域層的複雜性。

其他考慮

每一層的抽象和複雜度不同,如下圖所示。較高層使用較低層提供的功能,並且每層提供與其上方和下方層不同的抽象。表示層具有高抽象和低複雜性,而基礎結構層具有較低的抽象和較高的複雜度。我們應該始終降低複雜度,因為這將導致應用程式中其他位置的許多簡化。我們應該注意的另一件事是依賴方向。較高的層依賴於較低的層,但較低的層不得依賴於較高的層。例如,領域層不應依賴於表示層。

抽象與複雜度.png

1.3 Flutter的價值——跨平臺

我們的使命是讓任何人都可以建立適合自己需求的應用程式。目標是提供Notion的功能外加資料安全性和跨平臺的原生體驗。我們通過堅持三個最基本的價值觀來實現這一使命: - 資料隱私第一 - 可靠的原生體驗 - 社群驅動

Flutter是Google釋出的一個用於建立跨平臺、高效能移動應用的框架。要了解更多資訊,您可以在其官方網站flutter.dev上檢視 。

由於Flutter相對較新,您可能想知道:

如果Flutter在其中一個平臺上表現得不夠出色,我們要如何應對?

我們同樣關心這個問題。AppFlowy對衝這種風險的策略是以最低的成本重寫UI元件(表現層、應用層和領域層)。以下說明我們將如何處理它。我們讓UI元件儘可能純淨,專注於UI呈現,並將複雜的業務邏輯留給資料元件(基礎結構層)。因此,如果UI元件從一個平臺切換到另一個平臺,則資料元件不必更改,如下圖所示。基礎設施層將成為在Dart/JS/Swift和Rust中實現的混合基礎設施層。

基礎設施層的複雜性.png

最複雜的層是基礎結構層。但是,我們將基礎結構層分為兩部分:介面和實現。我們創造了一個術語,FlowySDK,它在Dart中定義介面,在Rust中實現。多虧了Dart的FFI,讓介面與其實現的繫結變得簡單。例如,Dart中的某個介面叫做helloWorld(),對應的在Rust中的實現是hello_world(),它們通過HelloWorldEvent進行對映。當呼叫到helloWorld()的時候,HelloWorldEvent事件將通過dart_ffi傳送,然後傳遞到FlowySDK內部。在FlowySDK中有一個對映表記錄著事件和與之對應的元件。元件在FlowySDK初始化時宣告並註冊需要監聽的事件。

Dart FFI.png

我們將這種模式命名為事件排程。

優點: 1. 方便擴充套件

我們可以輕鬆新增或刪除模組。例如,flowy的使用者模組將自身註冊到事件排程系統。當相應的事件發生時,將呼叫該處理程式。此外,我們可以將模組轉換為動態庫並按需載入,從而提高效能。

水平刻度.png

  1. 可移植性強

將FlowySDK整合到不同的平臺很容易,因為FFI介面很簡單。

移植和靈活性.png

  1. 更精細的控制

我們可以使用不同的CPU/IO資源處理不同類別的事件。例如,在分配CPU資源時,音訊處理事件的優先順序應高於別的事件。

缺點:

  1. 效能問題

我們使用protobuf來進行Flutter和Rust間的通訊,這會損耗一些效能。序列化和反序列化的時間將隨著業務的增加而增長。

  1. 認知負荷

事件排程有其缺點,實現函式似乎有點太麻煩了。那為什麼我們不直接使用CodeGen從Rust的函式生成Dart的函式?就像Flutter Rust Bridge所做的那樣呢?原因是在我們寫AppFlowy的時候,Flutter在Web和桌面環境中還沒有得到很好的支援。如果Flutter Mac桌面的效能不符合我們的需求,我們將不得不在macOS本機上實現桌面。因此,我們還需要開發swift_rust_bridge,這需要額外的工作。鑑於我們目前是一個兩人團隊,我們選擇了一箇中間選項,即事件排程。

認知負荷.png

2. Appflowy前端

2.1. 模組

AppFlowy被分為許多模組,每個模組都有獨立的特性和功能。使用模組化架構,使得我們在更改一個模組後不會影響其他模組的功能,開發人員可以根據個人客戶需求或偏好定製應用程式。目前,AppFlowy由Core和User模組組成,每個模組都有兩個部分,如下所示。在 Flutter 中實現的左側部分(紫色)遵循 DDD 設計模式,並專注於 UI 呈現。由 Rust crate組成的右側部分(黃色)側重於資料處理,我們將在核心模組中探討關於它的更多細節。

模組.png

2.2. 核心模組

核心模組為AppFlowy應用程式定義了基礎的上下文,同時也作為協調其他各個模組的容器而存在。

每個"enties"都有一個自己的ID,它們是可以被引用的。您可以使用"enties"來表達您的業務。

Entity.png

使用者可以擁有多個工作區,每個工作區都包含許多應用。每個應用由多個檢視組成。檢視是一個獨立的物件,併為任何可顯示的物件提供抽象。在撰寫本文時,我們只定義了Document物件。

View.png

我們用flutter_bloc實現每個entity的業務。

下面讓我們來看看AppFlowy是如何使用DDD來實現業務規則的。

CoreModule.png

  1. Widget將收到的使用者互動資訊轉換成Bloc事件,這些事件會被髮送到特定的Bloc。反之,Bloc也傳送訊息給widgets,widgets再將UI更新為最新的狀態。此處的 Bloc表示DDD中的應用層,該層使用領域層提供的儲存庫或服務來處理Bloc事件。

  2. 只需將資料傳播到領域層。

  3. 儲存庫定義了實現其業務需求的介面和資料模型。我們使用從Rust端生成的protobuf來描述資料模型。例如,proto檔案是從rust結構workspace.rs生成的,它將建立workspace.dart和workspace.rs(protobuf生成的檔案)。它們表示相同的結構,但以不同的語言實現。使用protobuf可以更輕鬆地將資料從Flutter端轉換為Rust端,反之亦然。但是,序列化和反序列化是有代價的。

AST.png

通常情況下它執行得很好,但在某些情況下會導致嚴重的效能問題。例如,處理影象時出現記憶體問題。有許多方法可以優化這個問題,但在此我們選擇不深入研究細節。在此步驟中,dart物件將被包裝到請求中並傳播到基礎結構層。

  1. 將請求序列化為二進位制資料,並通過Dart_ffi將其傳送到FlowySDK。

  2. 請求將由分發器安排。排程程式查詢請求的處理程式,然後使用其資料對其進行呼叫。每個模組宣告它可以處理的事件,並將自身註冊到排程程式。

  3. 處理程式提取二進位制資料,並根據事件將其反序列化為特定的資料結構,並執行一些業務邏輯。

  4. 將返回值序列化為二進位制資料,並將其傳送到排程程式。

  5. 響應包含狀態程式碼,二進位制資料作為返回值傳遞給呼叫方。

  6. 將二進位制資料反序列化為特定的dart物件。我們使用CodeGen自動將二進位制資料對映到dart物件。您可以檢視code_gen.dart以獲取更多資訊。

  7. 將protobuf物件傳播到上層。

  8. Bloc等待future的完成,然後根據狀態更新widget。