從 QuickJS 到 Dart VM:稿定跨端渲染工程的執行時演化

doodlewind發表於2021-03-16

在稿定科技,我們使用 QuickJS 與 Skia 搭建並落地了自研的 App 端編輯器渲染能力。去年北京的 QCon+ 上,筆者為此做了「基於 QuickJS + Skia 的 GUI 框架」分享。下面是一些基於該能力渲染的實際應用截圖:

image.jpg

但在短短几個月後,我們就再次升級了這項 QuickJS + Skia 的工程設計,將 Skia 的渲染能力切換到與 Flutter 中的 Dart VM 相整合。本文會介紹這背後的技術演進,共有這麼幾個部分:

  • QuickJS 方案演化歷程
  • 從 QuickJS 到 Dart VM 的探索
  • Dart VM 遷移實踐經驗
  • 覆盤總結

QuickJS 方案演化歷程

稿定的跨端工程最早始於筆者一項出於業餘興趣的個人實驗,即嘗試用 QuickJS 結合 libuv 來接入平臺 IO 能力,並在此基礎上繫結 Skia 來實現 Canvas 渲染。這相當於實現了一套 HTML5 Canvas 標準的子集,效果如下:

skia-quickjs-poc.jpg

我們在這一設計的基礎上搭建了編輯器的原型,但並未最終落地。其問題主要在於效能,具體可參見這張圖:

js-canvas-arch.png

上圖顯示了在將 JS 引擎嵌入原生環境後,從點選事件到執行 UI 更新之間的主要環節。其中,JS 的 Canvas 繪製會直接操作 Skia 的 SkBitmap。這一操作雖然已沒有執行緒通訊開銷,但一旦每幀進行數百次繪製 API 呼叫(這對命令式的 Canvas 繪製而言很常見),仍然很容易超出 16ms 的限制。這種高頻操作時的效能問題,應當也是 React Native 始終不考慮 Canvas 支援的主要原因之一,在其換用無 JIT 的 Hermes 引擎後更是如此。

但是,直譯器的效能是足夠支撐 DOM 式的 API 的。為此我們直接借用了 Flutter Engine 中的部分原始碼,不再將 drawImage 這種繪製 API 開放到 JS 層,改為用 C++ Layer 來建模編輯器中的各類元素物件。也可以認為,這是將命令模式 GUI 封裝為了保留模式 GUI。每種 Layer 都具備自己的 paint 方法,每幀更新時,只需遞迴遍歷 Layer 執行其 paint 方法即可:

layer-tree.png

這種 API 設計,使我們較為容易地實現了渲染執行緒拆分改造。執行互動邏輯的 QuickJS 執行緒和執行渲染的 Skia 執行緒獨立運作,QuickJS 每次事件回撥中提交的更新不再需要被全部繪製,而是隻在渲染執行緒空閒時繪製最新的任務,同時清空任務佇列,從而實現避免卡頓的跳幀能力。可以認為這屬於經典生產者 - 消費者模式的變體,如下所示:

image.png

最終的 JS 版本架構可以分三層概括如下:

  • 基礎的畫布繪製能力依賴 Skia。我們參考了 Flutter Engine 原始碼中的 Layer 結構,封裝出可樹形巢狀的 Layer 類。由於 Flutter 的文字排版實現不符合我們的需求(如缺少豎排,具體可參見 My first disappointment with Flutter 這篇文章),我們還單獨維護了基於 Harfbuzz 和 ICU 的 C++ 文字排版庫。
  • Layer 化後的繪製能力,繫結到了 QuickJS 引擎上。在此基礎上,我們用 TypeScript 實現了處理編輯器畫布內互動的框架,其中包含點選檢測、手勢等能力,基於它承載更上層的業務邏輯。
  • 畫布外的常規 UI 控制元件使用平臺原生,如各種滑桿、按鈕、皮膚等。

從 QuickJS 到 Dart VM 的探索

雖然上述架構成功支援了業務的初期落地,但它在此過程中也暴露出了一些問題,主要有這麼幾點:

  • 畫布和平臺 UI 皮膚的業務邏輯分屬兩套 View 環境,二者需通過較低效的 RPC 式 Bridge 通訊,它們在兩套環境的 UI 同時更新(如皮膚展開收起)時容易出現動畫狀態不同步,其聯調較為不便。
  • QuickJS 引擎周邊配套不完善,缺少偵錯程式和 Hot Reload。前者屬於引擎暫缺的能力,後者雖理論上可基於網路協議自行實現,但也需要較多基礎性工作。另外 QuickJS 引擎效能雖然在無 JIT 的 JS 引擎中屬於前列,但相對於支援 AOT 的靜態語言 VM 仍然較為平庸。
  • 外圍皮膚等控制元件 UI 無法跨平臺,業務層的開發技術棧仍然是分歧的。

