近期線上收實驗報告的時候,學生們上傳的圖片亂七八糟的。後期提示使用 掃描王 等軟體處理後再上傳效果好了很多。但無疑這給學生了帶來了相應的繁瑣。於是:如何在WEB能快速的處理圖片,並實時的顯示效果成為了新的需求。
首先,我們可以點選demo感受一下它的魅力。
而處理圖片往往都在後端執行,直接在 WEB 處理則需要一個叫WebAssembly的知識,簡單來說就是瀏覽器允許執行二進位制的檔案,而這個二進位制的檔案則是各種原後端語言透過編譯器編譯出來的。
所以可以用C++來寫一個圖片處理程式,並使用WebAssembly把它應用到瀏覽器中便成了解決方案。
本文在macos下,演示如何把Hello world執行在瀏覽器中。
Emscripten
要想把C++原始碼編譯成瀏覽器可以執行的 WebAssembly , 則需要一些編譯器,而Emscripten則屬於其中的一個。
docker(推薦)
docker無疑是最簡單的安裝方式,官方image提供了多個版本供我們選擇。
我們在使用前僅僅需要下載相應的image即可,比如我們下載最新的版本:
% docker pull emscripten/emsdk
然後我們進行資料夾對映,並執行容器中的emcc命令即可,比如:
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) \
emscripten/emsdk emcc helloworld.cpp -o helloworld.js
則表示將當前路徑下的helloworld.cpp編譯成helloworld.js
macos
安裝 Emscripten 需要從github下載相當的程式碼,並執行相應的操作,
環境要求:
- macOS 10.14 Mojave及以上
- 安裝 Xcode Command Line Tools
- 安裝git
- 安裝cmake
命令如下:
# 下載程式碼
$ git clone https://github.com/emscripten-core/emsdk.git --depth=1
# 進入下載的資料夾
$ cd emsdk
# 執行安裝命令,由於這個操作會從網下下載相應的第三方安裝包,所以這可能需要一個比較友好的網路
$ ./emsdk install latest
# 源活我們剛剛安裝的 latest 版本
$ ./emsdk activate latest
# 源活環境變數,每啟動一新的shell,都要執行一次
$ source ./emsdk_env.sh
驗證:
建立以下檔案:
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
驗證
我們新建hello.c檔案
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
然後執行emcc hello.c -o hello.html
panjie@panjies-Mac-Pro src $ emcc hello.c -o hello.html
shared:INFO: (Emscripten: Running sanity checks)
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/Users/panjie/github/emscripten-core/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO: - ok
然後我們就得到了 一個 html 檔案,一個js檔案以及一個wasm檔案:
panjie@panjies-Mac-Pro src % ls
hello.c hello.html hello.js hello.wasm
接著我們起一個http-server,並在瀏覽器中檢視效果:
panjie@panjies-Mac-Pro src % http-server
Starting up http-server, serving ./
Available on:
http://127.0.0.1:8081
http://192.168.0.242:8081
Hit CTRL-C to stop the server
如果你是用的docker,則可以如下執行:
panjie@panjies-Mac-Pro src % docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc hello.c -o hello.html
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO: - ok
最後的效果是一致的。
分析
.c檔案編譯後生成了3個新的檔案,html檔案用於展示頁面並且呼叫js檔案,js檔案則充當獲取二進位制檔案,裝載二進位制檔案,呼叫二進位制檔案並獲取返回值的目的,而wasm則是瀏覽器直接執行的二進位制檔案。該檔案由c語言編譯而來,可以兼顧功能與效率,重要的是原本一些只能支援在應用程式中的功能,可以被移植到瀏覽器中來了。
編譯至指定模板
Emscripten的github原始碼中,為我們提供了自定義的html模板,下面我們將 HelloWorld輸入到這個自定義的模板中。
首先我們在當前資料夾中建立子資料夾html_template,並將位於Emscripten的github原始碼資料夾中的 /upstream/emscripten/src/shell_minimal.html
複製到html_template
資料夾。
panjie@panjies-Mac-Pro src % tree
.
├── hello.c
├── hello.html
├── hello.js
├── hello.wasm
└── html_template
└── shell_minimal.html
1 directory, 5 files
接著我們執行如下命令:
panjie@panjies-Mac-Pro src % docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc -o hello1.html hello.c -O3 --shell-file html_template/shell_minimal.html
cache:INFO: generating system asset: symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt" for subsequent builds)
cache:INFO: - ok
注意,上面的命令中O3
不是03
.
此時,便會使用模模板html_template/shell_minimal.html
來生成hello1.html
,使用http-server起個服務後檢視結果如下:
如果我們將當前的網路模擬成慢速3G:
則會發下如下啟動過程:
先下載
再準備
最後才是呈現結果
請求的時序如下:
如果我們開啟快取,那麼整體請求將會友好的多:
自定義模板
學習 DEMO
透過 shell_minimal.html 模板的學習,我們簡單的把關鍵的資訊拿出來學習一下。首先是 CSS 樣式部分,該部門主要用於控制頁面顯示,我們暫時略過。
上圖這個html基本上可以分為兩個部分,第一部分是影像UI輸出,第三部分是sheel控制檯輸出。比如我們的hello.c,並沒有輸出任何影像,而是直接列印了 Hello World,所以上述影像就輸出了一個黑框框。
在模板中,用於輸出影像的標籤是canvas
:
<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex=-1></canvas>
而用於輸出shell資訊的是textarea
<textarea class="emscripten" id="output" rows="8"></textarea>
而以下的script程式碼的作用起的是銜接的作用:載入.c,執行.c,輸出.c的結果。
<script type='text/javascript'>
// 獲取三個dom,分別用於顯示 狀態、進度,以及 loading時轉圈圈
var statusElement = document.getElementById('status');
var progressElement = document.getElementById('progress');
var spinnerElement = document.getElementById('spinner');
// 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
var Module = {
// 執行前執行的
preRun: [],
// 執行後執行的
postRun: [],
// 輸出結果
print: (function() {
// 獲取用於輸出結果的 textarea DOM
var element = document.getElementById('output');
if (element) element.value = ''; // 清空textarea中的內容
// 返回function供hello.js呼叫,hello.js會在執行hello.c後呼叫該函式,並把執行hello.c結果做為參考text傳入
return function(text) {
// arguments的上下文位於hello.js中,欄位意思看是 呼叫引數. 如果呼叫的引數大於1,則重寫 text 的為arguments陣列
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
// 下面這幾行就給出的示例,主要是用於替換html中的關鍵字,比如 < 使用 < 來替換
//text = text.replace(/&/g, "&");
//text = text.replace(/</g, "<");
//text = text.replace(/>/g, ">");
//text = text.replace('\n', '<br>', 'g');
console.log(text);
// 向 textarea 中輸出內容,並調焦點設定為textarea的底部
if (element) {
element.value += text + "\n";
element.scrollTop = element.scrollHeight; // focus on bottom
}
};
})(),
// 輸出影像
canvas: (function() {
// 獲取 canvas
var canvas = document.getElementById('canvas');
// 瀏覽器對影像的渲染基於webgl,所以做一個預設的初始化選項,
// 為了保證程式的健壯性,我們應該在webgl上下文丟失時,提醒使用者重新重新整理介面
// 原文如下:
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
// application robust, you may want to override this behavior before shipping!
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
canvas.addEventListener("webglcontextlost", function(e) { alert('WebGL context lost. You will need to reload the page.'); e.preventDefault(); }, false);
// 將這個 canvas 返回給hello.js,hello.js則會將影像輸出到這個 canvas 上
return canvas;
})(),
// 設定狀態(比如開始下載、下載的百分比,準備完畢),這個應該不是WebAssembly的官方介面,而是自定義的
setStatus: function(text) {
// 設定個最後更新時間
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
// 如果更新內容與最後的更新內容相同,則什麼也不做
if (text === Module.setStatus.last.text) return;
// 判斷傳入的是否為下載的百分比(進度)
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
// 如果傳入的是下載百分比,而且距離上次傳入的時間小於30ms,則什麼也不做。
if (m && now - Module.setStatus.last.time < 30) return; // if this is a progress update, skip it if too soon
// 設定最後的時間及最後的文字
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) {
// 傳入的是下載百分比,則格式化
text = m[1];
progressElement.value = parseInt(m[2])*100;
progressElement.max = parseInt(m[4])*100;
progressElement.hidden = false;
spinnerElement.hidden = false;
} else {
// 不是百分比,則清空進度值
progressElement.value = null;
progressElement.max = null;
progressElement.hidden = true;
// 當text為空時,隱藏掉spinner
if (!text) spinnerElement.hidden = true;
}
// 最後設定狀態元素的內容
statusElement.innerHTML = text;
},
// 總依賴數
totalDependencies: 0,
// 監視執行依賴項,該方法會被間隔呼叫,用於通知當前載入的進度
// @param left 剩餘依賴項
monitorRunDependencies: function(left) {
// 未載入完畢,則設定狀態為 Preparing... (已載入數/未載入數);否則顯示 All downloads complete
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
}
};
// 設定起始狀態
Module.setStatus('Downloading...');
// 設定下異常的回撥
window.onerror = function() {
Module.setStatus('Exception thrown, see JavaScript console');
spinnerElement.style.display = 'none';
Module.setStatus = function(text) {
if (text) console.error('[post-exception status] ' + text);
};
};
</script>
模板最後存在的{{{ SCRIPT }}}
則用於替換為 js 檔案的引用。如此,我們先宣告瞭符合 WebAssembly 介面的物件 Module,然後引入了 js 檔案,而js檔案則會應用這個剛剛宣告的Module。這樣一來,WebAssembly的 JS 檔案更與當前頁面結合起來了。
自定義模板
為了驗證前面的假設,我們下面來如下自定義模板並命名為sample.html
,同是樣存到html_template資料夾中:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebAssembly</title>
</head>
<body>
<div style="margin-left: auto; margin-right: auto; width: 400px; margin-top:10em; text-align: center;">
<p id="output">這裡是輸出的內容</p>
</div>
<script type="text/javascript">
// 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
var Module = {
preRun: [],
postRun: [],
// 輸出結果
print: (function() {
// 獲取用於輸出結果的 textarea DOM
var element = document.getElementById('output');
if (element) element.innerHTML = '';
return function(text) {
if (element) {
element.innerHTML += text;
}
};
})(),
totalDependencies: 0,
monitorRunDependencies: function(left) {
console.log(left);
}
};
// 設定下異常的回撥
window.onerror = function() {
alert('error');
};
</script>
{{{ SCRIPT }}}
</body>
</html>
然後我們執行以下命令來將hello.c渲染進來:
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc -o sample.html hello.c -O3 --shell-file html_template/sample.html
最後我們將得到以sample打頭的js html 以及 wasm 檔案,執行http-server並檢視:
函式
最後我們再看看如何呼叫.cpp檔案中的函式,我們簡單用c++語言寫個求開平方,並把它應用html頁面中。官方文件指出呼叫 C 語言中function最簡單的方法便是使用 ccall以及cwrap.
ccall()
使用指定的引數來呼叫一個編譯後的 C 函式,而cwrap()
則是把 C 中的函式包裹成js的函式,然後再像呼叫普通的js函式一樣來進行呼叫。所以如果我們只想呼叫一次,那麼用ccall
就好了,如果我們想多次呼叫,則建立使用cwarp
來封裝一下。
建立一個 sqrt.cpp 檔案並加入以下程式碼:
#include <math.h>
// 相容 C++
extern "C" {
int int_sqrt(int x) {
return sqrt(x);
}
}
接下來我們將其編譯為 js wasm檔案,需要注意的是:
- 本次我們是先編譯,然後再寫模板,所以我們把目標檔案設定為sqrt.js,而非sqrt.html
- 我們需要指定編譯的方法,並以
_
打頭 - 我們需要指定ccall、cwarp
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc sqrt.cpp -o sqrt.js -sEXPORTED_FUNCTIONS=_int_sqrt -sEXPORTED_RUNTIME_METHODS=ccall,cwrap
最終將生成 js 及 wasm 兩種型別的檔案。
最後,我們寫個html程式碼來嘗試呼叫一下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>加法器</title>
</head>
<body style="margin-left:auto; margin-right:auto; width: 800px;">
<div>請輸入:<input type="number" id="num"/></div>
<div>結果:<p id="output"></p></div>
<button onclick="sqrt()">運算</button>
<script type="text/javascript">
// 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
var Module = {};
// 設定下異常的回撥
window.onerror = function() {
alert('error');
};
// 獲取輸入、輸出
var num = document.getElementById("num");
var output = document.getElementById("output");
// 定義開平方方法
var sqrt = function() {
// 呼叫c++中的int_sqrt方法
const result = Module.ccall(
"int_sqrt", // 方法名
"number", // 返回值型別
["number"], // 引數型別,這是個陣列,因為可能是多引數
[+num.value] // 引數值,也是個陣列,因為可能是多引數
);
// 最後將結果給html元素
output.innerHTML = result;
}
</script>
<!-- 手動引用js檔案 -->
<script async src=sqrt.js></script>
</body>
</html>
最後我們再測試下 cwrap
:
<script type="text/javascript">
let intSqrt;
// 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
var Module = {
monitorRunDependencies: function(left) {
// 載入完畢後初始化iniSqrt方法
if (left === 0 && !intSqrt) {
intSqrt = Module.cwrap('int_sqrt', 'number', ['number'])
}
}
};
// 設定下異常的回撥
window.onerror = function() {
alert('error');
};
// 獲取輸入、輸出
var num = document.getElementById("num");
var output = document.getElementById("output");
// 定義開平方方法
var sqrt = function() {
// 直接呼叫intSqrt方法
output.innerHTML = intSqrt ? intSqrt(+num.value) : '';
}
</script>
最終實現效果相同。
總結
WebAssembly是個變革性的東西,有人說它將引領下一代WEB開發,它的出現使得原本僅能安裝客戶端才能實現在功能,當下可以直接在 WEB 端來使用了。同時由於其編譯為2進位制的特性,在保證了效能的同時能提供了足夠的安全性。