如何不改一行代碼,讓Hippy啓動速度提升50%?

圖片

導讀|Hippy使用JS引擎進行異步渲染,在用戶從點擊到打開首屏可交互過程中會有一定的耗時,影響用戶體驗。如何優化這段耗時?騰訊客戶端開發工程師李鵬,將介紹QQ瀏覽器通過切換JS引擎來優化耗時的探索過程和效果收益。在分析Hippy耗時瓶頸、對比業界可選引擎方案後,最終QQ瀏覽器通過選擇使用Hermes引擎、將JS離線生成Bytecode並使用引擎直接加載Bytecode,讓首幀耗時優化50%起。希望本文對面臨同樣困擾的你有幫助。

圖片

背景

目前QQ瀏覽器(下簡稱QB)使用Hippy的業務超過100個,基本上95%的核心業務都是使用Hippy作爲首要技術棧來開發。但是跟Native相比較而言,Hippy是使用JS引擎進行異步渲染,在用戶從點擊到打開首屏可交互過程中會有一定的耗時,影響用戶體驗。如何優化耗時,儘量對齊Native體驗,想必是許多開發者都在思考優化的事情。

本文主要介紹QQ瀏覽器通過切換JS引擎來優化耗時的探索過程和效果收益。本文我將分析Hippy執行流程及耗時瓶頸、對比業界JS引擎方案,最終選擇使用Hermes引擎。之後分析將JS離線生成Bytecode,使用引擎直接加載Bytecode的能力。值得一提的是,在業務無需修改一行代碼的前提下,Hippy的包加載速度提高80%,首幀耗時優化50%起。下面我將展開講述。

圖片

Hippy業務耗時瓶頸分析

Hippy整個啓動流程依賴JS線程的執行。我們其實可以將整個過程抽象看成一個串行的操作,以QB冷啓動首頁Feed流,結合線上數據性能監控可以看到如下階段耗時

圖片

注:TTI = Time To Interact,意思是從業務創建到業務可交互所花費的時間,因爲衡量業務可交互比較複雜,各個業務對可交互的定義不一樣,所以這裏以首幀上屏爲準來衡量;

通過打點分析得到,用戶從打開業務創建RootView開始,到最終首幀上屏總共耗時1488毫秒,其中主要在Module初始化、創建HippyCore(bootstrap.js以及common包執行耗時)、業務包執行耗時上。其中加載執行業務包耗時1303毫秒,佔整體TTI的87%。

如果我們能夠優化加載執行業務包的耗時,那麼我們就可以極大的降低TTI。在iOS上Hippy使用的是系統提供的JavascriptCore引擎來運行JS代碼,所以我們要分析一下JSC的執行過程。

圖片

JavascriptCore執行流程分析

具體流程:詞法分析,輸出tokens;語法分析,生產AST(抽象語法樹);從AST生成字節碼; 通過Low Level解釋器執行字節碼;使用JIT加速解釋執行機器碼(帶JIT的版本)。

圖片

注:本文JSC是指蘋果官方提供的JavascriptCore.framework,JSC分帶JIT與不帶JIT的版本,帶JIT的版本目前只有蘋果自家的Safari能夠使用,公開的JavascriptCore因爲安全原因(JIT可以動態執行機器碼),實際是不帶JIT的版本。下面討論的也是指不帶JIT的JSC版本。

整個流程,在JS代碼被解釋執行前,絕大部分時間消耗是在字節碼生成上。如果能將Bytecode生成前置緩存起來,每次執行JS的時候直接取緩存的Bytecode,那將會極大降低耗時。但是很可惜的是,JavascriptCore屬於系統庫,並沒有提供這個能力。我們可以考慮選擇其他支持Bytecode的引擎替換掉JSC。

圖片

可選引擎對比

除了JSC,常見的開源引擎包括V8、QuickJS、Hermes。

注:直出是指支持編譯輸出Bytecode文件,並且直接運行Bytecode。

Hermes和QuickJS支持直出Bytecode,並且在包大小上對比V8和JSC佔優。

1)性能指標對比

以下各項對比取至Linux上各引擎測試數據

  • 包加載耗時速度對比(越低越好)

