實現一個簡單的基於 WebAssembly 的圖片處理應用

雲音樂前端團隊發表於2020-07-01

圖片來源:https://rustwasm.github.io/

本文作者:劉家隆

寫在前邊

本文希望通過 Rust 敲一敲 WebAssembly 的大門。作為一篇入門文章,期望能夠幫你瞭解 WebAssembly 以及構建一個簡單的 WebAssembly 應用。在不考慮IE的情況,目前大部分主流的瀏覽器已經支援 WebAssembly,尤其在移動端,主流的UC、X5核心、Safari等都已支援。讀完本文,希望能夠幫助你將 WebAssembly 應用在生產環境中。

WebAssembly(wasm) 簡介

如果你真的瞭解了 WebAssembly, 可以跳過這一節。

可以先看兩個 wasm 比較經典的 demo:

http://webassembly.org.cn/dem...

http://wasm.continuation-labs...

快速總結一下: WebAssembly(wasm) 是一個可移植、體積小、載入快並且相容 Web 的全新格式,由 w3c 制定出的新的規範。目的是在一些場景下能夠代替 JS 取得更接近原生的運算體驗,比如遊戲、圖片/視訊編輯、AR/VR。說人話,就是可以體積更小、執行更快。

wasm 有兩種表示格式,文字格式和二進位制格式。二進位制格式可以在瀏覽器的 js 虛擬機器中沙箱化執行,也可以執行在其他非瀏覽器環境中,比如常見的 node 環境中等;執行在 Web 上是 wasm 一開始設計的初衷,所以實現在瀏覽器上的執行方法非常簡單。

通過一個簡單的例子實現快速編譯 wasm 文字,執行一個 wasm 二進位制檔案:

wasm 文字格式程式碼:

(module
    (import "js" "import1" (func $i1)) // 從 js 環境中匯入方法1
    (import "js" "import2" (func $i2)) // 從 js 環境中匯入方法2
    (func $main (call $i1)) // 呼叫方法1
    (start $main)
    (func (export "f") (call $i2)) // 將自己內部的方法 f 匯出,提供給 js,當 js 呼叫,則會執行方法2
)

上述內容看個大概即可,參閱程式碼中註釋大致瞭解主要功能語法即可。主要功能就是從 js 環境中匯入兩個方法 import1import2; 同時自身定義一個方法 f 並匯出提供給外部呼叫,方法體中執行了 import2

文字格式本身無法在瀏覽器中被執行,必須編譯為二進位制格式。可以通過 wabt 將文字格式編譯為二進位制,注意文字格式本身不支援註釋的寫法,編譯的時候需要將其去除。這裡使用 wat2wasm 線上工具快速編譯,將編譯結果下載就是執行需要的 wasm 二進位制檔案。

有了二進位制檔案,剩下的就是在瀏覽器中進行呼叫執行。

// 定義 importObj 物件賦給 wasm 呼叫
var importObj = {js: { 
    import1: () => console.log("hello,"), // 對應 wasm 的方法1
    import2: () => console.log("world!") // 對應 wams 的方法2
}};
// demo.wasm 檔案就是剛剛下載的二進位制檔案
fetch('demo.wasm').then(response =>
    response.arrayBuffer() // wasm 的記憶體 buffer
).then(buffer =>
       /**
       * 例項化,返回一個例項 WASM.module 和一個 WASM.instance,
       * module 是一個無狀態的 帶有 Ast.module 佔位的物件;
       * 其中instance就是將 module 和 ES 相關標準融合,可以最終在 JS 環境中呼叫匯出的方法
       */
    WebAssembly.instantiate(buffer, importObj) 
).then(({module, instance}) =>
    instance.exports.f() // 執行 wasm 中的方法 f
);

大概簡述一下功能執行流程:

  • 在 js 中定義一個 importObj 物件,傳遞給 wasm 環境,提供方法 import1 import2 被 wasm 引用;
  • 通過 fetch 獲取二進位制檔案流並獲取到記憶體 buffer;
  • 通過瀏覽器全域性物件 WebAssembly 從記憶體 buffer 中進行例項化,即 WebAssembly.instantiate(buffer, importObj),此時會執行 wasm 的 main 方法,從而會呼叫 import1 ,控制檯輸出 hello;
  • 例項化之後返回 wasm 例項,通過此例項可以呼叫 wasm 內的方法,從而實現了雙向連線,執行 instance.exports.f() 會呼叫 wasm 中的方法 ff 會再呼叫 js 環境中的 import2,控制檯輸出 world。

細品這段實現,是不是就可以達到 wasm 內呼叫 js,從而間接實現在 wasm 環境中執行瀏覽器相關操作呢?這個下文再展開。

