[RN] React Native bundle拆分與合并之安卓篇

在網(wǎng)上看到攜程之前拆分的一些經(jīng)驗

先來說一組數(shù)據(jù),一個Helloorld的App,如果使用0.30 RN 官方命令react-native bundle打包出來的JSBundle文件大小大約為531KB,RN框架JavaScript本身占了530KB, zip壓縮之后也有148KB。

如果只有一兩個業(yè)務使用,這點大小算不了什么,但是對于我們這種動輒幾十個業(yè)務的場景,如果每個業(yè)務的JSBundle都需要這么大的一個RN框架本身,那將是不可接受的。

因此,我們需要對RN官方的打包腳本做改造,將框架代碼拆分出來,讓所有業(yè)務使用一份框架代碼。

開始拆分之前, 我們先以HelloWorld的RNApp為基礎(chǔ)介紹幾個背景知識。


上述是一個HelloWorld RNApp代碼的結(jié)構(gòu),基本分為3部分

頭部:各依賴模塊引用部分;

中間:入口模塊和各業(yè)務模塊定義部分;

尾部:入口模塊注冊部分;


上述是HelloWorld RNApp打包之后JSBundle文件的結(jié)構(gòu),基本分為3部分 頭部:全局定義,主要是define,require等全局模塊的定義; 中間:模塊定義,RN框架和業(yè)務的各個模塊定義; 尾部:引擎初始化和入口函數(shù)執(zhí)行;

__d是RN自定義的define,符合CommonJS規(guī)范,__d后面的數(shù)字是模塊的id,是在RN打包過程中,解析依賴關(guān)系,自增長生成的。

如果所有業(yè)務代碼,都遵照一個規(guī)則:入口JS文件首先require的都是react/react-native, 則打包生成的JSBundle里面react/react-native相關(guān)的模塊id都是固定的。

拆分方案一

基于上面2點背景知識介紹,我們很容易發(fā)現(xiàn),如果將打包之后的JSBundle文件,拆分成2部分(框架部分+業(yè)務模塊部分),使用的時候合并起來,然后去加載,即可實現(xiàn)拆分功能。

具體實現(xiàn)步驟:

創(chuàng)建一個空工程,入口文件只需要2行代碼,require react/react-native即可;

使用react-native bundle命令,打包該入口文件,生成common.js;

使用react-native bundle打包業(yè)務工程(有一點要保證,業(yè)務工程入口文件前面2行代碼也是require react/react-native), 生成business_all.js;

開發(fā)工具,從business_all.js里面刪除common.js的內(nèi)容,剩下的就是business.js;

App加載的時候?qū)ommon.js和business.js合并在一起,然后加載;

貌似功能完成,可是回到Dive into React Native performance, 這么做還是優(yōu)化不了JSBundle的執(zhí)行時間,因為我們不能把拆分開的2個文件分別執(zhí)行,因為加載common.js會提示找不到RNApp的入口,先執(zhí)行business.js,會提示一堆依賴的RN模塊找不到。

顯然,這種拆分方式不能滿足我們這種需要。

那這個方案就完全沒有價值嗎?不是的,如果你做的是一個純RNApp,native只是一個殼,里面業(yè)務全是RN開發(fā)的,完全可以使用這種方式做拆分,這種方案簡單,無侵入,實現(xiàn)成本低,不需要修改任何RN打包代碼和RN Runtime代碼。

拆分方案二

RN框架部分文件(common.js)大小530KB,如此大的js文件,占用了絕大部分的JS執(zhí)行時間,這塊時間如果能放到后臺預先做完,進入業(yè)務也只需執(zhí)行業(yè)務頁面的幾個JS文件,將可以大大提升頁面加載速度,參考上面的RN性能瓶頸圖,預估可以提升100%。

按照這個思路,能后臺加載的JS文件, 實際上是就是一個RNApp,因此 我們設(shè)計了一個空白頁面的FakeApp,這個FakeApp做一件事情,就是監(jiān)聽要顯示的真實的業(yè)務JS模塊,收到監(jiān)聽之后,渲染業(yè)務模塊,顯示頁面。

