深入解析基於 Flutter 的 Web 渲染引擎「北海 Kraken 」技術原理

染陌同學發表於2021-08-20

寫在前面

大家好,我是染陌,這是我在 全球開源技術峰會 GOTC 上的一個 topic ——《基於 Flutter 的 Web 渲染引擎「北海 Kraken」》。我主要從技術角度來分享 Kraken 的一些實現原理以及關鍵的技術特性,現在整理成文字版分享給大家。

Kraken Github:github.com/openkraken/…

Kraken 官網:openkraken.com/

北海的技術背景

說到北海的技術背景就不得不提及跨端技術的演進,很多同學應該都比較熟悉跨端技術的歷程了,我還是簡單講一些。

我們知道,瀏覽器是最成熟的天然跨平臺方案。早在 PC 時代,瀏覽器已經成為了網際網路的入口,大家都會習慣性通過瀏覽器來進行網頁的瀏覽以汲取各種資訊,當時我們把這種上網的方式叫做“衝浪”。然而到了移動時代,瀏覽器在移動裝置上並沒有一個搶眼的表現,反之因為記憶體大、弱網環境白屏久、感測器能力缺失(標準跟進慢)等問題使各種質疑不絕。

為了彌補上述瀏覽器在移動端的一些不足,出現了 Hybrid 技術,在 Web 之上通過容器的能力實現一些非標準化的超集,同時也通過 prefetch、離線包等各種技術來提升首屏的載入效能。

此後,出現了類 RN 的方案(典型代表 React Native),它的原理是通過 JS engine 將 Native 控制元件與前端生態實現一個橋接,通過 Web 開發業務邏輯提升效率,而向下通過 Native 控制元件渲染來提升效能及體驗。但是這類方案的缺點是無法完全抹平兩端的差異,沒有解決一致性的問題,而最終將複雜度暴露給了開發者。

Flutter 作為跨端屆的新寵,這兩年也獲得了越來越多的關注,下面介紹一下 Flutter。

Flutter 的優點是效能好、由於其通過自繪渲染使得跨端一致性高,但是它也有它自身的缺點,比如生態自成一派,既不是前端也不是 Android/iOS。

這就是引出了一系列的問題。

  • 首先,前端(JavaScript)或客戶端(Swift / JAVA)轉型都有一定成本,但是由於端側的 GUI 體系大同小異。筆者站在一個前端開發者的視角去看語言上的學習成本並不會特別高,有 React 或者 Vue 等前端框架經驗的同學可以通過簡單的學習快速上手。對於一些小型的創業團隊,確實可以小步快跑快速學習上手並開發,但當組織龐大到一定程度,這個轉換的成本將會指數級上升。
  • 其次,生態圈等待重新建設,一些 Flutter 開發者朋友或許覺得目前 Flutter 開發已經有挺多的 pub 可以直接使用了。但實際上生態圈不止於 Flutter pub,還有各種已有的基礎鏈路,比如建設相關的 CI/CD,再比如搭建等等。這一系列的生態都需要重新建設,成本是非常大的。
  • 再次,已有的非常多業務都是通過 JavaScript + 前端框架開發的前端專案,我們如果想把它們遷移成 Dart + Widget 成本無疑是非常龐大的。

在面對如此多的問題以及切換的高成本的同時,我們也期望通過 Flutter 給我們的業務帶來更多的技術的可能性,同時改善 Web 容器在端上的一些效能及體驗問題。那麼,引入一項新技術的第一步是解決引入這項新技術的成本問題,所以我們積極探索一種將前端生態與 Flutter 結合起來的方案。

於是產生了本次 topic 的主角——北海 Kraken。

Kraken 是一款高效能 Web 標準的自繪渲染引擎,具有高效能、易擴充套件、基於 Flutter 以及 遵守 Web 標準的特點。

下面我列舉了一些北海在阿里的一些應用場景,在 C 端 APP 或者 IoT 裝置上,北海都有相關的落地。

北海的技術原理

在介紹 Kraken 的技術原理之前,我先演示一下如何開發一個 Kraken 應用。因為 Kraken 是基於 W3C 標準來開發的 Web 渲染引擎,所以上層是框架無關的,無論開發者使用的是 Vue 或者 React 還是 Rax 都可以在 Kraken 上進行一個應用開發。

以 Vue.js 開發為例,下面是我用 Vue 官方提供的 vue-cli 起的一個專案。具體的程式碼見官方示例

可以看到的是,最左邊是 Vue 的相關程式碼,右邊分別是該應用在 Chrome(左)上跑的結果以及在 Kraken(右)上跑的結果,大家可以看到結果是完全一致的。

