持續(xù)完善中...
一、拆包關(guān)鍵之bridge
1、bridge原理
RCTBridge是對JavaScriptCore中Bridge的封裝,每個bridge都是一個獨立的js環(huán)境。
RN的啟動流程可以簡單概括為:
- Native編譯并啟動
- 創(chuàng)建js虛擬機環(huán)境
- 創(chuàng)建 bridge,擁有獨立的context js運行環(huán)境,并負責原生和js線程的通信(通過不同bridge加載的js代碼,可以存在相同的全局變量,不會沖突)
- 通過 bridge 獲取js線程來解析js代碼(可以是遠程包和離線包)
- 運行js代碼,并根據(jù)參數(shù)創(chuàng)建 RootView
bridge在RN中起到承上啟下的作用,在做RN拆包的時候是重點考慮的對象。目前RN拆包針對brdige有兩種主流方案,分別是單bridge和多bridge。
2、單bridge和多bridge的選擇
- 單bridge:react-native-multibundler
| 優(yōu)勢 | 劣勢 |
|---|---|
| 不用管理bridge的緩存和復(fù)用問題 | 不重啟APP的情況下想要更新bundle需要做更多的配置,比較繁瑣,且更新bundle并不會清除bridge中的舊bundle,存在少量內(nèi)存浪費 |
| 占用內(nèi)存更少 | 由于不同模塊都是運行在同一個bridge環(huán)境中,如果存在相同的全局變量會造成代碼污染 |
- 多bridge:攜程CRN
| 優(yōu)勢 | 劣勢 |
|---|---|
| 不同模塊之間使用了bridge隔離,不用擔心全局變量污染的問題 | 由于bridge很占用內(nèi)存,所以需要手動維護bridge的緩存和復(fù)用問題,避免APP內(nèi)存溢出(CRN維護了5個上限的bridge) |
不重啟APP的情況下更新bundle很方便,只需要重新指定路徑加載或者執(zhí)行reload
|
占用內(nèi)存多 |
二、基礎(chǔ)包和業(yè)務(wù)包的拆分
1、metro 介紹和打包流程
react-native metro 分析
metro是一種支持ReactNative的打包工具,我們現(xiàn)在也是基于他來進行拆包的,metro打包流程分為以下幾個步驟:
- Resolution:Metro需要從入口點構(gòu)建所需的所有模塊的圖,要從另一個文件中找到所需的文件,需要使用Metro解析器。在現(xiàn)實開發(fā)中,這個階段與Transformation階段是并行的。
- Transformation:所有模塊都要經(jīng)過Transformation階段,Transformation負責將模塊轉(zhuǎn)換成目標平臺可以理解的格式(如React Naitve)。模塊的轉(zhuǎn)換是基于擁有的核心數(shù)量來進行的。
- Serialization:所有模塊一經(jīng)轉(zhuǎn)換就會被序列化,Serialization會組合這些模塊來生成一個或多個包,包就是將模塊組合成一個JavaScript文件的包,序列化的時候提供了一些列的方法讓開發(fā)者自定義一些內(nèi)容,比如模塊id,模塊過濾等。
觀察一下原生Metro代碼的node_modules/metro/src/lib/createModuleIdFactory.js文件,代碼為:
function createModuleIdFactory() {
const fileToIdMap = new Map();
let nextId = 0;
return path => {
let id = fileToIdMap.get(path);
if (typeof id !== "number") {
id = nextId++;
fileToIdMap.set(path, id);
}
return id;
};
}
module.exports = createModuleIdFactory;
邏輯比較簡單,如果查到map里沒有記錄這個模塊則id自增,然后將該模塊記錄到map中,所以從這里可以看出,官方代碼生成moduleId的規(guī)則就是自增,所以這里要替換成我們自己的配置邏輯,我們要做拆包就需要保證這個id不能重復(fù),但是這個id只是在打包時生成,如果我們單獨打業(yè)務(wù)包,基礎(chǔ)包,這個id的連續(xù)性就會丟失,所以對于id的處理,我們還是可以參考上述開源項目,每個包有十萬位間隔空間的劃分,基礎(chǔ)包從0開始自增,業(yè)務(wù)A從1000000開始自增,又或者通過每個模塊自己的路徑或者uuid等去分配,來避免碰撞,但是字符串會增大包的體積,這里不推薦這種做法。所以總結(jié)起來js端拆包還是比較容易的,這里就不再贅述
2、Plain Bundle 分析
通過react-native bundle --platform android --dev false --entry-file index.common.js --bundle-output {輸出bundle的路徑} --assets-dest {資源路徑} --config {自定義打包配置} --minify false 打出基礎(chǔ)包(minify設(shè)為false便于查看源碼)
function (global) {
"use strict";
global.__r = metroRequire;
global.__d = define;
global.__c = clear;
global.__registerSegment = registerSegment;
var modules = clear();
var EMPTY = {};
var _ref = {},
hasOwnProperty = _ref.hasOwnProperty;
function clear() {
modules = Object.create(null);
return modules;
}
function define(factory, moduleId, dependencyMap) {
if (modules[moduleId] != null) {
return;
}
modules[moduleId] = {
dependencyMap: dependencyMap,
factory: factory,
hasError: false,
importedAll: EMPTY,
importedDefault: EMPTY,
isInitialized: false,
publicModule: {
exports: {}
}
};
}
function metroRequire(moduleId) {
var moduleIdReallyIsNumber = moduleId;
var module = modules[moduleIdReallyIsNumber];
return module && module.isInitialized ? module.publicModule.exports : guardedLoadModule(moduleIdReallyIsNumber, module);
}
這里主要看__r,__d兩個變量,賦值了兩個方法metroRequire,define,具體邏輯也很簡單,define相當于在表中注冊,require相當于在表中查找,js代碼中的import,export編譯后就就轉(zhuǎn)換成了__d與__r
三、拆包的后遺癥
1、按序加載基礎(chǔ)包和業(yè)務(wù)包
將RN的js業(yè)務(wù)拆出了公共模塊之后,在bridge加載bundle的時候需要優(yōu)先加載common包。這里需要考慮兩個問題:
-
RCTBridge需要疊加加載bundle
由于RCTBridge并沒有提供多次加載bunlde的方法,但是其內(nèi)部又一個私有方法實現(xiàn)了該功能(- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;),在iOS中我們可以通過Category的方式將該方法暴露出來 -
bundle加載完成獲取回調(diào)
我們必須要在common bunlde加載完成之后再去加載業(yè)務(wù)模塊,所以我們需要獲取到bundle加載完成的回調(diào)。然而RCTBridge并沒有提供回調(diào)入口,但是其有一個loading屬性,我們可以使用一個do while循環(huán)阻塞線程,直到loading為false代碼再往下走
如果是多bridge方案,每個bridge都得先加載common包,再加載具體業(yè)務(wù)包,這樣會很浪費內(nèi)存。
2、熱更新改造
單bridge熱更新
單bridge的疊加加載問題已經(jīng)解決了,但是疊加加載并不會覆蓋已經(jīng)加載過的bundle包,如果在不重啟APP的情況下,單bridge將無法實現(xiàn)熱更新。解決辦法是在打更新包的時候,得更新需要熱更的bundle包的模塊ID,具體可參考:react-native實現(xiàn)不重啟App的情況下更新分包。
第二個問題是熱更之后資源路徑發(fā)生變化。需要制定熱更之后的bundle從沙盒加載資源,否則會出現(xiàn)資源文件找不到的問題。多bridge熱更新
多bridge方案進行熱更時,無需考慮單bridge reload影響全局的問題,只需要reload當前需要更新的bridge就行,如果模塊劃分比較細,這樣做通常更有優(yōu)勢。
如果使用靜默升級,那么可以在下載完bundle包之后先不做替換或者reload,而是等到下一次進入APP的時候從新的路徑加載bundle,這樣做可以使用戶進行無感知的更新。
3、混合開發(fā)的路由方案
純RN路由
適用于純RN,使用react-navigation即可,僅需使用AppRegistry.registerComponent注冊一個根組件,只會存在一個VC或activity,所有的路由跳轉(zhuǎn)其實都是在同一個VC或activity內(nèi)跳轉(zhuǎn)。如果后期要擴展混合路由,純RN改造會比較大純Native路由
每個RN頁面,都使用AppRegistry.registerComponent單獨注冊,然后在Native端利用注冊的組件創(chuàng)建的單獨的RootView,并最終創(chuàng)建單獨的VC承載。由于都使用Native路由,所以可以很方便的進行Native和RN路由的統(tǒng)一,管理一套路由表即可。但是如果項目中需要引入其他團隊開發(fā)的RN bundle包,其他團隊如果使用的是純RN路由,那么這個時候就不兼容了,所以純Native路由方式不太適合需要引入其他團隊開發(fā)的bundle的場景混合路由
混合路由指的是有一部分Native路由,有一部分RN路由,攜程CRN目前走的就是混合路由路線。如果有些模塊需要在其他App內(nèi)復(fù)用,建議采用攜程的模式,他們對路由進行了優(yōu)化(沒開源),管理起來應(yīng)該會方便些。
4、路由表的調(diào)整
拆包之后路由表怎么維護呢?由于拆分成了多個bundle,路由表散落在了多個bundle中,不同bundle之間如何跳轉(zhuǎn)。如果路由名產(chǎn)生了沖突,就會導(dǎo)致跳轉(zhuǎn)異常和錯亂,所以這里就需要給每個路由加上一個所屬bundle標識。
5、多bundle的debug
各種操作拆完包后,突然有個問題,怎么調(diào)試呢?起初還想著怎么讓Native在初始化時直接加載全部bundle。但后來突然想明白,拆包的本質(zhì)就是通過設(shè)置多個入口文件將代碼給分割,那調(diào)試的時候我們直接將入口文件都在放在index.js里不就行了么。這樣就實現(xiàn)了跟RN單包一樣的調(diào)試。這個操作需要再js端提供一個引用所有模塊入口的文件,然后Native端設(shè)置debug標識來做bundle加載區(qū)分。
多bundle的情況下還嘗試過區(qū)分端口來獨立啟動和調(diào)試不同模塊,暫時不調(diào)試的模塊就加載本地一個提前打包好的bundle。但是實踐過程發(fā)現(xiàn)當開啟 Remote JS Debug 的時候,所有的bridge都會重新調(diào)用reload,那么這會導(dǎo)致什么問題嗎?
這里要說下Remote JS Debug的原理和command + R 和 command + D + Reload 的區(qū)別。
這是command + R 的源代碼
#if RCT_DEV
RCTExecuteOnMainQueue(^{
RCTRegisterReloadCommandListener(self);
});
#endif
void RCTRegisterReloadCommandListener(id<RCTReloadListener> listener)
{
RCTAssertMainQueue(); // because registerKeyCommandWithInput: must be called on the main thread
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
listeners = [NSHashTable weakObjectsHashTable];
[[RCTKeyCommands sharedInstance] registerKeyCommandWithInput:@"r"
modifierFlags:UIKeyModifierCommand
action:
^(__unused UIKeyCommand *command) {
RCTTriggerReloadCommandListeners();
}];
});
[listeners addObject:listener];
}
void RCTTriggerReloadCommandListeners(void)
{
RCTAssertMainQueue();
// Copy to protect against mutation-during-enumeration.
// If listeners hasn't been initialized yet we get nil, which works just fine.
NSArray<id<RCTReloadListener>> *copiedListeners = [listeners allObjects];
for (id<RCTReloadListener> l in copiedListeners) {
[l didReceiveReloadCommand];
}
}
開發(fā)環(huán)境會監(jiān)聽command + R鍵盤事件,一旦監(jiān)聽到指令就會遍歷所有注冊過得bridge,并執(zhí)行其didReceiveReloadCommand方法,最后調(diào)用reload方法。所以如果當前初始化了多個bridge,就會將注冊的bridge全都reload一遍,即使加載的是離線包的bridge,也會觸發(fā)一個8081端口的bridge,由于此時可能沒有開啟8081端口服務(wù),那么屏幕就會爆紅。
所以在多bridge方案中,如果要方便調(diào)試,要么在底層做改造,要么區(qū)分開發(fā)和正式場景,在開發(fā)場景使用單bridge方案。但這又造成了開發(fā)和正式環(huán)境的不一致問題,可能會出現(xiàn)開發(fā)環(huán)境正常,正式環(huán)境報錯的問題,很難定位。