FakeApp設(shè)計如下:


為了實現(xiàn)該拆包方案,需要改造react-native的打包命令;

基于FakeApp打common.js包的時候, 需要記錄RN各個模塊名和模塊id之間的mapping關(guān)系;

打業(yè)務模塊包的時候,判斷,如果已經(jīng)在mapping文件里面的模塊,不要打包到業(yè)務包中

改造頁面加載流程:

因為要能夠后臺加載,所以需分離UI和JS加載引擎<iOS-RCTBridge, Android-ReactInstanceManager>;

進入業(yè)務RN頁面時候,獲取預加載好的JS引擎,然后發(fā)送消息給FakeApp,告知該渲染的業(yè)務JS模塊;

通過后臺預加載,省去了絕大部分的JS加載時間,似乎問題已經(jīng)完美解決。

但是,如果隨著業(yè)務不斷膨脹,一個RN業(yè)務JS代碼也達到500KB,進入這個業(yè)務頁面,500多KB JS文件讀取出來,執(zhí)行,整個JS執(zhí)行的時間瓶頸會再次出現(xiàn)。

拆分方案三

正在此時,我們研究RN在Facebook App里面的使用情況,發(fā)現(xiàn)了Unbundle,簡單點說,就是將所有的JS模塊都拆分成獨立的文件。

下面截圖就是unbundle打包的文件格式:


entry.js就是global部分定義+RNApp入口;

UNBUNDLE文件是用于標識這是一個unbundle包的flag;

12.js,13.js就是各個模塊,文件名就是模塊id;

在業(yè)務執(zhí)行,需要加載模塊(require)的時候,就去磁盤查找該文件,讀取、執(zhí)行。

RN里面加載模塊流程說明,以require(66666)模塊為例:

首先從__d<就是前文提到的define>的緩存列表里面查找是否有定義過模塊66666,如果有,直接返回,如果沒有走到下面第二步的nativeRequire;

nativeRequire根據(jù)模塊id,查找文件所在路徑,讀取文件內(nèi)容;

定義模塊,_d(66666)=eval(JS文件內(nèi)容),會將這個模塊ID和JS代碼執(zhí)行結(jié)果記錄在define的緩存列表里面;

打包通過react-native unbundle 命令,可以給android平臺打出這樣的unbundle包。

順便提一下,這個unbundle方案,只在android上有效,打ios平臺的unbundle包,是打不出來的,在RN的打包腳本上有一行注釋,大致意思是在iOS上眾多小文件讀取,文件IO效率不夠高,android上沒這樣的問題,然后判斷如果是打iOS的unbundle包的時候,直接return了。

相對應的,iOS開發(fā)了一個prepack的打包模式,簡單點說,就是把所有的JS模塊打包到一個文件里面,打包成一個二進制文件,并固定0xFB0BD1E5為文件開始,這個二進制文件里面有個meta-table,記錄各個模塊在文件中的相對位置,在加載模塊(require)的時候,通過fseek,找到相應的文件開始,讀取,執(zhí)行。

在Unbundle的啟發(fā)下,我們修改打包工具,開發(fā)了CRNUnbunle,做了簡單的優(yōu)化,把眾多零散的JS文件做了簡單的合并。


將common部分的JS文件,合并成一個common_ios(android).js.

_crn_config記錄了這個RNApp的入口模塊ID以及其他配置信息,詳見下圖:


main_module為當前業(yè)務模塊入口模塊ID;

module_path為業(yè)務模塊JS文件所在當前包的相對路徑;

666666=0.js,說明666666這個模塊在0.js文件里面;

做完這個拆包和加載優(yōu)化之后,我們用自己的幾個業(yè)務做了下測試,下圖是當時的測試驗證數(shù)據(jù)。


可以看出,iOS和android基本都比官方打包方式的加載時間,減少了50%。

