RN的bundle拆分与合并之安卓篇

在网上看到携程之前拆分的一些经验

先来说一组数据,一个Helloorld的App,如果使用0.30 RN 官方命令react-native bundle打包出来的JSBundle文件大小大约为531KB,RN框架JavaScript本身占了530KB, zip压缩之后也有148KB。
如果只有一两个业务使用,这点大小算不了什么,但是对于我们这种动辄几十个业务的场景,如果每个业务的JSBundle都需要这么大的一个RN框架本身,那将是不可接受的。
因此,我们需要对RN官方的打包脚本做改造,将框架代码拆分出来,让所有业务使用一份框架代码。
开始拆分之前, 我们先以HelloWorld的RNApp为基础介绍几个背景知识。


上述是一个HelloWorld RNApp代码的结构,基本分为3部分
头部:各依赖模块引用部分;
中间:入口模块和各业务模块定义部分;
尾部:入口模块注册部分;

上述是HelloWorld RNApp打包之后JSBundle文件的结构,基本分为3部分 头部:全局定义,主要是define,require等全局模块的定义; 中间:模块定义,RN框架和业务的各个模块定义; 尾部:引擎初始化和入口函数执行;
__d是RN自定义的define,符合CommonJS规范,__d后面的数字是模块的id,是在RN打包过程中,解析依赖关系,自增长生成的。
如果所有业务代码,都遵照一个规则:入口JS文件首先require的都是react/react-native, 则打包生成的JSBundle里面react/react-native相关的模块id都是固定的。
拆分方案一
基于上面2点背景知识介绍,我们很容易发现,如果将打包之后的JSBundle文件,拆分成2部分(框架部分+业务模块部分),使用的时候合并起来,然后去加载,即可实现拆分功能。
具体实现步骤:
创建一个空工程,入口文件只需要2行代码,require react/react-native即可;
使用react-native bundle命令,打包该入口文件,生成common.js;
使用react-native bundle打包业务工程(有一点要保证,业务工程入口文件前面2行代码也是require react/react-native), 生成business_all.js;
开发工具,从business_all.js里面删除common.js的内容,剩下的就是business.js;
App加载的时候将common.js和business.js合并在一起,然后加载;

貌似功能完成,可是回到Dive into React Native performance, 这么做还是优化不了JSBundle的执行时间,因为我们不能把拆分开的2个文件分别执行,因为加载common.js会提示找不到RNApp的入口,先执行business.js,会提示一堆依赖的RN模块找不到。
显然,这种拆分方式不能满足我们这种需要。
那这个方案就完全没有价值吗?不是的,如果你做的是一个纯RNApp,native只是一个壳,里面业务全是RN开发的,完全可以使用这种方式做拆分,这种方案简单,无侵入,实现成本低,不需要修改任何RN打包代码和RN Runtime代码。
拆分方案二
RN框架部分文件(common.js)大小530KB,如此大的js文件,占用了绝大部分的JS执行时间,这块时间如果能放到后台预先做完,进入业务也只需执行业务页面的几个JS文件,将可以大大提升页面加载速度,参考上面的RN性能瓶颈图,预估可以提升100%。
按照这个思路,能后台加载的JS文件, 实际上是就是一个RNApp,因此 我们设计了一个空白页面的FakeApp,这个FakeApp做一件事情,就是监听要显示的真实的业务JS模块,收到监听之后,渲染业务模块,显示页面。
FakeApp设计如下:


为了实现该拆包方案,需要改造react-native的打包命令;
基于FakeApp打common.js包的时候, 需要记录RN各个模块名和模块id之间的mapping关系;
打业务模块包的时候,判断,如果已经在mapping文件里面的模块,不要打包到业务包中

改造页面加载流程:
因为要能够后台加载,所以需分离UI和JS加载引擎<iOS-RCTBridge, Android-ReactInstanceManager>;
进入业务RN页面时候,获取预加载好的JS引擎,然后发送消息给FakeApp,告知该渲染的业务JS模块;

通过后台预加载,省去了绝大部分的JS加载时间,似乎问题已经完美解决。
但是,如果随着业务不断膨胀,一个RN业务JS代码也达到500KB,进入这个业务页面,500多KB JS文件读取出来,执行,整个JS执行的时间瓶颈会再次出现。
拆分方案三
正在此时,我们研究RN在Facebook App里面的使用情况,发现了Unbundle,简单点说,就是将所有的JS模块都拆分成独立的文件。
下面截图就是unbundle打包的文件格式:


entry.js就是global部分定义+RNApp入口;
UNBUNDLE文件是用于标识这是一个unbundle包的flag;
12.js,13.js就是各个模块,文件名就是模块id;
在业务执行,需要加载模块(require)的时候,就去磁盘查找该文件,读取、执行。

RN里面加载模块流程说明,以require(66666)模块为例:
首先从__d<就是前文提到的define>的缓存列表里面查找是否有定义过模块66666,如果有,直接返回,如果没有走到下面第二步的nativeRequire;
nativeRequire根据模块id,查找文件所在路径,读取文件内容;
定义模块,_d(66666)=eval(JS文件内容),会将这个模块ID和JS代码执行结果记录在define的缓存列表里面;

打包通过react-native unbundle 命令,可以给android平台打出这样的unbundle包。
顺便提一下,这个unbundle方案,只在android上有效,打ios平台的unbundle包,是打不出来的,在RN的打包脚本上有一行注释,大致意思是在iOS上众多小文件读取,文件IO效率不够高,android上没这样的问题,然后判断如果是打iOS的unbundle包的时候,直接return了。
相对应的,iOS开发了一个prepack的打包模式,简单点说,就是把所有的JS模块打包到一个文件里面,打包成一个二进制文件,并固定0xFB0BD1E5为文件开始,这个二进制文件里面有个meta-table,记录各个模块在文件中的相对位置,在加载模块(require)的时候,通过fseek,找到相应的文件开始,读取,执行。
在Unbundle的启发下,我们修改打包工具,开发了CRNUnbunle,做了简单的优化,把众多零散的JS文件做了简单的合并。