通過直接編寫文字格式實現 wasm 顯然不是我們想要的,那麼有沒有“說人話的”實現方式呢,目前支援比較好的主要包括 C、 C++、Rust、 Lua 等。

頗有特點的Rust

如果你瞭解 Rust,這一節也可以跳過了。

A language empowering everyone to build reliable and efficient software. ——from rust-lang

Rust 被評為 2019 最受歡迎的語言。

截圖自 https://insights.stackoverflo...

Rust 正式誕生於 15 年,距今僅僅不到五年的時間,但是目前已覆蓋各大公司,國外有 Amazon、Google、Facebook、Dropbox 等巨頭,國內有阿里巴巴、今日頭條、知乎、Bilibili 等公司。那是什麼讓如此年輕的語言成長這麼快?

  • Rust 關注安全、併發與效能,為了達成這一目標,Rust 語言遵循記憶體安全、零成本抽象和實用性三大設計哲學
  • 藉助 LLVM 實現跨平臺執行。
  • Rust 沒有執行時 gc,並且大部分情況不用擔心記憶體洩漏的問題。
  • ...

你內心 OS 學不動了?別急,先簡單領略一下 Rust 的魅力,或許你會被他迷住。

下邊看似很簡單的問題,你能否答對?一共三行程式碼,語法本身沒有問題,猜列印的結果是啥?

fn main() {
    let s1 = String::from("hello word"); // 定義一個字串物件
    let s2 = s1; // 賦值
    println!("{}", s1); // log輸出 
}

<details>
<summary>思考一會 點選檢視答案</summary>
報錯!變數 s1 不存在了。
</details>

這其實是 Rust 中一個比較重要的特性——所有權。當將 s1 賦值給 s2 之後,s1 的所有權便不存在了,可以理解為 s1 已經被銷燬。通過這種特性,實現記憶體的管理被前置,程式碼編寫過程中實現記憶體的控制,同時,藉助靜態檢查,可以保證大部分編譯正確的程式可以正常執行,提高記憶體安全之外,也提高了程式的健壯性,提高開發人員的掌控能力。

所有權只是 Rust 的眾多特性之一,圍繞自身的三大哲學(安全、併發與效能)其有很多優秀的思想,也預示著其上手成本還是比較高的,感興趣的可以深入瞭解一下。之前 Rust 成立過 CLI、網路、WASM、嵌入式四大工作組,預示著 Rust 希望發力的四大方向。截止目前已經在很多領域有比較完善的實現,例如在服務端方向有 actix-web、web 前端方向有 yew、wasm 方面有 wasm-pack 等。總之,Rust 是一門可以拓寬能力邊界的非常有意思的語言,儘管入門陡峭,也建議去了解一下,或許你會深深的愛上它。

除 wasm 外的其他方向(cli、server等),筆者還是喜歡 go,因為簡單,^_^逃...

行了,扯了這麼多,Rust 為何適合 wasm:

  • 沒有執行時 GC,不需要 JIT,可以保證效能
  • 沒有垃圾回收程式碼,通過程式碼優化可以保證 wasm 的體積更小
  • 支援力度高(官方介入),目前而言相比其他語言生態完善,保證開發的低成本

Rust -> wasm

Rust編譯目標

rustc 本身是一個跨平臺的編譯器,其編譯的目標有很多,具體可以通過 rustup target list 檢視,和編譯 wasm 相關的主要有三個:

  • wasm32-wasi:主要是用來實現跨平臺,通過 wasm 執行時實行跨平臺模組通用,無特殊 web 屬性
  • wasm32-unknown-emscripten:首先需要了解 emscripten,藉助 LLVM 輕鬆支援 rust 編譯。目標產物通過 emscripten 提供標準庫支援,保證目標產物可以完整執行,從而實現一個獨立跨平臺應用。
  • wasm32-unknown-unknown:主角出場,實現 rust 到 wasm 的純粹編譯,不需要藉助龐大的 C 庫,因而產物體積更加小。通過記憶體分配器(wee_alloc)實現堆分配,從而可以使用我們想要的多種資料結構,例如 Map,List 等。利用 wasm-bindgen、web-sys/js-sys 實現與 js、ECMAScript、Web API 的互動。該目標鏈目前也是處於官方維護中。
或許有人對 wasm32-unknown-unknown 的命名感覺有些奇怪,這裡大概解釋一下:wasm32 代表地址寬度為 32 位,後續可能也會有 wasm64 誕生,第一個 unknow 代表可以從任何平臺進行編譯,第二個 unknown 表示可以適配任何平臺。