使用引擎執行業務JS代碼,其中JSC和V8均是直接執行JS代碼,QuickJS和Hermes是執行Bytecode。

圖片

QuickJS一騎絕塵,Hermes緊跟其後,JSC次之,V8最差;

  • 執行效率對比(越高越好)

使用引擎跑一些開源的算法或者知名JS功能庫。

圖片

圖片

V8和JSC性能最好,Hermes次之,QuickJS最差;

  • 內存增量(越低越好)

圖片

圖片

表現最好的是JSC,其次是Hermes和V8;帶JIT的JSC和V8,內存消耗最高;

  • 編譯文件大小

衡量編譯文件壓縮比是爲了衡量包下發更新效率,以QB首頁Feed流(3.8M左右)舉例,JSC和V8均輸入原始js文件,QuickJS和Hermes輸入JS編譯後的Bytecode文件。

圖片

JSC和V8壓縮比較高,Hermes和QuickJS壓縮比不高,在下發效率上,差於JSC和V8;

2)結論

從執行耗時、執行性能、內存增量、編譯文件大小以及整體framework大小5個緯度來分析看: 帶JIT的JSC和V8性能最好,但是加載時間是最長的,內存消耗也是最多的,包也較大;支持提前預編譯的Hermes和QuickJS,加載速度以及內存表現是最好的。‍

對於提高TTI,加載速度指標最爲重要。雖然性能低於JSC和V8,但是對於JS耗時高的操作,可以充分利用modules放在Native去操作;所以基於以上,會優先考慮Hermes和QuickJS;

Hermes在性能、內存以及編譯包大小上是優於QuickJS的,另外Hermes有Facebook的React Native社區生態支持,相較於QuickJs更新演進更快,所以更傾向使用Hermes來替換JSC。

圖片Hermes引擎調研

1)編譯

Hermes雖然是深度集成在React Native裏的,但是facebook也將單獨的引擎獨立出來了,官網地址 倉庫地址 編譯指南。

按照編譯指南編譯之後,實際編譯的產物只是用於在PC/Mac/Linux運行的Hermes二進制文件。通過這些二進制文件,我們可以在Terminal裏執行JS,以及將JS編譯成Bytecode。

# 執行原始JS 
hermes test.js
# 編譯並輸出以及執行Bytecode 
hermes -emit-binary -out test.hbc test.js hermes test.hbc

在移動端上,Hermes也是使用CMake進行編譯,並且提供了腳本可以方便輸出Android和iOS動態庫。具體可以在官網上查看編譯指南。

2)運行

Hermes包含幾個非常重要的結構對象,下面主要講其中的幾個。

  • Runtime

Hermes使用非常簡單,提供了一個Runtime的抽象類,所有的js對象都執行在Runtime對象上,類似JSC的JSContext;派生了HermesRuntime子類來實現所有JS操作。通過靜態方法創建一個HermesRuntime對象;

HERMES_EXPORT std::unique_ptr<HermesRuntime> makeHermesRuntime(
    const ::hermes::vm::RuntimeConfig &runtimeConfig =
        ::hermes::vm::RuntimeConfig());

同時也提供了一些執行JS的方法

// 執行JS(JS or Bytecode)
  virtual Value evaluateJavaScript(
      const std::shared_ptr<const Buffer>& buffer,
      const std::string& sourceURL) = 0;

  // 預編譯JS
  virtual std::shared_ptr<const PreparedJavaScript> prepareJavaScript(
      const std::shared_ptr<const Buffer>& buffer,
      std::string sourceURL) = 0;

  // 執行預編譯的JS
  virtual Value evaluatePreparedJavaScript(
      const std::shared_ptr<const PreparedJavaScript>& js) = 0;
  • Value

JSC在處理基礎數據的時候,所有的類型都是JSValue類型;處理Object是JSObjectRef對象,在Hermes上也有對應的實現;

圖片

提供方法判斷是什麼類型,以及快捷獲取類型值,比如:

Bool isStr = value.isString()
facebook::jsi::String str = value.asString()
  • Object