瞭解瞭如何開發一個 Kraken 應用 ,我們再來理解一下 Kraken 的技術原理。為了大家更好地理解,首先我們來比較一下 Flutter 於 Webview 的渲染流程。

WebView 的渲染流程相信大家非常熟悉了,面試中非常經典的題目就是一個 URL 輸入如何最終渲染到螢幕上了。總的來說就是解析 HTML、JS 以及 CSS 檔案,執行相應 JS 呼叫 DOM API,最終會生成 DOM Tree 以及 CSSOM Tree,然後會計算最終得到 Render Tree,經過 Layout 以及 Paint 流程生成一系列的 Layer,最終通過合成以及光柵化渲染到螢幕上。

再看 Flutter 這邊,Flutter 經典的三棵樹——Widget Tree、Element Tree 以及 RenderObject Tree。Widget Tree 對應到前端類似於前端框架這層,而 Element 與 DOM Tree,RenderObject Tree 與 Render Tree 分別對應,最終也會通過 Layout 以及 Paint 一系列計算生成 Layer,然後通過合成以及光柵化渲染到螢幕上。

那麼,我們再將前端框架加入到我們整個流程中進行一個更加直觀的對比,這裡還是以 Vue.js 為例。

Vue.js 會在執行時生成一系列 Vdom 產生 Vdom Tree,再通過 platfom 的抽象呼叫具體平臺的 API。

那麼我們就會發現,只需要把我用紅框圈出來的部分的流程進行互換,就可以實現我們最終想要實現的效果(上層 Web 開發,下層基於 Flutter 進行渲染)。

基於以上設想,那麼北海的渲染流程就出來了。

目前主流的前端框架都會將產物打成一個 JS Bundle,通過標準的 DOM API 去操作具體的檢視,而 HTML 內一般只有一個根結點。在 Web 下,頁面會先請求 HTML 檔案,再解析 Script 標籤去載入對應的 JS 檔案。而 Kraken 的入口設計成了一個 JS 檔案,這樣做可以減少一次請求,加快首屏的渲染。

該 JS 檔案會在 JS Engine 中執行,Kraken 的 runtime 通過 JS Engine Binding 的方式提供了一系列 Web 標準的 API 介面,呼叫相應 API 會執行相關邏輯並建立一系列需要傳送給 Dart 層處理的指令,指令通過一個 struct 進行儲存。C++ 通過 FFI 將相應的指令底層的 address 傳送到 Dart 這邊,Dart 處理相關指令並生成 Dom Tree。同樣的,CSS 也會通過 Parser 生成對應的 CSSOM Tree,最終會結合生成 Flutter 的 RenderObject Tree,經過 Layout 以及 Paint 的一系列計算,生成對應的一系列 Layter,然後通過合成光柵化最終上屏顯示。

同樣的,在最新的實現中,我們考慮到了 SSR 應用的場景,所以加入了 HTML 為入口的北海應用開發方式,通過 HTML Parser 即可解析對應的 HTML 檔案,後續流程是一樣的。SSR 的支援也讓首屏的秒開率更上一層樓。

那麼瞭解了 Kraken 的整個渲染流程,那麼我們如何基於 Flutter 去完成 Web 標準的渲染引擎的開發呢?

那麼要基於 Flutter 去做這個事情,就必須先了解 Flutter 的架構。

Flutter 最上層是 Dart 實現的 Framework,包含了響應式框架、官網元件庫以及實現佈局與繪製協議的部分。中間是 C++ 實現的 Flutter Engine,他是渲染流程的下半部分,提供了一些基礎能力,以及將 layer 合成以及光柵化後輸出。最下層的 Embedder 層,則負責具體 platform 的一些實現,以實現跨平臺。

不難發現,最 Dart Framework 的 Widget 是對 UI 的抽象,實現了一套響應式框架,對應到前端就是 Vue / React 等前端框架。而下方的佈局協議,可以對應 W3C 的標準來實現一套基於前端標準的佈局與繪製協議。

那麼我們就可以得出北海的架構設計。

先看左邊,左邊還是上面介紹的 Flutter 的整體架構,Flutter 的 Widget 能力可以通過外掛的形式註冊到 Kraken 中去,成為一個前端標準的 Tag,JS 可以動態化地呼叫及控制渲染。整個左側的 Flutter 架構支撐了上層的 Flutter 生態,使 Flutter 生態也可以通過外掛的形式融合到整個 Kraken 的渲染體系中去。

