在稿定科技,我們使用 QuickJS 與 Skia 搭建並落地了自研的 App 端編輯器渲染能力。去年北京的 QCon+ 上,筆者為此做了「基於 QuickJS + Skia 的 GUI 框架」分享。下面是一些基於該能力渲染的實際應用截圖:
但在短短几個月後,我們就再次升級了這項 QuickJS + Skia 的工程設計,將 Skia 的渲染能力切換到與 Flutter 中的 Dart VM 相整合。本文會介紹這背後的技術演進,共有這麼幾個部分:
- QuickJS 方案演化歷程
- 從 QuickJS 到 Dart VM 的探索
- Dart VM 遷移實踐經驗
- 覆盤總結
QuickJS 方案演化歷程
稿定的跨端工程最早始於筆者一項出於業餘興趣的個人實驗,即嘗試用 QuickJS 結合 libuv 來接入平臺 IO 能力,並在此基礎上繫結 Skia 來實現 Canvas 渲染。這相當於實現了一套 HTML5 Canvas 標準的子集,效果如下:
我們在這一設計的基礎上搭建了編輯器的原型,但並未最終落地。其問題主要在於效能,具體可參見這張圖:
上圖顯示了在將 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
方法即可:
這種 API 設計,使我們較為容易地實現了渲染執行緒拆分改造。執行互動邏輯的 QuickJS 執行緒和執行渲染的 Skia 執行緒獨立運作,QuickJS 每次事件回撥中提交的更新不再需要被全部繪製,而是隻在渲染執行緒空閒時繪製最新的任務,同時清空任務佇列,從而實現避免卡頓的跳幀能力。可以認為這屬於經典生產者 - 消費者模式的變體,如下所示:
最終的 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
上,新增一個名為ptr
的Pointer<Void>
型別屬性。 - 在
BaseObject
的構造器中,先通過 FFI 呼叫一個返回Pointer<Void>
型別指標的 C++ 函式,賦值給ptr
屬性。 - 獲得
ptr
屬性後,將這個ptr
和this
(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_PersistentHandle
、Dart_WeakPersistentHandle
和 Dart_FinalizableHandle
。具體可參見 dart_api_dl.h。
在完成 Dart 物件與 C++ 物件的互通後,還需要實現一些常見的平臺 API。這部分內容和 QuickJS 等其他引擎很接近,其實也沒有什麼別的,大概三件事:
- 在 Dart 側同步呼叫 C++ 函式
- 在 C++ 側同步呼叫 Dart 函式
- 在 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 的 int
和 double
區分較嚴格,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 問題時提供了重要的幫助。
本文不限制轉載,歡迎交流探討。