将common部分的JS文件,合并成一个common_ios(android).js.
_crn_config记录了这个RNApp的入口模块ID以及其他配置信息,详见下图:

main_module为当前业务模块入口模块ID;
module_path为业务模块JS文件所在当前包的相对路径;
666666=0.js,说明666666这个模块在0.js文件里面;

做完这个拆包和加载优化之后,我们用自己的几个业务做了下测试,下图是当时的测试验证数据。


可以看出,iOS和android基本都比官方打包方式的加载时间,减少了50%。
这是自己单机测试的数据,那上线之后,数据如何呢?
下图,是我们分析一天的数据,得出的平均值<排除掉了5s以上的异常数据,后面实测下来5s以上数据极少>;

看到这个数据,发现和我们自己测试的基本一致,但是还有一个疑问,加载的时间分布,是否服从正态分布,会不会很离散,快的设备很快,慢的设备很慢呢?
然后我又进一步分析这一天的数据,按照页面加载时间区间分布统计。

看图上数据,很明显,iOS&Android基本一致,将近98%的用户都能在1s内加载完成页面,符合我们期望的正态分布,所以bundle拆分到此基本完成。


实践

我先用bundle打包命令打一个bundle出来

 react-native bundle --platform android --dev false --entry-file index.android.js --bundle-output finalbundle/index.android.bundle --assets-dest finalbundle/

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




密密麻麻但又有规则

  • !function打头的是公共的头部部分
  • _d(function是JS文件,用ctrl+s搜索welcome,找到我们的index.android.js,原来是在第一行的_d(function,而且结尾有个参数0,其余部分其实都是公共的js
  • ;require(120),是基础文件的配置入口,require(0)则是业务的入口

基于以上,能想到一个办法:

  • 内置一个common.js文件,里面包含了bundle文件公共部分的代码,
  • 业务代码单独生成一个js文件
  • 在需要展示加载某一个页面的时,将common.js和当前页面需要加载的业务js合并,然后再加载

这个办法解决了一部分问题,但加载时还是一个整体。如果common部分能重用,就能大大提升效率。所以就来试试上面提到的unbundle命令

react-native unbundle --platform android --dev false --entry-file index.android.js --bundle-output build/index.android.bundle

生成的bundle只有14行了



但多了一个js-modules文件夹,里面的xx.js里面的内容就是将之前的__d(xx)抽出来单独放到一个文件里面,通过require(xx)加载到内存供调用


基于unbundle命令再设计一个上面提到的fake页面用来加载相应的业务模块,这个页面可以预先在后台初始化js引擎,将公共部分的common.js文件读取到内存,然后设置一个监听事件,通过emmit方式,当需要加载某个页面的的module的时候讲这个页面的module的id传递过来,然后通过require方法调用这个模块。


思路差不多是这样了,来试试看实现起来有没什么坑。

首先

我拿例子 跑了一下,瞬间明白了流程是怎么回事,有几个关键:

  • DeviceEventEmitter
    前端发起监听,后端需要用的时候调用emit触发,通过返回模块id,然后return React.createElement(返回的模块ID,this.props)即可定制加载
  • 配置文件

    这个配置文件之前不是很理解为什么好多等于0.js、等于1.js,现在明白其实就是不同bu的入口JS,因为都是单页路由的形式,不过这个配置其实是一套打包的一个流程,不在这里做,以后研究打包工具的时候加上。

然后

我试着把这样融入到之前的demo里。

先建两个test页面,用于测试切换。

import React, { Component } from 'react';
import {
    AppRegistry,
    StyleSheet,
    Text,
    View,
} from 'react-native';

class testeight extends Component {
    render() {
        return (
            <View style={styles.container}>
                <Text style={styles.welcome}>
                    Welcome to Test 8888
                </Text>
            </View>
        );
    }
}

const styles = 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
 */

import React, { Component } from 'react';
import {
    AppRegistry,
    StyleSheet,
    Text,
    View,
    Image,
    NativeModules,
    DeviceEventEmitter,
} from 'react-native';

export default class AwesomeProject extends Component {
    constructor(props){
        super(props);
        this.state = {
           content:null,showModule:false
        };
        DeviceEventEmitter.addListener("test", (result) => {
            let mainComponent = 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 (
                <View style={styles.container}>
                    <Text style={styles.welcome}>
                        Welcome to React Native!
                    </Text>
                    <Text style={styles.instructions}>
                        To get started, edit index.android.js
                    </Text>
                    <Text style={styles.instructions}>
                        Double tap R on your keyboard to reload,{'\n'}
                        Shake or press menu button for dev menu
                    </Text>
                    <Text style={styles.instructions} onPress={() => this.showToast()}>
                        点我调用原生
                    </Text>
                    <Text style={styles.instructions} onPress={() => this.updateBundle()}>
                        点我更新bundle
                    </Text>
                    <Text style={styles.instructions} onPress={() => this.goNine()}>
                        点我加载页面9999
                    </Text>
                    <Text style={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 () {
        //调用原生
        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-native unbundle --platform android --dev false --entry-file index.android.js --bundle-output unbundle/index.android.bundle
react-native unbundle --platform android --dev false --entry-file bundletest1.js --bundle-output unbundle/index.android.bundle1
react-native unbundle --platform android --dev false --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丢到js-modules里,这个讲的估计不是很明白,但去看看代码就懂了。
然后建一个触发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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容