自 2019 年首次釋出以來,小巧輕便的 JavaScript 引擎 Hermes 在社群中的名氣越來越高,很多的框架也開始支援Hermes。作為 React Native 領域高人氣元框架的締造者,Expo 團隊此前公佈了對 Hermes 的實驗性支援。另外,流行移動資料庫 Realm 團隊近期也決定為 Hermes 提供 alpha 支援。
在本文中,我們希望重點介紹過去兩年來在推動 Hermes 成為 React Native 最佳 JavaScript 引擎方面取得的各項激動人心的進展。展望未來,我們有信心通過更多改進讓 Hermes 成為各類平臺上 React Native 中的預設 JavaScript 引擎。
專為 React Native 而優化
Hermes 中的功能定義,負責指示要如何提前執行編譯工作。換言之,啟用 Hermes 的 React Native 應用程式會附帶經過預編譯優化的位元組碼,而非純 JavaScript 原始碼。這就大大減少了使用者啟動產品所需要的工作量。來自 Facebook 及社群其他應用的量化測試表明,啟用 Hermes 通常能夠將產品的 TTI(即互動時間)指標縮短近一半。
但我們不會止步於此,始終致力於對 Hermes 進行全方位改進,努力讓它成為最出色的 React Native 專用 JavaScript 引擎。
為 Fabric 建立新的垃圾收集器
在新一代 React Native 架構中嶄露頭角的 Fabric 渲染器可謂萬眾矚目,它能夠在 UI 執行緒上同步呼叫 JavaScript。但如果 JavaScript 執行緒的執行時間過長,則會導致明顯的 UI 丟幀、令使用者無法正常輸入。
React Fiber 提供的併發渲染機制能夠將渲染工作拆分成多個塊,由此避免單一 JavaScript 任務佔用過長時間。此外,JavaScript 執行緒當中還有另一大常見延遲來源——垃圾收集(GC)機制。因為一旦開始垃圾收集,整個 JavaScript 引擎必須放下手頭的所有工作去執行垃圾收集。
Hermes 當中的原有預設垃圾收集器 GenGC 屬於單執行緒分代垃圾收集方案。其中會對新生代採用典型的半空間複製策略,而對老年代則使用 mark-compact 策略、從而更主動將記憶體返還至作業系統。
在像 Facebook for Android 這樣的複雜應用上,我們觀察到的平均暫停時長為 200 毫秒,而第 99 百分位暫停則為 1.4 秒。考慮到 Facebook for Android 龐大且多樣化的使用者群體,最極端的暫停時間甚至長達 7 秒。
為了緩解這種情況,我們建立起全新的、以併發為主要取向的垃圾回收方案,即 Hades。Hades 同樣採用分代設計,其新生代回收方式與 GenGC 完全相同,而老年代回收方式則通過快照式標記掃描收集器進行管理。
Hades 能夠將大部分工作負載交由後臺執行緒執行,從而顯著縮短垃圾回收暫停時長,同時不會阻止引擎主執行緒繼續執行 JavaScript 程式碼。我們的統計資料顯示,Hades 在 64 位裝置上第 99.9 百分位上的延遲為 48 毫秒(比 GenGC 快 34 倍!),而在 32 位裝置上第 99.9 百分位上的延遲約 88 毫秒(以單執行緒增量 GC 的形式執行)。
但由於需要資源成本更高的寫屏障、速度更慢的基於空閒列表的分配機制(與碰撞指標分配器相反)以及更多的堆碎片,Hades 實際是在用整體吞吐量來換取更短的暫停時間。我們認為這樣的取捨符合使用者習慣,也將通過合併與接下來將要討論的其他記憶體優化機制,實現更低的整體記憶體佔用量。
改善效能問題
應用程式的啟動時長對很多應用產品來說至關重要,我們也希望不斷提升 React Native 的效能上限。對於在 Hermes 當中實現的一切 JavaScript 功能,我們都會認真監測它們對生產效能造成的影響,並確保它們不會拉低效能指標。
在 Facebook,我們目前正在為 Metro 中的 Hermes 試驗一個專用的 Babel transform profile,希望用 Hermes 中的原生 ESNext 實現替換掉原本的十餘種 Babel transform。通過這種方式,我們已經在直接觀察中將 TTI 改進了 18% 至 25%,整體位元組碼獲得顯著瘦身;希望接下來也能在 OSS 中得到類似的結果。
除了啟動效能之外,我們還將記憶體佔用量視為改進 React Native 應用程式的重要機會,這也是成就良好虛擬現實體驗的前提。因此在 JavaScript 引擎的底層控制當中,我們利用壓縮位與位元組實現了多輪記憶體優化:
- 此前,所有 JavaScript 值都被表示為 64 位 NaN 裝箱編碼的標記值形式,用以表示 64
位架構上的雙精度浮點值與指標。但這種方式在實踐中屬於巨大浪費,因為大多數數字實際都屬於小整數(SMI),而且客戶端應用程式的JavaScript 堆通常不會大於 4 GiB。為了解決這個問題,我們引入了一種新的 32 位編碼,其中 SMI 與指標以 29位編碼(因為指標為 8 位元組對齊,可以假設底部 3 位始終為零),其餘的 JS 數字則裝箱在堆上。如此一來,JavaScript的堆大小就縮減了約 30%。 - 不同類別的 JavaScript 物件在 JS 堆上表示為不同的 GC 管理單元。通過主動優化這些單元頭的記憶體佈局,我們得以將記憶體佔用量進一步削減約 15%。
對此,我們在 Hermes 中還做出一項關鍵決定,即不再採用即時(JIT)編譯器。因為我們認為對於大多數 React Native 應用程式來說,額外的預熱成本與額外的二進位制檔案乃至記憶體佔用量並沒有實際意義。
多年以來,我們在直譯器效能與編譯器的優化方面投入了大量精力,也讓 Hermes 獲得了遠超其他引擎的 React Native 工作負載吞吐量優勢。我們將繼續關注廣泛存在的效能瓶頸(直譯器排程迴圈、堆疊佈局、物件模型、垃圾回收等)以提高吞吐量。
垂直整合領域
在 Facebook,我們習慣於使用大型 monorepo 託管專案。通過將引擎(Hermes)與 host(React Native)緊密迭代在一起,現在我們為垂直整合開闢出廣闊空間。以下是幾個具體的例子:
- Hermes 使用 Chrome DevTools 協議支援使用 Chrome 偵錯程式在裝置上執行 JavaScript 除錯。這種方法比傳統“遠端 JS 除錯”(使用應用內代理在桌面 Chrome 上執行 JS)效果更好,因為它支援除錯同步本機呼叫並能保證統一的執行時環境。與 React DevTools、Metro 以及 Inspector 等一道,Hermes 偵錯程式現已成為 Flipper 中的組成部分,共同提供良好的一站式開發者體驗。
- 在 React Native 應用的初始化路徑中分配的物件往往長期存在,而且並不符合分代 GC 所提出的分代假設。因此,我們在 React Native 中配置 Hermes 時,會將前 32 MiB 直接分配至老年代(即 pre-tenuring)以避免觸發 GC 暫停與延遲 TTI。
- 新的 React Native 架構在很大程度上基於 JSI(即 JavaScript Interface),這是一種輕量級通用 API,用於將 JavaScript 引擎嵌入至 C++ 程式當中。通過讓維護 JS 引擎的團隊同時維護 JSI API 實現,我們有信心提供最佳整合效果。而且這套整合方案已經在 Facebook 的大規模業務之上經過實戰測試,擁有良好的可靠性與執行效率。
- 擁有語義正確且效能良好的 JavaScript 併發原語(例如 promises)及平臺併發原語(例如 microtasks),對於 React 併發渲染以及 React Native 應用程式的未來發展可謂至關重要。從歷史上看,React Native 中的 promise 是使用非標準化 setImmediate API 作為膩子指令碼。我們正努力通過 JSI 實現來自 JS 引擎的原生 promises 和 microtasks,並將 queueMicrotask(Web 標準中的新增專案)引入平臺,從而更好地支援現代非同步 JavaScript 程式碼。
社群發展
Facebook 公司非常重視 Hermes 專案,但只有為 Hermes 建立起完整的生態系統、特別是技術社群,開發工作才算真正告一段落。也只有這樣,每個人才能充分運用 Hermes 的功能併發揮其潛力。
擴充套件至更多新平臺
Hermes 最初僅面向 React Native on Android 開源。在此之後,我們很高興看到社群成員們逐漸擴充 Hermes 的支援範圍,目前已經將其擴充套件到 React Native 生態系統所覆蓋的多種其他平臺。
Callstack 率先在 React Native 0.64 當中將 Hermes 引入 iOS。他們還發布了系列專題文章,併發起播客向使用者們介紹他們如何實現這一目標。根據基準測試,與 Mattermost 應用的 JSC 相比,Hermes 在 iOS 上的啟動效能可穩定提升約 40%、記憶體佔用量減少約 18%、應用程式執行期間的記憶體用量僅為 2.4 MiB。
微軟則不斷將 Hermes 引入 Windows 與 MacOS 上的 React Native。在微軟 Build 2020 大會上,軟體巨頭表示相較於原本的 Chakra 引擎,Hermes 能夠將 React Native for Windows 的記憶體佔用量降低 13%。而在最近的一些綜合基準測試中,微軟發現 Hermes 0.8(包含 Hades 及之前提到的 SMI 與指標壓縮優化功能)佔用的記憶體量比其他引擎少 30% 至 40%。毫無疑問,基於 React Native 的桌面版 Messenger 視訊通話體驗也在 Hermes 的支援下得到顯著改善。
更重要的是,Hermes 還一直在為 Oculus 上使用 React 系列技術構建的各類虛擬現實體驗提供支援,其中也包括 Oculus Home。
社群支援
我們承認,目前 Hermes 身上仍有一些問題阻礙著更多社群的順暢介入,我們也在努力為這些缺失的功能建立支援。我們的目標是儘快實現功能完備,讓 Hermes 成為大多數 React Native 應用程式的最佳選擇。以下是社群正在籌劃的 Hermes 發展路線圖:
- 由於 Facebook 並不使用,所以 Proxy 與 Reflect 最初被排除在 Hermes 之外。我們當時擔心即使不真正使用,貿然新增 Proxy 也會損害屬性查詢效能。但隨著 MobX 與 Immer 等庫的流行,Proxy 很快成為 Hermes 當中最受歡迎的功能。經過認真評估,我們決定針對社群提供專用 Proxy,而且設法以極低的成本完成實現。由於 Facebook 並不使用此功能,所以只能依靠技術社群證明其穩定性。我們首先在 0.4 與 0.5 版本中以標記和建立 opt-in npm 包的形式啟動了 Proxy 測試,並從 0.7 版本開始將其預設啟用。
- ECMAScript 國際化 API 規範(簡稱 ECMA-402 或 Intl)同樣是使用者呼聲中的焦點。Intl 代表一組龐大的 API,通常需要包含 6 MB 大小的 Unicode CLDR 資料才能實現。正因為如此,FormatJS(又名 react-intl)等膩子指令碼以及社群 JSC 的國際變體 build 等 JS 引擎才如此臃腫笨拙。為了避免 Hermes 二進位制檔案體積的不必要膨脹,我們決定直接使用並對映作業系統內建庫所提供的 ICU facilities 來實現,相應的代價就是給某些跨平臺行為引入一些(通常較為微小的)差異。
- 微軟合作完成了 Android 上的 build 支援工作。其中幾乎涵蓋從 ECMA-402 到 ES2020 的所有內容,而對體積的影響僅有 3%(每個 ABI 僅為 57 K 到 62 K)。我們在 Twitter 上發起的民意調查發現,使用者們強烈支援預設包含 Intl,所以我們決定從 0.8 版本開始引入這項功能。
- Facebook 已經贊助 Major League Hacking 發起遠端開源獎學金計劃。去年,我們推出了 Hermes 取樣分析器;今年,我們的研究員將與 Hermes、React Native 以及 Callstack 的成員們合作,在 iOS 上實現對 Hermes Intl 的支援。
- 感謝大家反饋中提到的預設堆大小上限太低問題,導致很多不熟悉自定義 Hermes GC 配置的使用者會產生不必要的 GC 壓力乃至 OOM 崩潰。因此在預設情況下,我們將上限由 512 MiB 增加到 3 GiB,這樣的配置對大多數使用者來說應該綽綽有餘。
- 有報告稱,我們專用的 Function.prototype.toString 實現會導致庫執行不正確的特徵檢測並導致效能下降,而且令使用者無法執行原始碼注入。在解決問題的同時,我們也更加堅定了 Hermes 應該尊重事實、儘量避免妨礙開發者順暢使用的決心。
總結
總之,我們的願景是讓 Hermes 成為一切 React Native 平臺上的預設 JavaScript 引擎。我們正在朝著這個方向努力,也希望充分聽取大家來自不同角度的反饋意見。
只有做好萬全準備,我們才能為生態系統廣泛接納 Hermes 奠定堅實的基礎。在這裡,我們誠邀大家體驗 Hermes,並將您發現的一切建議、意見、功能請求與不相容性錯誤提交給我們的 GitHub repo。