為此我們需要繼續探索解決方案,比如換 Flutter 重寫(不是)。

我們首先想到的一條折中路線,是單獨抽離 Dart VM,在現有程式碼庫中替代 QuickJS,屬於對 VM 的嵌入式整合(embedding)。基於一些工程實驗,我們確實搭建出了這一方案的 MVP 原型,具體可參見筆者「自己動手嵌入 Dart VM」這篇專欄。

然而,如果單純將 QuickJS 換成 Dart VM,並不能解決業務層開發技術棧分歧的問題。而如果引入 Flutter 的 Widget 體系來實現跨平臺 UI,這時由於 Flutter 中的 Dart VM 沒有對外開放(符號被隱藏),又會存在兩份 Dart VM,影響效能和體積。並且,Dart 和 Flutter Engine 存在相當深度的繫結,這種繫結甚至已經深到了「不依賴 Flutter Engine 就無法編譯出 Dart VM 的 iOS 和安卓版」的程度。因此抽離 VM 單獨使用的工程量相當大,得不償失。

但還有另一條更徹底的路線,那就是直接在標準 Flutter 環境中接入現有的 C++ 渲染體系,並用同一個 Dart VM 環境控制它。如果基於表層的 Flutter API,這條路線是不可行的。因為 Flutter 預設的 MethodChannel 性質屬於 RPC 非同步通訊,其延遲完全無法達到實時逐幀渲染的需求。但基於 Dart 的 FFI 能力,這一路線最終被證明是可行的,也是我們現在使用的方案。

Dart VM 遷移實踐經驗

FFI(Foreign Function Interface)意為外部函式介面,它允許我們在一門語言中呼叫另一門語言中的函式。Dart FFI 為我們提供了直通原生動態庫函式符號的能力,可以極大優化呼叫原生 API 時的效能。它此前長期處於 beta 狀態,並在前不久正式隨 Flutter 2.0 進入穩定。如果基於該能力來複用 Flutter 中的 Dart VM,那麼就可以獲得相當簡單而統一的應用層技術棧:

  • 畫布中的內容用 Skia 自行渲染,幷包裝成 Dart 中的 Layer 類來使用。
  • 皮膚、按鈕等 UI 控制元件,直接用標準的 Flutter Widget 渲染。

上述兩者都可以在同一個 Dart Isolate 中完成,從而也省下了 Bridge 通訊的開銷。為此有這麼兩項主要的工作需要完成:

  • 將 Skia 改為離屏繪製,渲染到 TextureWidget 而非直接上屏。
  • 將 C++ Layer 的繫結從 QuickJS 切換到 Dart VM。

首先對於 Skia 離屏上下文的建立過程,其重點可概述如下:

  • Skia 支援 CPU 和 GPU 兩種渲染後端。在使用 CPU 渲染後端(Raster Backend)時,可以直接建立 SkSurface 物件使用。而其 GPU 後端涉及子系統 Garnesh,它抹平了不同 GPU 後端的 API 差異。這時需要先建立 Garnesh 例項,再用其建立 SkSurface。具體可參見 SkCanvas Creation 文件。
  • 建立帶 GPU 加速的 SkSurface 時,既需要 Garnesh 的 GrContext 例項,也需要 GrBackendRenderTarget 作為繪製的輸出目標。這個目標在 OpenGL 體系中,可以用 FBO 的 ID 來指定。iOS 上這個 ID 值可以手動建立,安卓上如果使用 GLSurfaceView,那麼使用 0 作為 ID 即可。
  • 需要在對 GL 上下文 makeCurrent 之後,才能開始 Skia 的 GPU 渲染端初始化。

總之,Skia 的離屏渲染雖然有跨平臺一致的使用層 API,但其上下文建立過程是平臺獨立的。這具體還可參考 Flutter Engine 中的原始碼,在此不再贅述。

在具備支援離屏繪製的 Skia 例項後,就可以用 C++ 的 Layer 來繪製它,進而為 Layer 繫結 Dart 物件了。這裡實現 Dart 繫結的核心能力,是 Dart FFI 中的 GC Finalizer。它允許為 Dart 物件外掛一個由 void* 指標指向的任意 C++ 物件,並在 Dart 物件被 GC 時,執行用於銷燬(析構)該 C++ 物件的回撥函式(Finalizer)。其簡單示例如下所示:

// 在 Dart 物件被 GC 時執行的回撥,可在此銷燬附帶的 C++ 物件
static void RunFinalizer(void* isolate_callback_data,
                         Dart_WeakPersistentHandle handle,
                         void* peer) {
    // 將 void* 指標強轉為我們需要的型別,然後釋放它
    auto foo = reinterpret_cast<Foo*>(peer);
    delete foo;
}