右邊是 Kraken 的架構實現,Kraken 的實現並沒有把實現侵入到 Flutter Engine 中去。在 Dart 層,通過實現 W3C 標準的一系列佈局與渲染能力,為上層提供了一些列標準化的能力,比如 Element、CSS、以及各種 Web 標準的 Module 等。在上層 Kraken 的 runtime 通過 JS Engine Binding 的方式提供了一系列 Web 標準的 API 介面,呼叫相應 API 會執行相關邏輯並建立一系列需要傳送給 Dart 層處理的指令,指令通過一個 struct 進行儲存。C++ 通過 FFI 將相應的指令底層的 address 傳送到 Dart 這邊,最終 Dart 根據指令呼叫前面說的標準化能力,以完成對接。通過該實現,為上層的前端生態提供了支撐,憑藉豐富的前端生態,開發者可以享受前端生態帶來的高效的開發體驗。

關鍵技術特性

首屏的載入效能是一個 C 端場景的關鍵指標,長時間的白屏會極大地影響使用者體驗。

Kraken 在 首屏初始化時需要建立大量的節點,大量的時間耗費在通訊上,所以優化首屏效能迫在眉睫。

在上面技術原理部分我們知道,Kraken 需要通過 Bridge 來完成 C++(JS Engine) 與 Dart 之間的通訊,以達到將指令傳遞到 Dart 層的目的,Bridge 的架構也進行了三個版本的演進。

最初的第一代方案,我們侵入了 Flutter Engine,使資料從 JS Engine 傳遞到 Flutter Engine 中,然後通過 native bingding 最終將資料傳送給 Dart 層。這一代的方案非常明顯的缺點是侵入了 Flutter Engine,開發時需要編譯 Flutter Engine 需要耗費大量的時間。同時,對於 Kraken 的架構來說,侵入 Flutter Engine 也並不是一個合理的設計。

後來出現了 Dart FFI,可以實現 C++ 與 Dart 之間的高效通訊,所以產生了第二代方案。第二代 Bridge 方案通過將 JSON 資料序列化後,通過 Dart FFI 將資料傳遞到 Dart 層,Dart 層再通過 JSON 的反序列化以拿到最終的資料。這代方案比起上一代方案可以解決侵入 Flutter Engine 的缺點,但是引入了字串的拷貝以及 JSON 序列化反序列化的時間長的問題。

為了解決上述問題,於是產生了第三代 Bridge 方案。第三代 Bridge 方案通過共享記憶體的方式定義了一個標準的 40 Bytes 的 Struct 來儲存指令,而通過 Dart FFI 傳遞的只是指令的地址,C++ 跟 Dart 兩邊都依賴地址來訪問相關資料。這樣做解決了 JSON 序列化反序列化的問題,節約了時間,並且少一次資料拷貝。同時,由於記憶體是 40 Bytes 對齊的,可以提高記憶體的訪問效率。

下面是一些實際線上頁面帶來的首屏收益。

無限滾動的長列表是困擾前端開發者很久的歷史性問題了,大量的 layout 導致頁面卡頓,以及滾動時 Paint 的時間長導致滾動掉幀,頁面的體驗非常糟糕。社群也有非常多的前端的解決方案來處理該問題,而在 Kraken 上,我們也期望在容器層解決該問題。

在 Android 跟 iOS 上也分別有 RecyclerView 以及 TableView 來解決該問題,他們的原理分別是在可視區域 viewport 外定義一塊緩衝區域,當節點超過該區域時進行動態釋放,進入該區域時動態建立,以及通過一系列節點進行屬性替換的方式來保證節點數不爆炸。Flutter 中也提供了類似實現 Sliver,那麼我們能否用 Sliver 賦能前端解決該問題呢?

Kraken 定義了一個新的 display 屬性 sliver,通過將節點的 display 屬性設定為 sliver,則可以直接使用 Flutter 的 Sliver 能力,以達到節點超出可視及快取區域後動態回收的一個能力。可以看到我們使用 1000 個卡片的 DEMO 進行測試,sliver 下比起 block 有明顯的收益。

同時,該標準也已經在 W3C 中文興趣小組 進行了討論,期望在大家討論充分以及達成共識後,嘗試將此提案向 W3C 進行提交,反哺前端社群。

一個大前端團隊往往既有客戶端也有前端,會沉澱一系列的端上的能力。不同的需求會有不一樣的技術選型,譬如說一個播放器往往是通過 Native 技術去開發的。我們期望將端上的能力(包括 Flutter Widget、Web、Native 以及三方 SDK 等)進行整合,融合成一個大前端的端開發體系,所以在 Kraken 內我們如何整合端上的這一系列能力呢?同時,我們也期望按需引入,能做到包體積的優化。在不同的業務域,我們期望可以快速地進行定製化開發,快速形成一套垂直業務域的領域能力。

