React Native 新架構是如何工作的?

目前 React Native 新架構所依賴的 React 18 已經發了 beta 版,React Native 新架構面向生態庫和核心開發者的文檔也正式發佈,React Native 團隊成員 Kevin Gozali 也在最近一次訪談中談到新架構離正式發版還差最後一步延遲初始化,而最後一步大約會在 2022 年上半年完成。種種跡象表明,React Native 新架構真的要來了。

後續也會通過極客時間專欄的形式和大家介紹新架構的使用方法、剖析架構原理、講解實踐方案。

由於時間倉促,如果有翻譯不當之處還請大家指出,以下是正文部分。

本文檔還在更新持續中,會從概念上介紹 React Native 新架構是如何工作的。目標讀者包括生態庫的開發者、核心貢獻者和特別有好奇心的人。文檔介紹了即將發佈的新渲染器 Fabric 的架構。

Fabric

Fabric 是 React Native 新架構的渲染系統,是從老架構的渲染系統演變而來的。核心原理是在 C++ 層統一更多的渲染邏輯,提升與宿主平臺(host platforms)互操作性,併爲 React Native 解鎖更多能力。研發始於 2018 年和 2021 年,Facebook 應用中的 React Native 用的就是新的渲染器。

該文檔簡介了新渲染器(new renderer)及其核心概念,它不包括平臺細節和任何代碼細節,它介紹了核心概念、初衷、收益和不同場景的渲染流程。

名詞解釋:

宿主平臺(Host platform):React Native 嵌入的平臺,比如 Android、iOS、Windows、macOS。

Fabric 渲染器(Fabric Renderer):React Native 執行的 React 框架代碼,和 React 在 Web 中執行代碼是同一份。但是,React Native 渲染的是通用平臺視圖(宿主視圖)而不是 DOM 節點(可以認爲 DOM 是 Web 的宿主視圖)。Fabric 渲染器使得渲染宿主視圖變得可行。Fabric 讓 React 與各個平臺直接通信並管理其宿主視圖實例。Fabric 渲染器存在於 JavaScript 中,並且它調用的是由 C++ 代碼暴露的接口。在這篇文章中有更多關於 React 渲染器的信息。

新渲染器的初衷和收益

開發新的渲染架構的初衷是爲了更好的用戶體驗,而這種新體驗是在老架構上是不可能實現的。比如:

新架構的收益還包括,代碼質量、性能、可擴展性。

名詞解釋

JavaScript Interfaces (JSI):一個輕量級的 API,給在 C++ 應用中嵌入的 JavaScript 引擎用的。Fabric 使用它在 Fabric 的 C++ 核心和 React 之間進行通信。

渲染、提交和掛載

React Native 渲染器通過一系列加工處理,將 React 代碼渲染到宿主平臺。這一系列加工處理就是渲染流水線(pipeline),它的作用是初始化渲染和 UI 狀態更新。接下來介紹的是渲染流水線,及其在各種場景中的不同之處。

(譯註:pipeline 的原義是將計算機指令處理過程拆分爲多個步驟,並通過多個硬件處理單元並行執行來加快指令執行速度。其具體執行過程類似工廠中的流水線,並因此得名。)

渲染流水線可大致分爲三個階段:

名詞解釋

React 元素樹(React Element Trees):React 元素樹是通過 JavaScript 中的 React 創建的,該樹由一系類 React 元素組成。一個 React 元素就是一個普通的 JavaScript 對象,它描述了應該在屏幕中展示什麼。一個元素包括屬性 props、樣式 styles、子元素 children。React 元素分爲兩類:React 複合組件實例(React Composite Components)和 React 宿主組件(React Host Components)實例,並且它只存在於 JavaScript 中。

React 影子樹(React Shadow Tree):React 影子樹是通過 Fabric 渲染器創建的,樹由一系列 React 影子節點組成。一個 React 影子節點是一個對象,代表一個已經掛載的 React 宿主組件,其包含的屬性 props 來自 JavaScript。它也包括佈局信息,比如座標系 x、y,寬高 width、height。在新渲染器 Fabric 中,React 影子節點對象只存在於 C++ 中。而在老架構中,它存在於手機運行時的堆棧中,比如 Android 的 JVM。

宿主視圖樹(Host View Tree):宿主視圖樹就是一系列的宿主視圖。宿主平臺有 Android 平臺、iOS 平臺等等。在 Android 上,宿主視圖就是 android.view.ViewGroup實例、 android.widget.TextView實例等等。宿主視圖就像積木一樣地構成了宿主視圖樹。每個宿主視圖的大小和座標位置基於的是 LayoutMetrics,而  LayoutMetrics是通過佈局引擎 Yoga 計算出來的。宿主視圖的樣式和內容信息,是從 React 影子樹中得到的。