// 每個 Dart 物件會被表示為一個 handle,在此為其繫結 C++ 物件
DART_EXPORT void PassObjectToCUseDynamicLinking(Dart_Handle h) {
  // 在堆上 new 出 C++ 物件
  auto foo = new Foo();
  // 指定其體積以便垃圾回收器參考,可後續更新該體積
  intptr_t size = 2 * 1024 * 1024;
  // 用原始 handle 建立可持久存在的 weak persistent handle
  // 並關聯上析構回撥
  Dart_NewWeakPersistentHandle_DL(h, foo, size, RunFinalizer);
}
複製程式碼

上面的 C++ 可以按這種方式在 Dart 中使用:

// 根據平臺載入動態庫
final DynamicLibrary nativeLib = Platform.isAndroid
    ? DynamicLibrary.open('libdemo.so')
    : DynamicLibrary.process();

// 在動態庫中查詢原始函式符號
// 這裡的 void Function(Object) 是該函式從 Dart 側所見的型別
// Void Function(Handle, Pointer<Void>) 是為 FFI 庫宣告的型別
// FFI 側的 Handle 型別對應 Dart 側的 Object 型別
final void Function(Object) _passObjectToC = nativeLib
    ?.lookup<NativeFunction<Void Function(Handle, Pointer<Void>)>>(
        'PassObjectToCUseDynamicLinking')
    ?.asFunction();

// 對所有需繫結 C++ 物件的 Dart 物件,該基類可供其繼承
class BaseObject {
  BaseObject() {
    // 將 C++ 物件隱式繫結到 Dart 物件例項上
    // 從而該 Dart 物件銷燬時,也會銷燬 C++ 物件
    _passObjectToC(this);
  }
}
複製程式碼

通過這種形式,就可以形成 Dart 物件到 C++ 物件的一對一繫結了。但是,業務中還有可能需要動態獲取到這個 C++ 物件。比如在 C++ 中,經常需要將繫結在 Dart Layer 物件上的 C++ 物件拿來 walk 遍歷繪製。這時候 void* 指標並不能直接可見,需要在 Dart 物件上顯式新增一個指向 C++ 物件的屬性,其用 Dart FFI 定義出的型別為 Pointer<Void>。這個型別對應於 void*,就像 Dart 中的 Pointer<Int> 對應於 int* 一樣。它在 Dart 中不能做任何修改,只能用 C++ 建立並返回。因此我們在實際業務中的方案是這樣的:

  • 在 Dart 的 BaseObject 上,新增一個名為 ptrPointer<Void> 型別屬性。
  • BaseObject 的構造器中,先通過 FFI 呼叫一個返回 Pointer<Void> 型別指標的 C++ 函式,賦值給 ptr 屬性。
  • 獲得 ptr 屬性後,將這個 ptrthis(handle 型別)一起傳入上面的 _passObjectToC,並讓其中建立的 C++ 物件持有該 handle。
  • 後續需要訪問 Dart 物件上繫結的 C++ 物件時,從 Dart 側傳入該 ptr 並強轉型別即可。

Dart FFI 中 Pointer<Void> 型別和 C++ void* 型別的這種一對一對映關係,可以非常有效地幫助我們理解指標。在筆者「寫給前端的手動記憶體管理基礎入門(一)」中,也重度應用了這種從型別出發的視角,來幫助前端同學理解原生語言。如果你對 C 系語言還不熟悉,這裡推薦一讀。

以上程式碼示例中還有一個值得注意的地方,那就是名為 Dart_NewWeakPersistentHandle_DL 的函式。這是 Dart VM 特別開放的 DL(動態連結)API,只需引入標頭檔案即可使用,無需顯式依賴 Dart VM。這類 API 具有 _DL 字尾,可以用來在 C++ 中將普通的 Dart_Handle 轉換為具備長生命週期的 Dart_PersistentHandleDart_WeakPersistentHandleDart_FinalizableHandle。具體可參見 dart_api_dl.h

在完成 Dart 物件與 C++ 物件的互通後,還需要實現一些常見的平臺 API。這部分內容和 QuickJS 等其他引擎很接近,其實也沒有什麼別的,大概三件事:

  1. 在 Dart 側同步呼叫 C++ 函式
  2. 在 C++ 側同步呼叫 Dart 函式
  3. 在 C++ 側非同步呼叫 Dart 函式

