天天看點

記一次完整 C++ 項目編譯成 WebAssembly 的實踐項目背景需求分析技術方案功能的實作▐ 對接效果對 WebAssembly 的期待寫在最後

作者| 張翰(門柳)

出品|阿裡巴巴新零售淘系技術部

本文知識點提煉:

1、把複雜的 C++ 架構編譯成 WebAssembly。

2、在 wasm 子產品裡調用 DOM API !

3、在 js 和 wasm 之間傳遞複雜資料結構。

4、對 WebAssembly 技術發展的期待。

上一篇文章《基礎為零?如何将 C++ 編譯成 WebAssembly》裡介紹了怎麼把簡單的 C++ demo 編譯成 WebAssembly,但這是遠遠不夠的。正好手頭在寫一個 C++ 的項目,功能獨立完整也足夠複雜(有 2W+ 行代碼),就順便編譯成了 WebAssembly,未必是一個合适的使用場景,主要是為了學習這項技術,親身體驗一下過程中遇到的問題。

項目背景

我現在在用 C++ 寫一個原生的響應式架構,定位和前端架構差不多,但是用 C++ 來實作,可以有更好的性能,也友善對接各種原生渲染引擎和各種語言。上層開發者仍然以 JS 為開發語言,API 和 Web Component 相似,而且可以像小程式和 Vue.js 那樣用模闆+資料(聲明式+響應式)的方式開發原生 UI,架構本身就不介紹了,本文的重點是涉及 WebAssembly 的部分。

▐ 為什麼要編譯成 WebAssembly ?

架構是 C++ 寫的,本身的設計是源碼內建進各種渲染容器的,跟随原生 SDK 發版,即使是對接浏覽器,也是和浏覽器核心代碼(如 UC 的 U4 核心)打包在一起,但這樣就無法運作在獨立的浏覽器上,功能無法降級。如果說把 C++ 代碼編譯成 WebAssembly 的話,那架構就可以從遠端加載了再運作,相當于 C++ 的架構也有了動态化的能力。

簡單來講,現在這個 C++ 架構已經能運作在各種原生渲染引擎之上了,我想保持同一份 C++ 源碼,讓它能運作在幹淨的浏覽器中。

這條鍊路應該隻會用于降級的場景,重點是驗證鍊路能不能跑通,性能倒不是我最關注的問題。

需求分析

▐ 要實作的目标

首先細化一下要實作的目标,下面是一段使用響應式架構 API 開發的代碼,它可以運作在原生渲染引擎上,目标是讓這段代碼能運作在幹淨的浏覽器裡:

// 1. 自定義一個元件
class HelloWorld extends ReactiveElement { /* ... */ }
// 2. 向環境中注冊元件,給定一個名稱
customElements.define('hello-world', HelloWorld)
// 3. 定義元件的模闆
customElements.defineTemplate(HelloWorld, {
  type: 'h1',
  // 添加資料綁定,表示 h1 的 innerText 是由表達式 message 計算出來的
  innerText: { '@binding': '`Say: ${message}`' }
})
// 4. 建立元件,傳遞初始資料
const app = new HelloWorld({ message: 'Hi~' })
// 5. 把元件挂載到 #root
customElements.mount('#root', app)
// 6. 更新元件的資料,會自動觸發 UI 的更新
app.setState({
  message: "What's up!"
})           

這段代碼是一個完整的例子, 1, 2, 4 是 Web Component 的标準寫法(用 ReactiveElement 代替 HTMLElement),浏覽器已經支援,5 是用于挂載節點的文法糖, 3 和 6 是新增的 API,用于定義元件的模闆和傳遞資料。方案算是對 Web Component 的增強,加入了模闆和資料綁定的能力,在真實場景裡模闆不會是手寫的,而是由小程式、Vue.js 所定義的模闆文法編譯而來。