渲染流水線的各個階段可能發生在不同的線程中,更詳細的信息可以參考線程模型部分。

渲染流水線存在三種不同場景:

  1. 初始化渲染

  2. React 狀態更新

  3. React Native 渲染器的狀態更新

初始化渲染

渲染階段

想象一下你準備渲染一個組件:

function MyComponent() {
  return (
    <View>
      <Text>Hello, World</Text>
    </View>
  );
}

// <MyComponent />

在上面的例子中,<MyComponent />是 React 元素。React 會將 React 元素簡化爲最終的 React 宿主組件。每一次都會遞歸地調用函數組件  MyComponet ,或類組件的  render 方法,直至所有的組件都被調用過。現在,你擁有一棵 React 宿主組件的 React 元素樹。

名詞解釋:

React 組件(React Component):React 組件就是 JavaScript 函數或者類,描述如何創建 React 元素。

React 複合組件(React Composite Components):React 組件的 render 方法中,包括其他 React 複合組件和 React 宿主組件。(譯註:複合組件就是開發者聲明的組件)

React 宿主組件(React Host Components):React 組件的視圖是通過宿主視圖,比如 <View><Text>,實現的。在 Web 中,ReactDOM 的宿主組件就是 <p>標籤、<div>標籤代表的組件。

在元素簡化的過程中,每調用一個 React 元素,渲染器同時會同步地創建 React 影子節點。這個過程只發生在 React 宿主組件上,不會發生在 React 複合組件上。比如,一個 <View>會創建一個 ViewShadowNode 對象,一個<Text>會創建一個TextShadowNode對象。注意,<MyComponent>並沒有直接對應的 React 影子節點。

在 React 爲兩個 React 元素節點創建一對父子關係的同時,渲染器也會爲對應的 React 影子節點創建一樣的父子關係。這就是影子節點的組裝方式。

其他細節

在上面的示例中,各個渲染階段的產物如圖所示:

提交階段

在 React 影子樹創建完成後,渲染器觸發了一次 React 元素樹的提交。

提交階段(Commit Phase)由兩個操作組成:佈局計算和樹的提升。

更多細節

掛載階段

掛載階段(Mount Phase)會將已經包含佈局計算數據的 React 影子樹,轉換爲以像素形式渲染在屏幕中的宿主視圖樹。請記住,這棵 React 元素樹看起來是這樣的:

<View>
  <Text>Hello, World</Text>
</View>

站在更高的抽象層次上,React Native 渲染器爲每個 React 影子節點創建了對應的宿主視圖,並且將它們掛載在屏幕上。在上面的例子中,渲染器爲<View> 創建了android.view.ViewGroup 實例,爲  <Text> 創建了文字內容爲 “Hello World” 的 android.widget.TextView實例 。iOS 也是類似的,創建了一個 UIView 並調用 NSLayoutManager 創建文本。然後會爲宿主視圖配置來自 React 影子節點上的屬性,這些宿主視圖的大小位置都是通過計算好的佈局信息配置的。

更詳細地說,掛載階段由三個步驟組成:

更多細節

React 狀態更新

接下來,我們繼續看 React 狀態更新時,渲染流水線(render pipeline)的各個階段是什麼樣的。假設你在初始化渲染時,渲染的是如下組件:

function MyComponent() {
  return (
    <View>
      <View
        style={{ backgroundColor: 'red', height: 20, width: 20 }}
      />
      <View
        style={{ backgroundColor: 'blue', height: 20, width: 20 }}
      />
    </View>
  );
}

應用我們在初始化渲染部分學的知識,你可以得到如下的三棵樹:

請注意,節點 3 對應的宿主視圖背景是 紅的,而 節點 4 對應的宿主視圖背景是 藍的。假設 JavaScript 的產品邏輯是,將第一個內嵌的<View>的背景顏色由紅色改爲黃色。新的 React 元素樹看起來大概是這樣:

<View>
  <View
    style={{ backgroundColor: 'yellow', height: 20, width: 20 }}
  />
  <View
    style={{ backgroundColor: 'blue', height: 20, width: 20 }}
  />
</View>

React Native 是如何處理這個更新的?

從概念上講,當發生狀態更新時,爲了更新已經掛載的宿主視圖,渲染器需要直接更新 React 元素樹。但是爲了線程的安全,React 元素樹和 React 影子樹都必須是不可變的(immutable)。這意味着 React 並不能直接改變當前的 React 元素樹和 React 影子樹,而是必須爲每棵樹創建一個包含新屬性、新樣式和新子節點的新副本。

讓我們繼續探究狀態更新時,渲染流水線的各個階段發生了什麼。

渲染階段