為什麼沒有 Dart 到 C++ 的非同步呼叫呢?因為這可以通過 1 和 3 的組合來解決,亦即先進行一次 Dart 到 C++ 的同步呼叫,然後 C++ 非同步呼叫回 Dart。對於 3 的非同步呼叫,需要使用 Port 機制進行非同步通訊。通過建立 Dart_CObject 的方式,可以從任意執行緒向 Dart Isolate 傳送訊息。其具體示例可參見 GitHub Issue 討論。

對於 Dart FFI 的接入應用,這裡列出一些令人印象較為深刻的注意事項:

  • 如果想在 C++ 側同步呼叫 Dart 函式,我們的方式是先建立一個用於「接收 Dart 回撥函式」的 C++ 函式,然後在 Dart 側將回撥傳入。這樣需要寫出的 Dart FFI 型別會很複雜,可以用 typedef 緩解。
  • 對於一組各不相同的 Dart 物件,其對應的 Dart_Handle 可能在連續傳遞給 C++ 接收時存在重複,需要將它們轉為 Dart_WeakPersistentHandle
  • 非同步情況下,哪怕能夠在 C++ 側拿到 Dart 函式對應的函式指標,也不能直接呼叫(像 QuickJS 那樣執行 JS_Call),否則應用會立刻崩潰。這裡必須使用 Port。
  • 如果在 Flutter 中進行多次路由跳轉,可能會使單個 Dart Isolate 中共存多個不同頁面中的 TextureWidget 例項。這時需要為 Dart 中的 Layer 物件關聯到不同的 textureId,使其能各自渲染到正確的 Skia 例項中。

在完成 Dart FFI 的改造後,還有一項工作是重寫已有的 TS 框架到 Dart。這主要是件體力活,只需按照原有程式碼的字面意義,將 TS 中的邏輯搬運到 Dart 中即可。由於 Dart 不支援 JSON 式的物件字面量語法,因此對於一些形如 {a:{b:{c:1}}} 這樣存在巢狀的狀態結構,需要將它們逐層拆分為 class,這一點較為繁瑣。另外 Dart 的 intdouble 區分較嚴格,JSON 轉換時應注意相應的型別。除此之外,這部分改造並沒有遇到太多值得一提的麻煩。

覆盤總結

完成這項遷移後,最後還有一條靈魂的拷問,那就是這樣開發技術棧的搭建和切換,是否有「勞民傷財」的折騰之嫌呢?

首先需要明確的是,我們確實需要自己控制 Skia,因為 Flutter 預設缺乏豎排等一些必要的排版能力。如果沒有對特殊渲染能力的需求,直接使用 Flutter 自帶的 Widget 與 Canvas 是最方便的選擇。但只要走通了 Dart FFI,不論是特殊的豎排文字還是更底層的 GL 操作,這些依賴 C++ 庫的能力,原理上都已經可以無縫地接入 Dart 了。伴隨著 Flutter 2.0 中 Dart FFI 的穩定,我們應當有望見到更多這類「深度嵌入」的混合渲染技術棧。

另外整套方案中,Dart VM 關鍵的 GC Finalizer 能力,在我們選擇 QuickJS 的時間點還沒有推出。並且 QuickJS 的 API 非常友好易懂,它的整合為我們培養了從 0 到 1 的入門經驗,在專案早期發揮了很大作用。回頭看來,這仍然是一條選擇從頭自研時的必經之路。如果把 Dart VM 比喻成我們吃飽的第四個包子,那麼 QuickJS 就是前三個——沒有辦法只靠吃最後一個就吃飽。但一旦發現更優的路線,個人仍然認為應當(在有條件的前提下)做到儘早切換,避免因技術債而積重難返

最後在開發成本方面,從最早引入 QuickJS 到現在接入 Dart VM,從 C++ 渲染層到 TS 和 Dart 的編輯器框架,我們對整套基礎設施的搭建實際上只有兩個人全職投入,再加上一位幫助實現業務層需求的校招同學就足夠了。這並不需要大型的 infra 團隊,最後搭建出的方案也仍然處於對 Flutter 無侵入性的輕量級。對於有同類場景的中小團隊,個人認為本文分享的這套實踐應當是務實且具備參考價值的

在未來,我們希望使原有的 TS 程式碼庫繼續在服務端發揮價值。為此賦能的重點之一是筆者正在與 @太狼 合作開發的 @napi-rs/canvas 庫。這是一個用 Rust 將 Skia 實現為 Node 擴充套件的服務端 Canvas 實現,大家不妨期待其後續的進展與分享。至於本文所介紹的框架本身則尚處於內部演化中,暫時尚不開源。另外特別感謝同為國人研發的 Dart Native 專案,它在我們遇到 FFI 問題時提供了重要的幫助。

本文不限制轉載,歡迎交流探討。

相關文章