wasm-pack

以上各個工具鏈看著複雜,官方開發支援的 wasm-pack 工具可以遮蔽這一切細節,基於 wasm32-unknown-unknown 工具鏈可快速實現 Rust -> wasm -> npm 包的編譯打包,從而實現在 web 上的快速呼叫,窺探 wasm-npm 包這頭“大象”只需要如下幾步:

  1. 使用 rustup 安裝rust
  2. 安裝 wasm-pack
  3. wasm-pack new hello-wasm.
  4. cd hello-wasm
  5. 執行 wasm-pack build.
  6. pkg 目錄下產物就是可以被正常呼叫的 node_module 了

一個真例項子看一下 wasm 執行優勢

路指好了,準備出發!接下來可以愉快的利用 rust 編寫 wasm 了,是不是手癢了;下邊通過實現一個 MD5 加密方法來對比一下 wasm 和 js 的執行速度。

首先修改 Cargo.toml,新增依賴包

[dependencies]
wasm-bindgen = "0.2"
md5 = "0.7.0"

Cargo 是 Rust 的包管理器,用於 Rust 包的釋出、下載、編譯等,可以按需索取你需要的包。其中 md5 就是一會要進行 md5 加密的演算法包,wasm-bindgen 是幫助 wasm 和 js 進行互動的工具包,抹平實現細節,方便兩個記憶體空間進行通訊。

編寫實現(src/lib.rs)

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn digest(str: &str) -> String {
    let digest = md5::compute(str);
    let res = format!("{:x}", digest);
    return res;
}

藉助 wasm_bindgen 可以快速將方法匯出給 js 進行呼叫,從而不需要關心記憶體通訊的細節。最終通過 wasm-pack build 構建出包(在目錄 pkg 下),可以直接在 web 進行引用了,產物主要包含以下幾部分

├── package.json
├── README.md
├── *.ts
├── index_bg.wasm:生成 wasm 檔案,被index.js進行呼叫
├── index.js:這個就是最終被 ECMAScript 專案引用的模組檔案,裡邊包含我們定義的方法以及一些自動生成的膠水函式,利用 TextEncoder 實現記憶體之間的資料通訊。

js 呼叫

import * as wasm from "./pkg";
wasm.digest('xxx');

構建出的 wasm pkg 包引入 web 專案中,使用 webpack@4 進行打包編譯,甚至不需要任何其他的外掛便可支援。

速度對比

針對一個大約 22 萬字元長度的字串進行 md5 加密,粗略的速度對比:

加密1次時間(ms) 加密100次時間(ms) 演算法依賴包
js版本md5 ~57 ~1300 https://www.npmjs.com/package...
wasm版本md5 ~5 ~150 https://crates.io/crates/md5

從資料層面來看,wasm 的效能優勢顯而易見。但同時也發現在 100 次的時候,效能資料差值雖然擴大,但是比值卻相比一次加密縮小。原因是在多次加密的時候,js 和 wasm 的通訊成本的佔比逐漸增高,導致加密時間沒有按比例增長,也說明 wasm 實際加密運算的時間比結果更小。這其實也表明了了 wasm 在 web 上的應用場景:重計算、輕互動,例如音視訊/影像處理、遊戲、加密。但在將來,這也會得到相應的改善,藉助 interface-type 可實現更高效的值傳遞,未來的前端框架或許會真正迎來一場變革。

利用 wasm 實現一個完整 Web 應用

藉助 wasm-bindgen,js-sysweb-sys crates,我們甚至可以極小的依賴 js,完成一個完整的 web 應用。以下是一個本地彩色 png 圖片轉換為黑白圖片的 web-wasm 應用。

效果圖:

線上體驗:點我

大致功能是通過 js 讀取檔案,利用 wasm 進行圖片黑白處理,通過 wasm 直接建立 dom 並進行圖片渲染。

1. 利用 js 實現一個簡單的檔案讀取:

// html
<div>
    <input type="file" id="files" style="display: none" onchange="fileImport();">
    <input type="button" id="fileImport" value="選擇一張彩色的png圖片">
</div>
// js
$("#fileImport").click(function () {
    $("#files").click();
})
window.fileImport = function() {
    //獲取讀取我檔案的 File 物件
    var selectedFile = document.getElementById('files').files[0];
    var reader = new FileReader(); // 這是核心, 讀取操作就是由它完成.
    reader.readAsArrayBuffer(selectedFile); // 讀取檔案的內容,也可以讀取檔案的URL
    reader.onload = function () {
        var uint8Array = new Uint8Array(this.result);
        wasm.grayscale(uint8Array);
    }
}