看起來用 JS 寫個 polyfill 就可以搞定。但是模闆的運算和資料綁定怎麼實作?裡面是可以包含循環、分支和表達式的,前端架構的做法是把它編譯成 js 代碼,如果寫 polyfill,很可能又寫出了一個前端架構,或者基于現有前端架構做封裝,但是這樣就和原生架構的行為不一緻了。

▐ 遇到的問題

想用響應式架構跑通上面的例子,就是要實作這麼一個調用鍊路:

demo.js <-----> [響應式架構] <-----> DOM API           

在原生架構中,ReactiveElement class 和 customElements 上的接口都是由 C++ 實作的,類似于 DOM API,有 ES6 的類,也有普通函數,接受的參數有 class(Function),String 和 Object 等各種類型。然而 wasm 目前隻可以 import 和 export C 語言函數風格的 API,而且參數隻有四種資料類型(i32, i64, f32, f64),都是數字,可以了解為赤裸裸的二進制編碼,沒法直接傳遞複雜的類型和資料結構。是以在浏覽器中這些進階類型的 API 必須靠 JS 來封裝,中間還需要一個機制實作跨語言轉換複雜的資料結構。

WebAssembly 是一種編譯目标,雖然運作時是 wasm 和 JS 之間互相調用,但是寫代碼的時候感覺到的是 C++ 和 JS 之間的互相調用。文中說的 JS 和 C++ 的調用,實際意思是 JS 和 wasm 子產品之間的調用。

另外,如果要實作在浏覽器裡渲染和更新 UI 的話,就必須要用到 DOM API,衆所周知 WebAssembly 調用不了 DOM API,也不是個靠譜的用法。原生架構提供了 C++ 的 ComponentRenderer 抽象類來對接渲染引擎,不同的渲染引擎分别實作這個類然後注入架構,不管靠譜不靠譜,在浏覽器裡也隻能靠 JS 實作這個渲染器了,封裝 DOM 操作然後把接口傳給 C++ 調用,而且也要傳遞複雜的資料結構。如果想精确更新某個特定元件節點,還得解決 C++ 元件和 JS 元件一對一 binding 的問題。

總結下來,是兩個問題:

  1. 在 C++ 和 JS 之間傳遞複雜資料結構。(資料通信)
  2. 實作 C++ 和 JS 複雜資料類型的一對一綁定。(類型綁定)

技術方案

▐ 如何編譯代碼

首先需要确定一下如何把 C++ 檔案編譯成 wasm 檔案,前一篇文章講提到兩種方式,用 Emscripten 會生成 wasm + js 兩個檔案,可以很好的跑在浏覽器和 Node.js 上;用 wasi-sdk 可以編譯出獨立的 wasm 包,但是想把它運作起來需要運作時給它注入必要的接口。

記一次完整 C++ 項目編譯成 WebAssembly 的實踐項目背景需求分析技術方案功能的實作▐ 對接效果對 WebAssembly 的期待寫在最後

我的目标是跑在浏覽器裡,是以用 Emscripten 來編譯,生成一份 “js glue” 檔案來輔助運作, 像個奧利奧,有種必須被 js 捏在手裡運作的感覺,但是在浏覽器裡不得不這樣。這很可能是浏覽器裡 WebAssembly 和 JS 需要長期保持的關系…… 有

提案

說用

具體怎麼編譯?參考 Emscripten 官方文檔配置一下編譯腳本就好了,這裡列出響應式架構的其中一部分編譯腳本,可供參考。

記一次完整 C++ 項目編譯成 WebAssembly 的實踐項目背景需求分析技術方案功能的實作▐ 對接效果對 WebAssembly 的期待寫在最後

使用 wasi-sdk 的編譯鍊路我也在嘗試,可以編譯出來獨立的 wasm 包,但是目前還沒把它運作起來,需要在運作時注入響應式架構依賴的渲染接口。

算上上面的 demo 檔案,一共有三個檔案,大概的執行順序是 1. 加載架構的 js 檔案, 2. 架構 js 檔案自動加載并編譯 wasm 檔案,3. 執行 demo 檔案。僞代碼如下:

<script src="path/to/wasm-framework.js"></script>
<script>
  initializeWasmAPIs().then(exports => {
    // wasm 初始化完成後,動态插入 demo 的腳本
    const $script = document.createElement('script')
    $script.setAttribute('src', `path/to/demo.js`)
    document.body.appendChild($script)
  })
</script>           

▐ 接口的互相調用

明确了打包生成的代碼是 js-wasm-js 這種奧利奧格式的,代入上面提到的調用鍊路裡就是這樣:

(A)        (B)        (C)        (D)
demo.js <-----> [js <---> wasm <---> js] <-----> DOM API           

看起來這個渲染鍊路是比較長的,但是中括号裡的屬于架構内部的調用,不被外界感覺的。全部展開來,可以分成 A/B/C/D 四個環節的接口調用,其中 (A) 和 (D) 都是 js 和 js 之間的調用,封裝 API 而已,沒什麼難的,關鍵要搞定 (B) 和 (C) 的調用。

再回過頭來看 wasm 在運作時的層次結果,如下圖左半部分,demo.js 屬于邏輯 JS,運作與響應式架構之上,響應式架構運作與浏覽器之上。再把每一層展開,可以看到每層之間的接口調用關系,如下圖右半部分所示:

圖中 (A) 透出的接口就是上面 demo.js 用到的接口,主要是一個 ReactiveElement 的 ES6 class 和 customElements 這個對象。 (D) 就是前端很熟悉的 DOM API,不再贅述。中間響應式架構這層把 wasm + js 兩個檔案畫成三層的奧利奧結構,是因為雖然 js 檔案隻有一個,但是從邏輯上區分它做了兩件事情,分别處理 wasm 的輸入和輸出。

前面提到過 wasm 通過 import/export 的方式實作和外部的接口調用,而且接口隻能是普通的函數而且參數隻能是數字,分别對應了圖中的 (C) 和 (B)。是以運作與 wasm 下方的 js renderer 是負責封裝 DOM API,把它轉成 wasm 聲明的 import 接口 (C),在執行個體化 wasm 包的時候把接口注入進去。

(C) 接口的詳細設計如下圖所示,以 _ui 開頭的都是響應式架構聲明的、需要在運作時注入的接口。

同理,wasm 導出的接口也是 C 語言函數風格的,想要轉成 Web Component 風格的 API 需要在 JS 層做封裝,這就是圖中 js api wrapper 這一層所做的事,拿到 wasm 導出的接口 (B) 并把它封裝成 (A)。

(B) 接口的詳細格式如下圖所示,以 _ui 開頭的都是響應式架構導出的。

在實際編譯的時候,Emscripten 提供了 --pre-js ,--post-js 和 --js-library 等方式注入 JS 代碼,具體含義參考其

官方文檔

。我在項目裡用 --post-js 打包 api.js 檔案實作接口 (B) 到 (A) 的封裝,用 --js-library 打包 renderer.js 檔案,實作 (D) 到 (C) 的封裝。

除了上面提到的接口封裝方式以外, Emscripten 還提供了 Embind 和 Web IDL Binder 的方式綁定 JS 接口,原理和上面方法相同,我覺得封裝太厚了就沒選擇使用。

▐ 搞定資料通信

接口調用搞定了,然後還需要搞定傳值的問題。wasm 導入和導出的函數參數隻能是數字,要傳遞複雜值,必須借助

WebAssembly.Memory()

實作,它是由宿主環境開辟的記憶體,交給 wasm 子產品來運作,在運作時這塊記憶體可以被 wasm 子產品和宿主環境共同管理,這是 wasm 和宿主環境實作大塊資料通信的基礎。

傳遞記憶體 buffer

如下代碼建立了一塊初始大小為 256 的記憶體(機關是“頁”,每頁64KB),并在執行個體化時傳遞給 wasm 子產品。

const wasmMemory = new WebAssembly.Memory({ initial: 256 })
WebAssembly.instantiate(wasmBinary, {
  env: {
    memory: wasmMemory
  }
})           

