說起移動端開發,就繞不開 Hybrid 技術。這篇文章主要是引申出一些概念,方便后續介紹 js bridge、deeplink 等知識。如果有錯誤的地方,歡迎在評論區里面指出來。
一、Native App
在說 Hybrid App 之前不得不先講到 Native App,這是最為傳統的一種移動端開發技術。
在 iOS 和安卓中官方的開發語言是 oc/swift、java/kotlin,使用這些開發出來的 App 一般稱之為原生應用。
1、優點
原生應用一般體驗較好,性能比較高,可以提前把資源下載到本地,打開速度快。
除此之外,原生應用可以直接調用系統攝像頭、通訊錄、相冊等功能,也可以訪問到本地資源,功能強大。
一般需要開發 App,原生應用應該是首選。
2、缺點
原生應用最大的缺點就是不支持動態化更新,這也是很多大廠不完全使用原生開發的原因。
考慮一下,如果線上出現嚴重問題,那該怎么辦呢?
首先客戶端開發修復了 bug 之后,就需要重新發版、提交應用商店審核,這一流程走下來往往需要好幾天的時間。
如果發布了新版 App,用戶該怎么去更新呢?答案是沒法更新。他們只能重新去下載整個 App,但實際上可能只更新了一行文案,這樣就得不償失了。
除此之外,最麻煩的地方在於要兼容老版本的 App。比如我們有個列表頁原本是分頁加載的,接口返回分頁數據。產品說這樣體驗不好,我們需要換成全量加載,那接口就需要做成全量的。
但接口一旦換成了全量的,老版本的客戶端里面依然是分頁請求接口的,這樣就會出現問題。因此,接口不得不根據不同版本進行兼容。
二、Web App
Web App 就是借助於前端 HTML5 技術實現的在瀏覽器里面跑的 App,簡單來說就是一個 Web 網站。
因為是在瀏覽器里面運行,所以天然支持跨平台,一套代碼甚至很容易支持移動端和 PC 端不需要安裝到手機里面,上線發版也比較容易。
缺點也很明顯,那就是只能使用瀏覽器提供的功能,無法使用手機上的一些功能。比如攝像頭、通訊錄、相冊等等,局限性很大。
也由於依賴於網絡,加載頁面速度會受到限制,體驗較差。受限於瀏覽器 DOM 的性能,導致對一些場景很難做到原生的體驗,比如長列表。
同時,也因為不像客戶端一樣在手機上有固定入口,會導致用戶黏性比較低。
三、Hybrid App
Hybrid App 是介於 Native 和 Web 之間的一些開發模式,一般稱作混合開發。
簡單來說 Hybrid 就是套殼 App,整個 App 還是原生的,也需要下載安裝到手機,但是 App 里面打開的頁面既可以是 Web 的,又可以是原生的。
H5 頁面會跑在 Native 的一個叫做 WebView 的容器里面,我們可以簡單理解為在 App 里面打開了一個 Chrome 瀏覽器,在這個瀏覽器里面打開一個 Tab 去加載線上或者本地的 H5 頁面,這樣還可以實現打開多 WebView 來加載多個頁面。
1、優勢
Hybrid App 同時擁有 Native 和 Web 的優點,開發模式比較靈活。
既可以做到動態化更新,有 bug 直接更新線上 H5 頁面就行了。也可以使用橋接(JS Bridge)來調用系統的攝像頭、相冊等功能,功能就不僅僅局限於瀏覽器了。
由於 H5 的優勢,Hybrid 也支持跨平台,只要有 WebView,一套代碼可以很容易跨iOS、安卓、Web、小程序、快應用多個平台。
2、缺點
缺點主要還是 Web App 的那些缺點,加載速度比較慢。
同時,因為受制於 Web 的性能,在長列表等場景依然無法做到和原生一樣的體驗。
當然加載速度是可以優化的,比如離線包。可以提前下載打包好的 zip 文件(包括 JS、CSS、圖片等資源文件)到 App 里面,App 自己解壓出來 JS 和 CSS 等文件。這樣每次訪問的是 App 本地的資源,加載速度可以得到質的提升。
如果文件有更新,那么客戶端就去拉取遠程版本,和本地版本進行對比,如果版本有更新,那就去拉取差量部分的文件,用二進制 diff 算法 patch 到原來的文件中,這樣可以做到熱更新。
但是成本也比較高,不僅需要在服務端進行一次文件差分,還需要公司內部提供一套熱更新發布平台。
3、WebKit
WebView 是安卓中展示界面的一個控件,一般是用來展示 Web 界面。前面我們說過,可以把 WebView 理解為你正在使用的 Chrome 瀏覽器。
那么瀏覽器又是怎么去解析渲染 HTML 和 CSS,最終渲染到頁面上面的呢?
這也是一道經典面試題里面的一環:從URL輸入到頁面展現到底發生什么?
簡單來說就是瀏覽器拿到響應的 HTML 文本后會解析 HTML 成一個 DOM 樹,解析 CSS 為 CSSOM 樹,兩者結合生成渲染樹。在 Chrome 中使用 Skia 圖形庫來渲染界面,Skia 也是 Flutter 的渲染引擎。
可以參考這張經典圖:
PS:使用 Skia 去繪制界面,而非編譯成 Native 組件讓系統去渲染,也是 Flutter 區別於 React Native 的一個地方。
除了解析 HTML,瀏覽器還需要提供 JavaScript 的運行環境,我們知道的 V8 引擎就是做這件事的。
4、WebKit 內核
從上面我們可以得知,一個瀏覽器至少離不開一個渲染 HTML 的引擎和一個運行 JavaScript 的引擎。
當然,上面的這些操作都是瀏覽器由內核來完成的。現在主流的瀏覽器都使用了 WebKit 內核。
WebKit 誕生於蘋果發布的 Safari 瀏覽器。后來谷歌基於 WebKit 創建了 Chromium 項目,在此基礎上發布了我們熟悉的 Chrome 瀏覽器。
WebKit 內核的結構如下圖所示。
我們依次從上往下看,WebKit 嵌入式接口就是提供給瀏覽器調用的,不同瀏覽器實現可能有所差異。
其中解析 HTML 和 CSS 這部分是 WebCore 做的,WebCore 是 WebKit 最核心的渲染引擎,也是各大瀏覽器保持一致的部分,一般包括 HTML 和 CSS 解釋器、DOM、渲染樹等功能。
WebKit 默認使用 JavaScriptCore 作為 JS 引擎,這部分是可以替換的。JavaScriptCore 也是 React Native 里面默認的引擎。
由於 JavaScriptCore 前期性能低下,於是谷歌在 Chrome 里面選用了 V8 作為 JS 引擎。
WebKit Ports 則是非共享的部分,由於平台差異、依賴的庫不同,這部分就變成了可移植部分。主要涉及到網絡、視頻解碼、圖片解碼、音頻解碼等功能。
WebView 自然也使用了 WebKit 內核。只是在安卓里面以 V8 作為 JS 引擎,在 iOS 里面以 JavaScriptCore 作為 JS 引擎。
由於渲染 DOM 和操作 JS 的是兩個引擎,因此當我們用 JS 去操作 DOM 的時候,JS 引擎通過調用橋接方法來獲取訪問 DOM 的能力。這里就會涉及到兩個引擎通信帶來的性能損失,這也是為什么頻繁操作 DOM 會導致性能低下。
React 和 Vue 這些框架都是在這一層面進行了優化,只去修改差異部分的 DOM,而非全量替換 DOM。
5、iOS 中的 JavaScriptCore
JavaScriptCore 是 WebKit 內核默認使用的 JS 引擎。既然是講解 WebView,那么就來介紹一下 iOS 里面的 JavaScriptCore 框架吧。
iOS 中的 JavaScriptCore 框架是基於 OC 封裝的 JavaScriptCore 框架。
它提供了調用 JS 運行環境以及 OC 和 JS 互相調用的能力,主要包含了 JSVM、JSContext、JSValue、JSExport 四個部分(其實只是想講 JSVM)。
JSVM:JSVM 全稱是 JSVirtualMachine,簡單來說就是 JS 的虛擬機。那么什么是虛擬機呢?
我們以 JVM 為例,一般來說想要運行一個 Java 程序要經過這么幾步:
- 把 Java 源文件(.java文件)編譯成字節碼文件(.class文件,是二進制字節碼文件),這種字節碼就是 JVM 的“機器語言”。javac.exe 就可以看做是 Java 編譯器。
- Java 解釋器用來解釋執行 Java 編譯器編譯后的程序。java.exe可以簡單看成是 Java 解釋器。
所以 JVM 是一種能夠運行 Java 字節碼的虛擬機。除了運行 Java 字節碼,它還會做內存管理、GC 等方面的事情。
而 JSVM 則提供了 JS 的運行環境,也提供了內存管理。每個 JSVM 只有一個線程,如果想執行多個線程,就要創建多個 JSVM,它們都自己獨立的 GC,所以多個 JSVM 之間的對象無法傳遞。
JS 源代碼經過了詞法分析和語法分析這兩個步驟,轉成了字節碼,這一步就是編譯。
但是不同於我們編譯運行 Java 代碼,JS 編譯結束之后,並不會生成存放在內存或者硬盤之中的目標代碼或可執行文件。生成的指令字節碼,會被立即被 JSVM 進行逐行解釋執行。
6、字節碼
字節碼是已經經過編譯,但與特定機器碼無關,需要解釋器轉譯后才能成為機器碼的中間代碼。
在 v8 中前期沒有引入字節碼,而是簡單粗暴地直接把源程序編譯成機器碼去運行,因為他們覺得先生成字節碼再去執行字節碼會降低執行速度。
但后期 v8 又再一次將字節碼引入進來,這是為什么呢?
早期 v8 將 JS 編譯成為二進制機器碼,但是編譯會占用很大一部分時間。如果是同樣的頁面,每次打開都要重新編譯一次,這樣就會大大降低了效率。
於是在 chrome 中引入了二進制緩存,將二進制代碼保存到內存或者硬盤里面,這樣方便下次打開瀏覽器的時候直接使用。
但二進制代碼的內存占用特別高,大概是 JS 代碼的數千倍,這樣就導致了如果在移動設備(手機)上使用,本來容量就不大的內存還會被進一步占用,造成性能下降。
然而字節碼占用空間就比機器碼實在少太多了。因此,v8 團隊不得不再次引入字節碼。
7、JIT
除此之外,還有一個大家都很熟悉的概念,那就是 JIT(即時編譯)。
即時編譯(Just-in-time compilation: JIT):又叫實時編譯、及時編譯。是指一種在運行時期把字節碼編譯成原生機器碼的技術,一句一句翻譯源代碼,但是會將翻譯過的代碼緩存起來以降低性能耗損。這項技術是被用來改善虛擬機的性能的。
簡單來說就是某段代碼要被執行之前才進行編譯。還是以 JVM 為例子。
- JVM 的解釋過程:
java 代碼 -> 編譯字節碼 -> 解釋器解釋執行
- JIT 的編譯過程:
java 代碼 -> 編譯字節碼 -> 編譯機器碼 -> 執行
所以 Java 是一種半編譯半解釋語言。之所以說 JIT 快,是指執行機器碼效率比解釋字節碼效率更高,而非編譯比解釋更快。因此,JIT 編譯還是會比解釋慢一些。
同樣,編譯成機器碼還是會遇到上面空間占用大的問題。所以在 JIT 中只對頻繁執行的代碼就行編譯,一般包括下面兩種:
- 被多次調用的方法。
- 被多次執行的循環體。
在編譯熱點代碼的時候,這部分就會被緩存起來。等下次運行的時候就不需要再次進行編譯,效率會大大提升。這也是為什么很多 JVM 都是用解釋器+JIT的形式。
將一次編譯的成本均攤到多次執行上就基本可以忽略了。一開始需要解釋執行主要還是為了啟動速度和進行熱點探測。實際上JVM在生產上用的解釋器叫模板解釋器,也是會將字節碼翻譯成機器碼再執行。JIT的最主要作用還是在於通過花時間做優化來提供執行效率。
8、JSContext
JSContext 就是 JS 運行的上下文,我們想要在 WebView 上面運行 JS 代碼,就需要 JSContext 這個運行環境。以下面這段代碼為例:
JSContext *context = [[JSContext alloc] init]; [context evaluateScript:@"var i = 4"]; NSNumber *number = [context[@"i"] toNumber];
上面的 JSContext 調用 evaluateScript 來執行一段 JS 代碼,通過 context 可以拿到對應的 JSValue 對象。
9、JSValue
JS 和 OC 交換數據的時候 JSCore 幫我們做了類型轉換,JSCore 提供了10種類型轉換:
Objective-C type | JavaScript type --------------------+--------------------- nil | undefined NSNull | null NSString | string NSNumber | number, boolean NSDictionary | Object object NSArray | Array object NSDate | Date object NSBlock | Function object id | Wrapper object Class | Constructor object
10、JSExport
JSExport 支持把 Native 對象暴露給 JS 環境。例如:
@protocol NativeObjectExport <JSExport> @property (nonatomic, assign) BOOL property1; - (void)method1:(JSValue *)arguments; @end @interface NativeObject : NSObject<NativeObjectExport> @property (nonatomic, assign) BOOL property1; - (void)method1:(JSValue *)arguments; @end
上面的 NativeObject 只要實現了 JSExport,就可以被 JS 直接調用。我們需要在 Context 里面注入一個對象,就可以在 JS 環境調用 Native 了。
context[@"nativeMethods"] = [NativeObject new];
JS 中調用:
nativeMethods.method1({
callback(data) {
}
])
11、React Native
Hybrid 中的 H5 始終是 WebView 中運行的,WebKit 負責繪制的。
因為瀏覽器渲染的性能瓶頸,Facebook 基於 React 發布了 React Native 這個框架。
由於 React 中 Virtual DOM 和平台無關的優勢,理論上 Virtual DOM 可以映射到不同平台。在瀏覽器上就是 DOM,在 Native 里面就是一些原生的組件。
受制於瀏覽器渲染的性能,React Native 吸取經驗將渲染這部分交給 Native 來做,大大提高了體驗。個人認為 React Native 也算是 Hybrid 技術的一種。
RN 中直接使用 JavaScriptCore 來提供 JS 的運行環境,通過 Bridge 去通知 Native 繪制界面,最終還是 Native 渲染的。
所以性能上比 Hybrid 更好,但受限於 JS 和 Native 通信的性能消耗,性能上依然不及 Native。
12、JS 和 Native 通信原理
在 JS 和 Native 通信的時候往往要經過 Bridge,這一步是異步的。
在 App 啟動的時候,Native 會創建一個 Module 配置表,這個表里面包括了所有模塊和模塊方法的信息。
{ "remoteModuleConfig": { "XXXManager": { "methods": { "xxx": { "type": "remote", "methodID": 0 } }, "moduleID": 4 }, ... }, }
由於在 OC 里面每個提供給 JS 調用的模塊類都實現了 RCTBridgeModule
接口,所以通過 objc_getClassList
或 objc_copyClassList
獲取項目中所有的類,然后判斷每個類是否實現了 RCTBridgeModule
,就可以確定是否需要添加到配置表中。
然后 Native 會將這個配置表信息注入到 JS 里面,這樣在 JS 里面就可以拿到 OC 里面的模塊信息。
其實如果你寫過 JS Bridge,會發現這個流程和 WebViewJavaScriptBridge 有些類似。主要是這么幾個步驟:
- JS 調用某個 OC 模塊的方法
- 把這個調用轉換為 ModuleName、MethodName、arguments,交給 MessageQueue 處理。
- 將 JS 的 Callback 函數放到 MessageQueue 里面,用 CallbackID 來匹配。再根據配置表將 ModuleName、MethodName映射為 ModuleID 和 MethodID。
- 然后把上面的 ID 都傳給 OC
- OC 根據這幾個 ID 去配置表中拿到對應的模塊和方法
- RCTModuleMethod 對 JS 傳來的參數進行處理,主要是將 JS 數據類型轉換為 OC 的數據類型
- 調用 OC 模塊方法
- 通過 CallbackID 拿到對應的 Callback 方法執行並傳參
13、熱更新
相比 Native,RN 的一大優勢就是熱更新。我們將 RN 項目最后打包成一個 Bundle 文件提供給客戶端加載。在 App 啟動的時候去加載這個 Bundle 文件,最后由 JavaScriptCore 來執行。
如果有新版本該怎么更新?這個其實很簡單,重新打包一個 Bundle 文件,用 BS Diff 算法對不同版本的文件進行二進制差分。
客戶端會比較本地版本和遠程版本,如果本地版本落后了,那就去下載差量文件,同樣使用 BS Diff 算法 patch 進 Bundle 里面,這樣就實現了熱更新。
這種方式也適用於 H5 的離線包更新,可以很大程度上解決 H5 加載慢的問題。
14、RN 新架構
在 RN 老架構中,性能瓶頸主要體現在 JS 和 Native 通信上面,也就是 Bridge 這里。
我們寫的 RN 代碼會通過 JS Thread 進行序列化,然后通過 Bridge 傳給 shadow Thread 反序列化獲得原生布局信息。
之后又通過 Bridge 傳給 UI Thread,UI Thread 反序列化之后會根據布局信息進行繪制。這里就有三個線程通過 Bridge 來通信。
由於多次序列化/反序列化以及 Bridge 通信,這樣就造成了一些性能損耗。
尤其是在快速滑動列表的時候容易造成白屏,然而瀏覽器里面快速滑動卻沒有白屏,這又是為什么呢?
主要還是瀏覽器中,JS 可以持有 C++ 對象的引用,所以這里其實是同步調用。
由於受到 Flutter 的沖擊,RN 團體提出了新的架構來解決這些問題。
為了解決 Bridge 通信的問題,RN 團隊在 JavaScriptCore 之上抽象了一層 JSI(JavaScript Interface),允許底層更換成不同的 JavaScript 引擎。
除此之外,JS 還可以拿到 C++ 的引用,這樣就可以直接和 Native 通信,不需要反復序列化對象,也節省了 Bridge 通信的開支。
這里解釋一下,為啥拿到 C++ 引用就可以和 Native 通信。由於 OC 本身就是 C 語言的擴展,所以可以直接調用 C/C++ 的方法。Java 雖然不能 C 語言擴展,但它可以通過 JNI 來調用。
JNI 就是 Java Native Interface,它是 JVM 提供的一套能夠使運行在 JVM 上的 Java 代碼調用 C++ 程序、以及被 C++ 程序調用的編程框架。
相信新架構的到來會解決 RN 原有的一些痛點,以及會帶來性能上的飛躍。
四、Flutter
傳統的跨端有兩種,一種是 Hybrid 那種實現 JS 跑在 WebView 上面的,這種性能瓶頸取決於瀏覽器渲染。
另一種是將 JS 組件映射為 Native 組件的,例如 React Native、Weex,缺點就是依然需要 JS Bridge 來進行通信(老架構)。
Flutter 則是在吸取了 RN 的教訓之后,不再去做 Native 的映射,而是自己用 Skia 渲染引擎來繪制頁面,而 Skia 就是前面說過的 Chrome 底層的二維圖形庫,它是 C/C++ 實現的,調用 CPU 或者 GPU 來完成繪制。所以說 Flutter 像個游戲引擎。
Flutter 在語法上深受 React 的影響,使用 setState 來更新界面,使用類似 Redux 的思想來管理狀態。從早期的 WPF,到后面的 React,再到后來的 SwiftUI 都使用了聲明式 UI 的思想。
Flutter 架構圖如下:
Framework 是用 Dart 實現的 SDK,封裝了一些基礎庫,比如動畫、手勢等。還實現了一套 UI 組件庫,有 Material 和 Cupertino 兩種風格。Material 適用於安卓,Cupertino 適用於 iOS。
Engine 是 C/C++ 實現的 SDK,主要包括了 Skia 引擎、Dart 運行時、文本渲染等。
Embedder 是一個嵌入層,支持把 Flutter 嵌入各個平台。
Flutter 使用 Dart,支持 AOT 編譯成 ARM Code,這樣擁有更高的性能。在 Debug 模式下還支持 JIT。
在 Flutter 中,Widgets 是界面的基本構成單位,和 React Component 有些類似。而 StatelessWidget 類似 React Functional Component。
class Echo extends StatelessWidget { const Echo({ Key key, @required this.text, this.backgroundColor:Colors.grey, }):super(key:key); final String text; final Color backgroundColor; @override Widget build(BuildContext context) { return Center( child: Container( color: backgroundColor, child: Text(text), ), ); } }
在 Flutter 渲染過程中有三棵樹,分別是 Widgets 樹、Element 樹、RenderObject 樹。
如果你有寫過 React,會發現真的和 React 很類似。
當初始化的時候, Widgets 通過 build 方法來生成 Element,這類似於 React.createElement 生成虛擬 DOM(Virtual DOM)。
Element 重新創建的開銷會比較大,所以每次重新渲染它並不會重新構建。從 Element Tree 到 RenderObject Tree 之間一般也會有一個 Diff 的環境,計算最小需要重繪的區域。
這里也和 React 渲染流程比較相似,虛擬 DOM 會和真實 DOM 進行一次 Diff 對比,最后將差異部分渲染到瀏覽器上。
1、瀏覽器渲染
在前面我們講過瀏覽器的渲染流程。一般是將 HTML 解析成 DOM 樹,將 CSS 解析為 CSSOM 樹,兩者合並成一顆 RenderObject 樹。
一個 RenderObject 對象保存了繪制 DOM 節點需要的各種信息,它知道怎么繪制自己。不同的 RenderObject 對象構成一棵樹,就叫做 RenderObject 樹。它是基於 DOM 樹創建的一棵樹。
然后 WebKit 會為這些 RenderObject 對象創建新的 RenderLayer 對象,最后形成一棵 RenderLayer 樹。一般來說,RenderLayer 和 RenderObject 是一對多的關系。每個 RenderLayer 包括的是 RenderObect 樹的子樹。
什么情況下會創建 RenderLayer 對象呢?比如 Video 節點、Document 節點、透明的 RenderObject 節點、指定了位置的節點等等,這些都會創建一個 RenderLayer 對象。
一個 RenderLayer 可以看做 PS 里面的一個圖層,各個圖層組成了一個圖像。
2、Flutter 渲染
Flutter 渲染和瀏覽器渲染類似,Widget 通過 createElement 生成 Element,而 Element 通過 createRenderObject 創建了 RenderObject 對象,最終生成 Layer。
一般來說,RenderObject 上面存着布局信息,所以布局和繪制都是在 RenderObject 中完成。Flutter 通過深度遍歷渲染 RenderObject 樹,確定每個對象的位置和大小,繪制到不同的圖層中。繪制結束后,由 Skia 來完成合成和渲染。
所以 Flutter 的更新流程如下:
3、通信
Flutter 沒辦法完成 Native 所有的功能,比如調用攝像頭等,所以需要我們開發插件,而插件開發的基礎還是 Flutter 和 Native 之間進行通信。
Flutter 和 Native 之間的通信是通過 Channel 完成的,一般有下面幾種通信場景:
- Native 發送數據給 Dart
- Dart 發送數據給 Native
- Dart 發送數據給 Native,Native 回傳數據給 Dart
Flutter 實現通信有下面三種方式:
- EventChannel:一種 Native 向 Flutter 發送數據的單向通信方式,Flutter 無法返回任何數據給 Native。一般用於 Native 向 Flutter發送手機電量、網絡連接等。
- MethodChannel:Native 和 Flutter之間的雙向通信方式。通過 MethodChannel 調用 Native 和 Flutter 中相對應的方法,該種方式有返回值。
- BasicMessageChannel:Native 和 Flutter之間的雙向通信方式。支持數據類型最多,使用范圍最廣。該方式有返回值。
BinaryMessenger 是 Flutter 和 Channel 通信的工具。它在安卓中是一個接口,使用二進制格式數據通信。
在 FlutterView 中實現,它可以通過 JNI 來和系統底層通信。因此,基本上和原生調用差不多,不像 RN 中 Bridge 調用需要進行數據轉化。
所以,如果想開發插件,還是需要實現安卓和 iOS 的功能,以及封裝 plugin 的 api,總體上還是無法脫離 Native 來運作。
4、對比 React Native
- Flutter 官方暫時不支持熱更新,RN 有成熟的 Code Push 方案
- Flutter 放棄了 Web 生態,RN 擁有 Web 成熟的生態體系,工具鏈更加強大。
- Flutter 將 Dart 代碼 AOT 編譯為本地代碼,通信接近原生。RN 不僅需要多次序列化,不同線程之間還需要通過 Bridge 來通信,效率低下。
更多細節對比可以參考知乎這個問題:開發跨平台 App 推薦 React Native 還是 Flutter?
文章來源於前端小館 ,作者尹光耀
原文鏈接:https://mp.weixin.qq.com/s/yegI1oXndv9exhF0Uc-S3g