React 要創建了一個包含新狀態的新的 React 元素樹,它就要複製所有變更的 React 元素和 React 影子節點。複製後,再提交新的 React 元素樹。

React Native 渲染器利用結構共享的方式,將不可變特性的開銷變得最小。爲了更新 React 元素的新狀態,從該元素到根元素路徑上的所有元素都需要複製。但 React 只會複製有新屬性、新樣式或新子元素的 React 元素,任何沒有因狀態更新發生變動的 React 元素都不會複製,而是由新樹和舊樹共享。

在上面的例子中,React 創建新樹使用了這些操作:

  1. CloneNode(Node 3, {backgroundColor: 'yellow'}) → Node 3'

  2. CloneNode(Node 2) → Node 2'

  3. AppendChild(Node 2', Node 3')

  4. AppendChild(Node 2', Node 4)

  5. CloneNode(Node 1) → Node 1'

  6. AppendChild(Node 1', Node 2')

操作完成後,節點 1'(Node 1') 就是新的 React 元素樹的根節點。我們用 T 代表 “先前渲染的樹”,用 T' 代表 “新樹”。

注意節點 4 在 T and T' 之間是共享的。結構共享提升了性能並減少了內存的使用。

提交階段

在 React 創建完新的 React 元素樹和 React 影子樹後,需要提交它們。

掛載階段

React Native 渲染器狀態更新

對於影子樹中的大多數信息而言,React 是唯一所有方也是唯一事實源。並且所有來源於 React 的數據都是單向流動的。

但有一個例外。這個例外是一種非常重要的機制:C++ 組件可以擁有狀態,且該狀態可以不直接暴露給 JavaScript,這時候 JavaScript (或 React)就不是唯一事實源了。通常,只有複雜的宿主組件纔會用到 C++ 狀態,絕大多數宿主組件都不需要此功能。

例如,ScrollView 使用這種機制讓渲染器知道當前的偏移量是多少。偏移量的更新是宿主平臺的觸發,具體地說是 ScrollView 組件。這些偏移量信息在 React Native 的 measure 等 API 中有用到。因爲偏移量數據是由 C++ 狀態持有的,所以源於宿主平臺更新,不影響 React 元素樹。

從概念上講,C++ 狀態更新類似於我們前面提到的 React 狀態更新,但有兩點不同:

提交階段(Commit Phase):在執行 C++ 狀態更新時,會有一段代碼把影子節點 (N) 的 C++ 狀態設置爲值 S。React Native 渲染器會反覆嘗試獲取 N 的最新提交版本,並使用新狀態 S 複製它 ,並將新的影子節點 N' 提交給影子樹。如果 React 在此期間執行了另一次提交,或者其他 C++ 狀態有了更新,本次 C++ 狀態提交失敗。這時渲染器將多次重試 C++ 狀態更新,直到提交成功。這可以防止真實源的衝突和競爭。

掛載階段(Mount Phase)實際上與 React 狀態更新的掛載階段相同。渲染器仍然需要重新計算佈局、執行樹對比等操作。詳細步驟在前面已經講過了。

跨平臺實現

React Native 渲染器使用 C++ core 渲染實現了跨平臺共享。

在上一代 React Native 渲染器中,React 影子樹、佈局邏輯、視圖拍平算法是在各個平臺單獨實現的。當前的渲染器的設計上採用的是跨平臺的解決方案,共享了核心的 C++ 實現。

React Native 團隊計劃將動畫系統加入到渲染系統中,並將 React Native 的渲染系統擴展到新的平臺,例如 Windows、遊戲機、電視等等。

使用 C++ 作爲核心渲染系統有幾個有點。首先,單一實現降低了開發和維護成本。其次,它提升了創建 React 影子樹的性能,同時在 Android 上,也因爲不再使用 JNI for Yoga,降低了 Yoga 渲染引擎的開銷,佈局計算的性能也有所提升。最後,每個 React 影子節點在 C++ 中佔用的內存,比在 Kotlin 或 Swift 中佔用的要小。

名詞解釋

Java Native Interface (JNI):一個用 Java 寫的 API,用於在 Java 中寫 native(譯註:指調用 C++) 方法。作用是實現 Fabric 的 C++ 核心和 Android 的通信。

React Native 團隊還使用了強制不可變的 C++ 特性,來確保併發訪問時共享資源即便不加鎖保護,也不會有問題。

但在 Android 端還有兩種例外,渲染器依然會有 JNI 的開銷:

React Native 團隊在探索使用 ByteBuffer 序列化數據這種新的機制,來替換 ReadableMap,減少 JNI 的開銷。目標是將 JNI 的開銷減少 35~50%。

渲染器提供了  C++ 與兩邊通信的 API:

關於 **(i)**React 與渲染器的通信,包括 渲染(render) React 樹和監聽 事件(event),比如 onLayoutonKeyPress、touch 等。