這裡獲取到的檔案是一個 js 物件,最終拿到的檔案資訊需要藉助記憶體傳遞給 wasm , 而檔案物件無法直接傳遞給 wasm 空間。我們可以通過 FileReader 將圖片檔案轉換為一個 8 位無符號的陣列來實現資料的傳遞。到此,js 空間內的使命完成了,最後只需要呼叫 wasm.grayscale 方法,將資料傳遞給 wasm 即可。

2. wasm 獲取資料並重組

fn load_image_from_array(_array: &[u8]) -> DynamicImage {
    let img = match image::load_from_memory_with_format(_array, ImageFormat::Png) {
        Ok(img) => img,
        Err(error) => {
            panic!("{:?}", error)
        }
    };
    return img;
}

#[wasm_bindgen]
pub fn grayscale(_array: &[u8]) -> Result<(), JsValue> {
    let mut img = load_image_from_array(_array);
    img = img.grayscale();
    let base64_str = get_image_as_base64(img);
    return append_img(base64_str);
}

wasm 空間拿到傳遞過來的陣列,需要重組為圖片檔案物件,利用現成的輪子 image crate 可以快速實現從一個無符號陣列轉換為一個圖片物件(load_image_from_array),並進行影像的黑白處理(img.grayscale())。處理過後的物件需要最終再返回瀏覽器 <img /> 標籤可識別的內容資訊,提供給前端進行預覽,這裡選擇 base64 字串。

3. wasm 內生成 base64 圖片格式

fn get_image_as_base64(_img: DynamicImage) -> String {
    // 建立一個記憶體空間
    let mut c = Cursor::new(Vec::new());
    match _img.write_to(&mut c, ImageFormat::Png) {
        Ok(c) => c,
        Err(error) => {
            panic!(
                "There was a problem writing the resulting buffer: {:?}",
                error
            )
        }
    };
    c.seek(SeekFrom::Start(0)).unwrap();
    let mut out = Vec::new();
    // 從記憶體讀取資料
    c.read_to_end(&mut out).unwrap();
    // 解碼
    let stt = encode(&mut out);
    let together = format!("{}{}", "data:image/png;base64,", stt);
    return together;
}

在 wasm 空間內將 DynamicImage 物件再轉換為一個基礎值,從而再次實現值得傳遞;藉助 Rust Cursor,對 DynamicImage 物件資訊進行讀寫,Rust Cursor 有點類似前端的 Reader/Writer,通過一個快取區實現資訊讀寫,從而拿到記憶體空間內的圖片儲存資訊,獲得的資訊經過 base64 解碼即可拿到原始字串資訊,拿到的字串拼接格式資訊 data:image/png;base64 組成完整的圖片資源字元創,便可以直接返回給前端進行預覽渲染了。

以上已經完成了圖片處理的所有流程了,獲取到的 base64 可以直接交還給 js 進行建立 dom 預覽了。但是!我有沒有可能不使用 js 進行操作,在 wasm 內直接完成這步操作呢?

4. wasm 內建立 dom 並渲染圖片

wasm 本身並不能直接操作 dom,必須經過 js 完成 dom 的操作。但是依然可以實現在 wasm 內載入 js 模組間接操作 dom。web_sys 便實現了這步操作,並基本完成所有的介面實現,藉助 web_sys 甚至可以很方便的實現一個純 wasm 的前端框架,比如 yew。

圖片引自:https://hacks.mozilla.org/201...
pub fn append_img(image_src: String) -> Result<(), JsValue> {
    let window = web_sys::window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    let body = document.body().expect("document should have a body");
    let val = document.create_element("img")?;
    val.set_attribute("src", &image_src)?;
    val.set_attribute("style", "height: 200px")?;
    body.append_child(&val)?;
    Ok(())
}

操作的流程和直接使用 js 操作 dom 基本一致,其實也都是間接呼叫了 js 端方法。在實際應用中,還是要儘量避免多次的通訊帶來額外的效能損耗。

一個簡單的圖片黑白處理應用完成了,完整的程式碼:點我。其他的功能可以按照類似的方式進行擴充,比如壓縮、裁剪等。

寫在最後

本文簡述了從 Rust 到 wasm,再到 web based wasm 的流程。希望讀完本文,能夠幫你在實際業務開發中開拓解決問題的思路,探索出更多更實用的場景。由於作者水平有限,歡迎批評指正。

資料參考

https://rustwasm.github.io/

https://rustwasm.github.io/wa...

https://github.com/WebAssembl...

https://yew.rs/docs/v/zh_cn/

https://hacks.mozilla.org/201...

本文釋出自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們

相關文章