這塊記憶體就是 wasm 子產品運作時使用的記憶體,可以通過 wasm 指令讀取、寫入以及 grow,對應到 C++ 源碼裡就是指針和 new/malloc 之類的操作;同時這塊記憶體又是一個 js 變量,也可以在 js 裡讀寫它的值。

是以 js 和 wasm 通信的過程就是:先把想要傳遞的資料序列化成 ArrayBuffer,然後把 buffer 寫入 Memory 并且把資料的起始位置、資料大小傳給 wasm 子產品,在 wasm 中根據位置取到這塊 buffer,最後把 buffer 反序列化成自己想要的類型。僞代碼如下:

// 把想要傳遞的資料轉成 ArrayBuffer (假設是 Uint8Array)
const dataBuffer = encodeDataByJS({ /* my data */ })
// 向 wasm 申請一段記憶體,由 wasm 代碼負責實作并傳回記憶體記憶體起始位址
const offset = applyMemoryFormWasm(dataBuffer.length)
// 以 unit8 的格式操作 wasm 的記憶體 (格式應該與 dataBuffer 的格式相同)
const heapUint8 = new Uint8Array(wasmMemory.buffer, offset, dataBuffer.length)
// 把資料寫入 wasm 記憶體
heapUint8.set(dataBuffer)           

傳遞複雜資料結構

僅支援傳遞 buffer 并不能解決所有問題,總不能在寫代碼的時候都去手動操作記憶體吧,而且 JS 的資料類型和 C++ 的資料類型差别很大,傳遞數字和數組還好一些,在傳遞複雜資料結構的時候,如何正确的實作資料的序列化和反序列化?

先說一下通用的解法,這是一個跨語言資料結構轉換的問題。面對同一塊記憶體,要讓不同的語言都能按照同樣的格式(或記憶體布局)來讀取這塊記憶體,就得定義一套語言無關的二進制資料格式。這個在業界有挺多方案的,首先有種叫

BSON

的資料格式,就是二進制版本的 JSON,主要用在 MongoDB 資料庫裡。另外 Google 的

Protocol Buffers

也是做這個事情的,

自定義了一套二進制格式

,并且提供了多種語言(沒有 JS)的序列化、反序列化 API。另外 Flutter 的 MethodChannel 也是

自己設計了一套二進制編碼格式

,用于實作跨語言的接口調用。

但是上面這些方式用在 WebAssembly 裡都不太合适,或者說太重太麻煩了,現在 WebAssembly 社群在讨論的 WebAssembly Interface Types 就是要解決這個問題的,借鑒 Web IDL 的思路,有希望定義出一套适用于 wasm 的語言無關的類型标準,但是目前還有很多問題需要解決,如 anyref 和 GC objects 的支援等。

思來想去,針對我這個場景,我最終決定用 JSON 字元串實作資料的序列化,因為比較省事……

如上圖所示,二進制的 Buffer 是可以在 C++ 和 JS 共同通路的,在 JS 裡我把想要傳遞的資料都用 JSON.stringify() 轉成字元串,然後用 TextEncoder 把字元串轉成 Uint8Array,解碼的時候,先用 TextDecoder 把二進制資料轉成字元串,然後用 JSON.parse() 轉成我想要的資料結構。在 JS 裡我全都用浏覽器提供的 API 就可以實作序列化和反序列化,不需要自己寫太多代碼。在 C++ 裡,從二進制資料到 std::string 的轉換是很簡單的,直接構造和取值就行了,而且巧了,在響應式架構裡我設計了一套和 JS 類型對等的 C++ 資料結構,實作了類似 JSON.stringify() 和 JSON.parse() 接口,内部的元件模闆、資料綁定、元件狀态都可以轉換成這套資料類型。是以,對我這個架構來說,每一步的序列化反序列化接口都已經存在了,我隻要把它們串起來就行了。