這是自己單機測試的數(shù)據(jù),那上線之后,數(shù)據(jù)如何呢?

下圖,是我們分析一天的數(shù)據(jù),得出的平均值<排除掉了5s以上的異常數(shù)據(jù),后面實測下來5s以上數(shù)據(jù)極少>;


看到這個數(shù)據(jù),發(fā)現(xiàn)和我們自己測試的基本一致,但是還有一個疑問,加載的時間分布,是否服從正態(tài)分布,會不會很離散,快的設(shè)備很快,慢的設(shè)備很慢呢?

然后我又進一步分析這一天的數(shù)據(jù),按照頁面加載時間區(qū)間分布統(tǒng)計。

看圖上數(shù)據(jù),很明顯,iOS&Android基本一致,將近98%的用戶都能在1s內(nèi)加載完成頁面,符合我們期望的正態(tài)分布,所以bundle拆分到此基本完成。

實踐

我先用bundle打包命令打一個bundle出來

react-nativebundle --platform android --devfalse--entry-file index.android.js --bundle-output finalbundle/index.android.bundle --assets-dest finalbundle/

只有一個簡單的3k左右的index.android.js,打出了一個五百多k的index.android.bundle,看看里面是些什么


密密麻麻但又有規(guī)則

!function打頭的是公共的頭部部分