關於 (ii) React Native 渲染器與宿主平臺的通信,包括在屏幕上 掛載(mount) 宿主視圖,包括 create、insert、update、delete 宿主視圖,和監聽用戶在宿主平臺產生的 事件(event)

視圖拍平

視圖拍平(View Flattening)是 React Native 渲染器避免佈局嵌套太深的優化手段。

React API 在設計上希望通過組合的方式,實現組件聲明和重用,這爲更簡單的開發提供了一個很好的模型。但是在實現中,API 的這些特性會導致一些 React 元素會嵌套地很深,而其中大部分 React 元素節點只會影響視圖佈局,並不會在屏幕中渲染任何內容。這就是所謂的 “只參與佈局” 類型節點。

從概念上講,React 元素樹的節點數量和屏幕上的視圖數量應該是 1:1 的關係。但是,渲染一個很深的 “只參與佈局” 的 React 元素會導致性能變慢。

舉個很常見的例子,例子中 “只參與佈局” 視圖導致了性能損耗。

想象一下,你要渲染一個標題。你有一個應用,應用中擁有外邊距 ContainerComponent的容器組件,容器組件的子組件是 TitleComponent 標題組件,標題組件包括一個圖片和一行文字。React 代碼示例如下:

function MyComponent() {
  return (
    <View>                          // ReactAppComponent
      <View style={{margin: 10}} /> // ContainerComponent
        <View style={{margin: 10}}> // TitleComponent
          <Image {...} />
          <Text {...}>This is a title</Text>
        </View>
      </View>
    </View>
  );
}

React Native 在渲染時,會生成以下三棵樹:

注意視圖 2 和視圖 3 是 “只參與佈局” 的視圖,因爲它們在屏幕上渲染只是爲了提供 10 像素的外邊距。

爲了提升 React 元素樹中 “只參與佈局” 類型的性能,渲染器實現了一種視圖拍平的機制來合併或拍平這類節點,減少屏幕中宿主視圖的層級深度。該算法考慮到了如下屬性,比如  margin, padding, backgroundColor, opacity等等。

視圖拍平算法是渲染器的對比(diffing)階段的一部分,這樣設計的好處是我們不需要額外的 CUP 耗時,來拍平 React 元素樹中 “只參與佈局” 的視圖。此外,作爲 C++ 核心的一部分,視圖拍平算法默認是全平臺共用的。

在前面的例子中,視圖 2 和視圖 3 會作爲 “對比算法”(diffing algorithm)的一部分被拍平,而它們的樣式結果會被合併到視圖 1 中。

雖然,這種優化讓渲染器少創建和渲染兩個宿主視圖,但從用戶的角度看屏幕內容沒有任何區別。

線程模型

React Native 渲染器在多個線程之間分配渲染流水線(render pipeline)任務。

接下來我們會給線程模型下定義,並提供一些示例來說明渲染流水線的線程用法。

React Native 渲染器是線程安全的。從更高的視角看,在框架內部線程安全是通過不可變的數據結果保障的,其使用的是 C++ 的 const correctness 特性。這意味着,在渲染器中 React 的每次更新都會重新創建或複製新對象,而不是更新原有的數據結構。這是框架把線程安全和同步 API 暴露給 React 的前提。

渲染器使用三個不同的線程:

讓我們回顧一下每個階段支持的執行場景:

渲染場景

在後臺線程中渲染

這是最常見的場景,大多數的渲染流水線發生在 JavaScript 線程和後臺線程。

在主線程中渲染

當 UI 線程上有高優先級事件時,渲染器能夠在 UI 線程上同步執行所有渲染流水線。

默認或連續事件中斷

在這個場景中,UI 線程的低優先級事件中斷了渲染步驟。React 和 React Native 渲染器能夠中斷渲染步驟,並把它的狀態和一個在 UI 線程執行的低優先級事件合併。在這個例子中渲染過程會繼續在後臺線程中執行。

不相干的事件中斷

渲染步驟是可中斷的。在這個場景中, UI 線程的高優先級事件中斷了渲染步驟。React 和渲染器是能夠打斷渲染步驟的,並把它的狀態和 UI 線程執行的高優先級事件合併。在 UI 線程渲染步驟是同步執行的。

來自 JavaScript 線程的後臺線程批量更新

在後臺線程將更新分派給 UI 線程之前,它會檢查是否有新的更新來自 JavaScript。這樣,當渲染器知道新的狀態要到來時,它就不會直接渲染舊的狀態。

C++ 狀態更新

更新來自 UI 線程,並會跳過渲染步驟。更多細節請參考 React Native 渲染器狀態更新。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fROPFAHpiiXXe56H05ubVw