使用 JSON 字元串來傳遞複雜類型僅僅适用于我這個項目,并不是個通用方案,如何高效實作 wasm 資料通信還要具體情況具體分析。

功能的實作

▐ 代碼實作

技術方案以及介紹的比較清楚了,具體代碼該怎麼寫就不說太多了,與架構内部的邏輯有關,與 WebAssembly 關系不大。下面就以 setState() 這個接口的僞代碼為例,簡單捋一下調用流程。

記一次完整 C++ 項目編譯成 WebAssembly 的實踐項目背景需求分析技術方案功能的實作▐ 對接效果對 WebAssembly 的期待寫在最後

在 demo.js 裡調用到的 ReactiveElement 接口是 js 實作的,如上圖最左側代碼, setState() 函數的實作就是把資料轉成 buffer 塞到 wasm memory 裡面,然後調用響應式架構導出的 _component_set_state() 接口設定元件狀态。中間的 wasm 代碼是由 C++ 編譯而來的。在 C++ 的實作裡,根據傳入的 buffer 位置和大小初始化成 std::string 然後把字元串解析成狀态資料,然後調用 C++ 對象的 SetState() 方法更新狀态。

▐ 對接效果

代碼編譯好之後,準備一個 HTML 頁面再啟動一個 Web 服務就可以在浏覽器裡預覽 wasm 的例子了。大部分浏覽器都已經支援 WebAssembly 了,包括各大 App 裡的 webview,下面是一個簡單 demo 在微信掃碼打開的效果(快看,我把一個 C++ 架構在微信裡跑起來了):

圖中 f 函數的計算是在 js 裡實作的,點選事件也是在 js 裡綁定的。首次渲染時 wasm 架構會生成一份 HTML 字元串塞到根節點的 innerHTML 裡(比逐個加節點要快),過程中會綁定點選事件。當使用者點選了按鈕時,原生事件通過 DOM API 透到 js,js 再傳給響應式架構的 wasm 包做處理,然後調用 js 綁定的回調函數,回調函數裡調用了 setState() 把新狀态傳給 wasm 架構,wasm 裡計算新的節點資訊,期間會調用 js 的 calculate 函數,最後調用 DOM API 更新節點(并不是重新生成 HTML 再替換,而是可以精确更新特定的節點)。

wasm 檔案理論上也可以調試,可以自己嘗試一下,然後你就知道調試 WebAssembly 是多麼坑的一件事了。另外 demo 也不一定非得用 JS 寫,可以用其他語言寫,然後編譯成 wasm,也能夠運作在響應式架構之上。

▐ 性能對比

下面到了大家喜聞樂道的性能測試環節。開頭我也說了,把這個項目編譯成 WebAssembly 并不是為了追求性能,現階段 js + wasm 的運作鍊路大家也都看到了,整個鍊路跑下來性能未必有什麼優勢,但是并不代表 wasm 的性能差,未來可優化的潛力是比 js 要高得多的。

是以我并沒有很認真的測性能…… 現階段意義不大。下面是我用一個稍複雜的頁面(大概 200+節點),在 Node.js v12 環境下渲染生成 HTML 字元串測出來的結果:

解釋一下各個字段。第一列 n 是循環生成 HTML 字元串的次數,第二列和第三列都是使用 wasm 表示使用渲染 HTML 字元串的耗時,它兩個的差別是 wasm 代碼僅包含了 js api wrapper 和一個幾乎為空的 js renderer (參考上文接口的調用鍊路),因為僅生成 HTML 字元串的話不需要 renderer;而 wasm+js 是包含了 js renderer 代碼,這樣在渲染過程中就會有比較頻繁的 wasm <--> js 調用和傳值。最後兩列是前面三列資料的比值。

可以看到把 c++ 編譯成 wasm,執行時間大概是原版 c++ 的 1.7~1.8 倍,這個基本上比的是執行 wasm 指令和執行原生指令的性能差距,也是符合預期的。而帶上 wasm+js 的通信之後,性能變成了 C++ 的 4.4~4.5 倍,是以說大部分性能其實耗在了通信上,而不是指令的運作上。