Kraken 提供了一套擴充套件能力來解決上述問題,通過渲染能力擴充套件介面,開發者可以將開發完成的符合標準的 Flutter Widget 以及 Native 的渲染能力快速整合到 Kraken 體系中去,最終通過 JavaScript 來提供一個動態化呼叫的能力。同樣的,通過 MethodChannel,開發者可以通過該通道呼叫一些 Native 或者 Dart API 的能力,譬如說一些二方或者三方的 SDK 能力。

開發者可以通過擴充套件能力自定義業務域需要的能力,按需拔插以達到包體積優化的目的。同樣的,註冊到 kraken 的外掛都可以通過 JavaScript 程式碼控制,提供了動態性。

下面是一系列在 Kraken 內部擴充套件 Flutter Widget、Native API 以及 Native 播放器的 Demo。

下面是提升可互動性。在介紹 Kraken 的可互動性之前,我們先來看一下在 Web 下的一些互動問題。

在 Web 下開發富互動能力的應用時,前端開發者往往需要引入一個額外的 lib 來提供增強的手勢能力(譬如說 Hammer.js 這樣的手勢庫)。那麼當前端開發者引入 lib 時,就會導致載入 index.html 以後,還需要額外的請求對應的 JS 庫,造成一次額外的請求開銷的同時延長了首屏的可互動時間。

當使用者在螢幕上進行某個操作時,由於使用者操作的方式可能是使用者的手,也可能是 Apple Pencil 或者滑鼠這樣的裝置。所以在 W3C 標準中,將使用者操作可互動應用的觸點抽象為一個 pointer,這些 pointer 會根據操作形成一個手勢,分別是 down、move、up 三個過程,其中 move 可省略(譬如說 click)。

在 Web 中,需要將這一系列 pointer 給 dispatch 到 element tree 上,通過冒泡將這些 pointer 頻繁地傳送到 JS 層,然後 JS 再通過封裝 Touch API 來完成對互動的識別。這樣做帶來幾個問題,首先頻繁地將 pointer 從 C++ 傳遞到 JS 帶來了不必要的開銷,此外封裝標準的能力也會造成額外的開發成本,易用性並不突出。此時,如果使用社群的一些方案,也會導致非標準化使標準不對齊導致同個應用中的不同頁面有不一致的互動體驗。

為了解決上述問題,我們期望從標準化、易用性、標準化幾個方面提供一套標準化的互動能力。通過封裝底層的 pointer 來得到不同的手勢能力,使開發者可以快速開發富互動的應用。

下面是 Kraken 中增強互動能力的流程圖。當使用者進行某些互動操作以後,每一個觸點的 pointer 會從 Native 傳遞到 Kraken 中,Pointer 會同時分發給 GestureManager(手勢識別器管理類)以及 Scroll 識別器。GestureManager 會識別開發者通過 Web 標準的監聽行為(EventTarget.addEventListener)來註冊以及分發給對應的手勢識別器,同樣 Scroll 識別器也會被分發 pointer。這些識別器被加入到 Flutter 的競爭場進行手勢競爭,以保證只觸發某一個具體操作(互動可控)。Scroll 識別器會觸發滾動區域的滾動操作,手勢識別器則會通過標準的 Web 流程進行冒泡以及 dispatch,最終開發者通過監聽事件完成自定義行為。

開發應用時,除錯能力是必不可少的,前端開發效率高不止要歸功於繁榮的生態,友好的開發除錯體驗一樣是提升效率的神器。

Kraken 抽象了 Inspector 以通過 Chrome DevTools Protocol 來對接 Chrome DevTools,提供了一系列跟前端開發 Web 應用完全一致的除錯體驗,無論開發者喜歡使用 Console.log 還是通過 JS Debugger ,都可以快速上手。

此外,Kraken 也通過支援 HMR 的所有標準的 Web API,來提供區域性熱更新的能力,使開發 Kraken 應用能跟 Web 下一致的區域性熱更新的除錯體驗,大大提升了開發者的開發除錯體驗。

最後,Kraken 的所有程式碼都已經開源,Kraken 提供了開放的 TSC 機制期望所有開發者可以平等地交流以及決策,使 Kraken 可以更好地發展,也歡迎更多的開發者一起來共建 Kraken。

Kraken Github:github.com/openkraken/…

Kraken 官網:openkraken.com/

相關文章