Object對應就是JS的對象,基於Object派生Function以及Array和JSArrayBuffer,同樣Object也提供很多方法獲取和設置屬性。‍

Runtime提供一個默認的全局對象global, 所有的JS邏輯均運行在默認的global之上。Object也提供很對方法獲取屬性,比如:

//判斷是否有該屬性
bool hasProperty(Runtime& runtime, const char* name) const;

// 獲取屬性值
Value getProperty(Runtime& runtime, const char* name) const;

// 獲取屬性值並轉化成object
Object getPropertyAsObject(Runtime& runtime, const char* name) const;
  • Function

對應JS的Function,提供靜態方法創建Function:

// 判斷是否有該屬性
bool hasProperty(Runtime& runtime, const char* name) const;

// 獲取屬性值
Value getProperty(Runtime& runtime, const char* name) const;

// 獲取屬性值並轉化成object
Object getPropertyAsObject(Runtime& runtime, const char* name) const;

提供實例方法調用:

static Function createFromHostFunction(
      Runtime& runtime,
      const jsi::PropNameID& name,
      unsigned int paramCount,
      jsi::HostFunctionType func);

同樣還有Array,ArrayBuffer,HostObject等等。

通過Runtime,我們可以獲取JS Object、Function,同時我們也可以創建JS Object、Function,注入給JS,這樣就可以實現雙向通信。

圖片Hippy2.0架構分析

1)架構

圖片

包含三層:

和平臺相關的能力擴展比如Module能力和UI組件,以及調用底層HippyCore的接口封裝的Bridge和JS Executor層,該層在iOS和Android上分別使用OC和JAVA實現。‍

HippyCore層,通過napi對不同JS引擎的接口進行接口封裝,抹平不同引擎的接口差異,讓上層調用通過調用簡單的接口實現複雜的能力,該層使用C++實現,跨平臺。

前端JS SDK層,主要是定義了雙向通信的方法函數跟上層進行通信以及功能處理。

另外還包括一些能力,基本是在hippycore層實現。比如C++ Modules, TurboModules等。

我們需要切換引擎,上下兩層其實都不需要特別(大量)修改,核心就是在hippycore層,需要使用hermes將napi定義的接口全部實現一遍,以及同時實現現在已經有的Abilites。

  • napi

主要有幾種概念:Engine:負責創建VM以及Scope;VM:負責創建管理Ctx,一個VM可以創建一個或者多個Ctx;Ctx:負責創建引擎實例,並封裝操作引擎的接口供外部調用;CtxValue:負責封裝不同引擎的JS Value;Scope:使用Ctx,執行Hippy基礎初始化流程。‍

  • Scope

主要負責Hippy基礎初始化流程,核心步驟如下:

圖片

注入Natives方法

通過給JS注入Native Function方法的方式,讓JS可以直接調用終端方法;主要是常見的JS側CallNative方法均通過此進行分發。

執行JS Native Source Code

Hippy將一部分基礎JS SDK代碼,通過腳本將JS代碼轉換成二進制集成在hippycore的C++代碼裏,在通過Ctx執行這些JS代碼。好處是:解決C++ Module跟JS側代碼一致性問題(均使用C++形式加載調用);對於常用的基礎JS的SDK代碼,不用打包到基礎包裏,可以減少Common包大小,另外職責也分離。

其中包括C++ Module跟JS對象綁定,以及TurboModule和DynamicImport均在此步驟進行定義實現;

  • Abilities

C++ Module:不同於Native Module字符串消息映射和TurboModule HostObject的實現,C++ Module是將HippyCore裏標記爲導出的C++Module和其函數對應在前端生成一個名字一樣的JS對象和方法。Hippy裏常見的TimeModule,ContextifyModule均是如此實現。

TurboModule:前有NativeModule,後有C++Module,爲什麼還有TurboModle?

NativeModule好處是對於一些能力要分端去實現的,兩端實現起來比較方便,但是其是通過字符串映射到終端方法的方式進行調用以及存在JS線程到NativeModule線程切換效率問題。‍

C++Module的好處就是在JS線程直接調用綁定JS對象和方法執行,效率高,但是暴露的Module是用C++實現,如果分發調用到Native側,一個是要區分平臺,第二個是分發到上層Java或者OC需要對應的類型轉換。

