作者:chrongzhang,騰訊 WXG 客戶端開發工程師
這是一篇介紹微信小游戲客戶端底層,如果進行優化,可以讓所有小游戲獲得更好性能的文章。不是你想像的怎么優化某個小游戲的文章。來都來了,就了解一下吧:)
小游戲主要分為渲染和邏輯兩部分。渲染優化能讓渲染相關的指令(WebGL/GFX)得到更高效的執行,邏輯優化是讓除渲染之外的代碼也能更高效的執行,本篇主要講述邏輯相關的優化。
基礎功能優化
V8
微信小游戲是在 2017 年 12 月 28 日上線的,當時微信Android/ target=_blank class=infotextkey>安卓客戶端使用的 V8 版本還是 5.5。而 google 在 V8 上的迭代速度是很快的,其中一個大的版本變更是從 5.9 版本開始,編譯器由原來的 FullCodeGenerator + Crankshaft 變更成更加高效的 Ignition + TurboFan。
mark
V8 引擎之所以性能高,在于其出色的 JIT 執行效率。JIT 依賴了一個可以在運行時優化代碼的動態編譯器。V8 早期的 JIT 編譯器是 FullCodegen,后來是 Crankshaft,然后是一直沿用至今的 Turbofan。
升級 V8,可以獲得更高的執行性能(TurboFan)、更快的啟動速度(Snapshot + Code Caching)、更低的內存占用(64 位壓縮指針)。小游戲上線至今,客戶端使用的 V8 也一直在升級當中,從最初的 5.5,升級到 6.6,然后是 7.6,直到目前的 8.0。
JSBinding
微信小游戲對開發者暴露的是 JS 的接口,開發者調用某些 JS 函數時,最終會調用到客戶端底層的原生能力。而從 JS 到客戶端底層之間的橋接能力,就是所謂的 JS 綁定。JS 綁定又分為兩種:裸綁定和非裸綁定。裸綁定是通過 V8/JAVAScriptCore 提供的原生接口,將某個 JS 函數和原生函數實現綁定到一起,這是最直接,也是最高效的綁定方式。
非裸綁定是指通過某個 JS 和原生的通信的橋梁(evaluate/prompt/postMessage 等等),在此基礎上再封裝和轉發具體的函數調用。由于存在中間一層的轉發處理,會有額外的消耗。因此小游戲對外提供的 WebGL 等接口的實現,都采用了裸綁定的方式。直接用原生裸綁定的 API,又會存在以下問題:
- 原生 API 使用較復雜
- 不方便實現更高層次的類綁定
- V8 和 JavaScriptCore 的 API 差異很大,兩個平臺需要重復實現綁定
于是,我們實現了一套通用的綁定庫: jsbinding,公司內是開源的,未來計劃對外也開源。
具有如下特點:
- 簡單易用,支持類綁定
- 裸綁定,性能高
- 同時支持 V8 和 JavaScriptCore
- 支持 node addon 綁定實現
未來甚至計劃提供 WebAssembly 的綁定實現,是不是還有點小期待呢?
NodeJs/libuv
安卓客戶端已經全面擁抱 node。集成 node runtime 后,擁有了如下能力:
- node 內置能力(如文件、setTimeout 等)
- libuv 異步 IO 處理的能力
node 很多內置能力,是通過原生來實現的(node addon),屬于裸綁定,性能較高。有了 libuv 事件驅動后,可以更加靈活和高效的處理一些異步事件。比如 WebSocket 的回調,之前的處理流程是,在子線程收到 socket 消息后,將消息內容通過 JNI 調用到 Java 層,Java 層再拋到 JS 線程(也是 JVM 線程),回調到 JS。而如果使用 libuv,可以在子線程通過 uv_async_send 封裝的 ASyncCall 機制,在底層就直接拋到 JS 線程回調到 JS,避免了中間頻繁的 JNI 調用和數據傳輸的開銷。
調用鏈路優化
我們都知道,兩點之間,直線最短。代碼也是一樣,調用鏈路越短,越直接,中間的開銷就越小。
JsApi 優化
1. JsApi 調用優化
首先來看看之前 JsApi 的調用鏈路:
mark
一個 js api 的調用(WeixinJSCore.invokeHandler),首先會調用到 C/C++ 統一的回調函數 voidCallback,然后再通過 JNI 調用到 Java 的統一處理函數 callVoidJavaMethod。在這個函數里,需要根據 methodID 從 map 中找到對應的 Java Method,然后再通過多次 JNI 調用 J2V8 各種接口將 js api 的參數轉換為 Java 類型參數,最后再調用到具體 API 的 Java 實現函數 Invoke。
這個調用鏈路顯然不是前面提到的裸綁定的實現方法,因為中間還夾了一層 Java 的中轉處理層,產生了一些性能消耗。
針對 invokeHandler,縮短調用鏈路,減少 JNI 調用優化后,流程如下:
mark
針對 js 的 WeixinJSCore.invokeHandler 接口提供專門的 C++ 裸綁定接口 InvokeHandler,取出所有參數后,只需要一次 JNI 調用到 nativeInvokeHandler,然后調到具體 API 的 Java 實現函數 Invoke。
除此之外,針對異步 JsApi 調用,之前的流程是在 java 層拋到另一個線程執行。有了 libuv Looper 后,優化成在底層起一個 uv 的 worker 線程,通過 ASyncCall 將任務拋到 worker 線程,這樣 worker 里只需要執行同步的 api 流程,流程上簡化了,效率上比拋 Java 層線程更高。
2. JsApi 回調優化
當框架層需要觸發 JS 回調時,之前的做法是拼好一段 JS 字符串然后 evaluate:
evaluateJavascript(String.format(
"typeof WeixinJSBridge !== 'undefined' && " +
"WeixinJSBridge.invokeCallbackHandler(%d, %s)",
callbackId, data
));
這里的本質是去調用 JS 里的統一回調處理函數 WeixinJSBridge.invokeCallbackHandler,采取了直接執行一段 JS 的方法。優點是實現簡單,缺點是效率不高。
因為讓 JS 引擎執行一段 JS 代碼時,需要先編譯,parse 抽象語法樹,生成 Ignition 字節碼,甚至啟用到 TurboFan 編譯優化器,最后才真正執行到想調用的 JS 函數。
同時每個回調都拼一個字符串執行,在 JS 引擎內部會積攢大量臨時字符串,占用內存資源。
優化的方法其實也很簡單,就是通過 jsbinding 預先查找好 WeixinJSBridge.invokeCallbackHandler 函數,在需要回調這個 JS 函數時,直接調用即可。
// 查找到 invokeCallbackHandler 函數后,保存下來
mm::JSObject func =
JS_GET_AS(mm::JSObject, js_bridge, "invokeCallbackHandler");
js_func_holder_ = JS_NEW_OBJECT_HOLDER(func);
// ...
// 當需要回調時,直接調用
JS_CALL(js_func_holder_->Get(), nullptr, nullptr,
js_bridge, callbackId, data);
并行調用優化
開發者在執行某些耗時較重的任務時,可以使用多線程 Worker,類比標準 H5 的 WebWorker。
// 主線程初始化 Worker
const worker = wx.createWorker('workers/request/index.js') // 文件名指定 worker 的入口文件路徑,絕對路徑
// 向 Worker 發送消息
worker.postMessage({
msg: 'hello worker'
})
// workers/request/index.js
// 在 Worker 線程執行上下文會全局暴露一個 `worker` 對象
worker.onMessage(function (res) {
console.log(res)
})
之前的 Worker 有個限制,只能執行一些純邏輯運算的代碼,不支持 JsApi 的調用。這很大程度限制了 Worker 的使用,于是我們也在不斷的擴展 Worker 的能力,增加了音頻、網絡、文件等能力。
// Worker 線程
var audio = worker.createInnerAudioContext()
audio.src = url
audio.play()
未來 Worker 將會賦予更多能力,提高開發者并行化處理的效率。
數據傳輸優化
開發者在 JS 層的數據(ArrayBuffer)需要傳到客戶端底層,同時客戶端底層的數據也需要傳到 JS 上層,這中間涉及到數據的高效傳輸。在渲染優化時,可以通過 wgfx 提供的 createNativeBuffer 接口,創建一塊 JS 和 Naitve 共享的內存,雙方可直接讀寫該內存而無需額外的傳輸,極大的提高了效率。
NativeBuffer 的共享內存傳輸機制,可以應用到多個需要頻繁傳輸數據的場景,比如 Camera 傳輸的數據、JS 的 WebGL CommandBuffer 傳輸等等。
還有一種情況是前面提到的 Worker 之間傳輸數據,如果通過默認的 postMessage 來傳輸,效率是非常低的,不利于傳輸較大的 ArrayBuffer 數據。為了解決這個問題,我們提供了類似標準 H5 的 SharedArrayBuffer 的能力,用來 Worker 之間高效的傳輸數據。
// game.js
const sab = wx.createSharedArrayBuffer(2)
worker.postMessage({
sab
})
// worker.js
worker.onMessage(function (res) {
res.sab.lock(() => {
setTimeout(() => {
res.sab.unlock()
}, 3000)
})
})
總結
小游戲的性能瓶頸,很大程度局限于 JavaScript,而我們所做的各種優化,是希望能盡量抹平 JavaScript 本身帶來的性能損耗,接近并向原生性能靠齊,極具困難和挑戰。
在 IOS 上,我們也為讓 JavaScript 擁有 JIT 能力做了深入探索。同時,我們也在 WebAssembly 上也進行了深入的探索和支持,未來有機會再進行分享。
為了小游戲有更好的運行性能,開發者能更好的發揮其創意,我們所有的性能優化還將持續不斷的迭代下去。