_d(function是JS文件,用ctrl+s搜索welcome,找到我們的index.android.js,原來是在第一行的_d(function,而且結(jié)尾有個參數(shù)0,其余部分其實都是公共的js

;require(120),是基礎(chǔ)文件的配置入口,require(0)則是業(yè)務的入口

基于以上,能想到一個辦法:

內(nèi)置一個common.js文件,里面包含了bundle文件公共部分的代碼,

業(yè)務代碼單獨生成一個js文件

在需要展示加載某一個頁面的時,將common.js和當前頁面需要加載的業(yè)務js合并,然后再加載

這個辦法解決了一部分問題,但加載時還是一個整體。如果common部分能重用,就能大大提升效率。所以就來試試上面提到的unbundle命令

react-nativeunbundle --platform android --devfalse--entry-file index.android.js --bundle-output build/index.android.bundle

生成的bundle只有14行了

但多了一個js-modules文件夾,里面的xx.js里面的內(nèi)容就是將之前的__d(xx)抽出來單獨放到一個文件里面,通過require(xx)加載到內(nèi)存供調(diào)用

基于unbundle命令再設(shè)計一個上面提到的fake頁面用來加載相應的業(yè)務模塊,這個頁面可以預先在后臺初始化js引擎,將公共部分的common.js文件讀取到內(nèi)存,然后設(shè)置一個監(jiān)聽事件,通過emmit方式,當需要加載某個頁面的的module的時候講這個頁面的module的id傳遞過來,然后通過require方法調(diào)用這個模塊。

思路差不多是這樣了,來試試看實現(xiàn)起來有沒什么坑。

首先

我拿例子跑了一下,瞬間明白了流程是怎么回事,有幾個關(guān)鍵:

DeviceEventEmitter

前端發(fā)起監(jiān)聽,后端需要用的時候調(diào)用emit觸發(fā),通過返回模塊id,然后return React.createElement(返回的模塊ID,this.props)即可定制加載

配置文件

這個配置文件之前不是很理解為什么好多等于0.js、等于1.js,現(xiàn)在明白其實就是不同bu的入口JS,因為都是單頁路由的形式,不過這個配置其實是一套打包的一個流程,不在這里做,以后研究打包工具的時候加上。

然后

我試著把這樣融入到之前的demo里。

先建兩個test頁面,用于測試切換。

importReact, { Component }from'react';

import{

AppRegistry,

StyleSheet,

Text,

View,

}from'react-native';

classtesteightextendsComponent{

render() {

return(

<Viewstyle={styles.container}>

<Textstyle={styles.welcome}>

Welcome to Test 8888

</Text>

</View>

);

}

}

conststyles = StyleSheet.create({

welcome: {

fontSize:20,

textAlign:'center',

margin:10,

}

});

module.exports = testeight;

然后在index.android.js加入切換按鈕

/**

* Sample React Native App

* https://github.com/facebook/react-native

* @flow

*/

importReact, { Component }from'react';

import{

AppRegistry,

StyleSheet,

Text,

View,

Image,

NativeModules,

DeviceEventEmitter,

}from'react-native';

exportdefaultclassAwesomeProjectextendsComponent{

constructor(props){

super(props);

this.state = {

content:null,showModule:false

};

DeviceEventEmitter.addListener("test", (result) => {

letmainComponent =require(result.name);

this.setState({

content:mainComponent,

showModule:true

})

});

}

render() {

let_content =null;

if(this.state.content){

_content = React.createElement(this.state.content,this.props);

return_content;

}else{

return(

<Viewstyle={styles.container}>

<Textstyle={styles.welcome}>

Welcome to React Native!

</Text>

<Textstyle={styles.instructions}>

To get started, edit index.android.js

</Text>

<Textstyle={styles.instructions}>

Double tap R on your keyboard to reload,{'\n'}

Shake or press menu button for dev menu

</Text>

<Textstyle={styles.instructions}onPress={()=>this.showToast()}>

點我調(diào)用原生

</Text>

<Textstyle={styles.instructions}onPress={()=>this.updateBundle()}>

點我更新bundle

</Text>

<Textstyle={styles.instructions}onPress={()=>this.goNine()}>

點我加載頁面9999

</Text>

<Textstyle={styles.instructions}onPress={()=>this.goEight()}>

點我加載頁面8888

</Text>

<Image

source={require('./img/music_play.png')}

style={{width:92,height:92}}

/>

</View>

);

}

}

updateBundle () {

NativeModules.updateBundle.check("5.0.0");

}

showToast () {

//調(diào)用原生

NativeModules.RNToastAndroid.show('from native',100);

}

goNine () {

NativeModules.BundleLoad.goPage(9999);

}

goEight () {

NativeModules.BundleLoad.goPage(8888);

}

}

const styles = StyleSheet.create({

container: {

flex: 1,

justifyContent: 'center',

alignItems: 'center',

backgroundColor: '#F5FCFF',

},

welcome: {

fontSize: 20,

textAlign: 'center',

margin: 10,

},

instructions: {

textAlign: 'center',

color: '#333333',

marginBottom: 5,

},

});

AppRegistry.registerComponent('rnandnative', () => AwesomeProject);

然后把index.android和兩個test頁面都用unbundle打包

react-nativeunbundle --platform android --devfalse--entry-file index.android.js --bundle-output unbundle/index.android.bundle

react-nativeunbundle --platform android --devfalse--entry-file bundletest1.js --bundle-output unbundle/index.android.bundle1

react-nativeunbundle --platform android --devfalse--entry-file bundletest2.js --bundle-output unbundle/index.android.bundle2

然后把index.android.bundle1、index.android.bundle2中除了_d的那句打頭的去掉,把__d(0的0改為9999、8888,把文件名改為9999.js和8888.js丟到j(luò)s-modules里,這個講的估計不是很明白,但去看看代碼就懂了。

然后建一個觸發(fā)emit的方法

public class RNBundleLoadModule extends ReactContextBaseJavaModule{

private ReactApplicationContext reactApplicationContext;

public RNBundleLoadModule(ReactApplicationContext reactApplicationContext){

super(reactApplicationContext);

}

@Override

public String? getName(){

return "BundleLoad";

}

@ReactMethod

public void goPage(final Integer pageid){

System.out.print("########"+pageid+"########");

// failedCallback.invoke();

WritableMap params = Arguments.createMap();

params.putInt("name", pageid);

reactApplicationContext

.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)

.emit("test", params);

}

}

跑起來,一切OK。

參考

https://github.com/pukaicom/reactNativeBundleBreak

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容