爲了解決上述問題,TuroboModule應運而生,兼具JS線程直接調用,並且不同平臺可以分別實現自己的Turbo能力,關鍵是直接使用的引擎提供的HostObject方式實現,相較於C++Module 效率都更高。

Dynamic Import:動態導入能力,容許在JS側動態加載遠程或者本地JS代碼,主要使用場景是對於分包加載,減少主包大小,提高業務加載包速度;最終實現也是通過C++Module ContextifyModule的LoadUntrustedContent方法來執行遠端或者本地JS代碼並返回給JS側。

HippyCore異常處理JS引擎接口異常,不同引擎異常不同(JSI Exception);Native異常,主要是Native側的代碼調用以及JS方法注入實現異常。

JSC引擎和V8處理邏輯不太一樣,JSC的JSI接口會將Exception通過參數傳遞出來,V8是通過在調用上下文初始化TryCatch對象,對異常進行捕獲。

所以對於JSC的JS異常,只需要處理接口的Exception就行;V8處理TryCatch對象捕獲的異常就可以。‍

SValueRef js_error = nullptr;
  JSValueRef value_ref =
      JSObjectGetProperty(context_, global_obj, name_ref, &js_error);
  bool is_str = JSValueIsString(context_, value_ref);
  JSStringRelease(name_ref);

Native異常一般就是平臺相關的異常,比如OC就是NSException,在雙向通信以及各種JS接口注入實現處加Try-Catch進行捕獲。

2)總結

通過以上架構分析,Hippy整個實現流程都已經變得非常清晰,我們可以使用Hermes的能力將上述能力均實現一下。

圖片

Hermes接入對比

1)性能

基於已經上線的業務性能統計數據(數據取至12月12日),對比如下:

圖片

可以看到包加載執行耗時已經被徹底打下來了(70-80%幅度),進而極大降低了首幀耗時。

另外通過線上業務大盤整體耗時曲線圖可以更直觀看到效果(大部分業務沒有全量,所以還會有持續下降的趨勢):

圖片

圖片

2)內存

在滑動相同的的List Item的情況下,Hippy Hermes和JSC的內存增量差別不大。根據官方文檔介紹Hermes應該是略優於JSC的,所以這裏不排除Hippy或者前端SDK還有優化空間。

3)Crash

Hippy的JSC相關的Crash率較高,比較難修改。Hermes也有一定的crash,但是從目前的對比來看,數量級較JSC少很多。以12月12日,iOS 13.4.0.5401版本的數據對比來看,Hermes的Crash率爲JSC的50%,也就是說如果切換到Hermes上的話,相關引擎的Crash會下降一半。

圖片

JSC Crash關鍵詞:jscctx/HippyJSCExecutor   Hermes Crash關鍵詞:hermes/HippyHermesExecutor

圖片

展望

目前Hermes已經在QB iOS版本上上線。業務接入成本非常低,無需修改一行代碼,只需要打包的時候使用插件,輸出Bytecode文件即可。接入上線的業務已經遍佈信息流、閱讀、商業、搜索等各個業務場景。

當然,還有很多事情可以持續做以持續提升性能: Android接入,對比V8性能,已經接近完成(對比V8,在低中端手機上有近50%的性能提升)。Hermes調試能力,可以使用Hermes在Chrome上調試JS代碼。 基於Hermes的內存調試診斷工具。本文不展開贅述,歡迎各位開發者交流探索~

通過接入Hermes,可以讓業務更多的關注在JS業務邏輯裏,讓前置SDK流程的耗時不再是性能瓶頸。希望本文能給你靈感。

公衆號回覆“性能優化“,查看作者推薦的更多文章‍‍‍

騰訊工程師技術乾貨直達:

1、H5開屏從龜速到閃電,企微是如何做到的

2、內存泄露?騰訊工程師2個壓箱底的方法和工具

3、全網首次揭祕:微秒級“復活”網絡的HARP協議及其關鍵技術

4、萬字避坑指南!C++的缺陷與思考(下)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章