使用 WebAssembly 調用 DOM API 并不是一個合适的使用場景,和 JS 進行頻繁的調用和傳值也不是 WebAssembly 的強項,這個測試用例放大了通信開銷。

對 WebAssembly 的期待

現在 WebAssembly 雖然還存在很多問題,但它依然是一個很有潛力的技術,在 W3C 有标準化的規範,得到了主流浏覽器的一緻支援,技術社群也很活躍,大家的關注度也很高。從種種因素來看,它幾乎必然是個會被大規模使用的技術,就看在什麼時機爆發了。

現在關注 WebAssembly 技術的人,大部分都在思考兩個問題:

• 我能用 WebAssembly 做點什麼?

• 我能為 WebAssembly 做的什麼?

第一個問題是 WebAssembly 的使用場景,但是不能為了用新技術而用新技術,得找到最适合使用 WebAssembly 而且具備不可替代性的場景,目前來看,用戶端上用在視訊、遊戲、AR、AI 等領域比較合适。

第二個問題是促進 WebAssembly 的發展,解決實踐中的問題幫助它落地。要實作 WebAssembly 在真實業務場景中落地,還需要繼續完善基礎設施,我很期待社群能夠有人解決下面幾個問題:

  1. 在工程層面解決 WebAssembly 研發鍊路的問題。現在無論是開發、編譯還是調試都會遇到很多問題,開發體驗和開發效率都比較低。目前我個人覺得比較靠譜的三種開發語言是 C++、Rust 和 AssemblyScript,分别面向不同類型的開發者。
  2. 在平台側解決 WebAssembly 子產品的管理問題。解決 wasm 在真正使用時的加載、分發、依賴管理、複用等問題,要是能建構出 npm 這樣豐富的生态就好了。
  3. 在用戶端/服務端解決 WebAssembly 獨立運作時的問題。能夠把豐富的平台原生能力,高效的、标準的透出到 wasm 子產品中,并且解決性能、穩定性、安全性等問題。
  4. 性能優化!性能優化!性能優化! 無需多說,性能優化永無止境。

等上面的基礎設施建設完成後,可以為 WebAssembly 的落地掃清大部分障礙。

最後結合本文的例子,設想在未來使用 WebAssembly 的更合理的架構:

左邊是目前在浏覽器裡運作 WebAssembly 的層次結構,業務邏輯 JS 運作與架構之上,wasm 子產品要裹上一層 js glue 才可以運作,浏覽器是集 JS 腳本引擎、WebAssembly 運作時、渲染引擎與一體的運作環境,平台的原生能力透過浏覽器(或 webview)的殼透出到 JS 環境中。這個架構裡最關鍵的是浏覽器,集中實作了各種引擎,功能穩定而且标準化,但是也增大了定制和改造的難度,要引入就都得引入。

右邊是一個理想的運作 WebAssembly 的層次結構,在最底層的還是平台原生能力,再往上就不是簡單的對接浏覽器了,而是将 JS 引擎和 WebAssembly 運作時分開,這兩個都可以算作腳本引擎,至于渲染引擎,它應該運作與腳本引擎的下方,屬于平台原生能力的一部分(圖中未畫出來)。有了獨立的 WebAssembly 運作時,平台原生能力就能夠直接以 wasi 的形式透出,開發和運作 wasm 的時候就不再受 JS 的影響,也有助于實作标準化,向上透出的接口是語言無關的,依然可以被 JS 調用,也可以支援其他語言,也支援其他編譯好的 WebAssembly 子產品。

寫在最後

文章主要講的是我在響應式架構裡的使用 WebAssembly 的經驗,雖然未必是一個很合适的使用場景,但是大部分技術方案是通用的,給大家做個參考,寫得不對的地方歡迎指正。如果有對 WebAssembly 感興趣的同學歡迎加入淘寶技術部和我一起交流~