React Native 按需加载实战(二)

前言

上一篇文章介绍了如何对 React Native 项目的 JS 文件进行拆包,这次我们仍然用一个例子来演示如何按需加载拆包后的各文件。

目标

如上图所示,最终想实现如下效果:

  1. 利用上一篇文章的方法,把 React Native 应用打包成三个 bundle:

其中 base.bundle.js 仅包括基础库:

1
2
import 'react'
import 'react-native'

home.bundle.js 包括的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 打包入口文件 index.js
import {AppRegistry} from 'react-native'
import Home from './App'

AppRegistry.registerComponent('home', () => Home)

// App.js
import React, {useEffect} from 'react'
import {View, Text, Button, StyleSheet, NativeModules} from 'react-native'

const Home = () => {
return (
<View>
<View>
<Text>Home</Text>
</View>
<View>
<Button
title='Go To Business1'
onPress={() => {
NativeModules.Navigator.push('business1')
}}
/>
</View>
</View>
)
}

export default Home

注意,这里我们实现了一个 Native Module Navigator,后面会介绍。

business1.bundle.js 包括的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 打包入口文件 index.js
import {AppRegistry} from 'react-native'
import Business1 from './App'

AppRegistry.registerComponent('business1', () => Business1)

// App.js
import React, {useEffect} from 'react'
import {View, Text, StyleSheet, Alert} from 'react-native'

const Business1 = () => {
useEffect(() => {
Alert.alert(global.name)
}, [])
return (
<View>
<Text>Business1</Text>
</View>
)
}
export default Business1
  1. 进入应用时,先加载运行 base.bundle.js(包含 react 及 react-native 等基础库),然后加载运行 home.bundle.js,此时页面显示 home 相关的内容。
  2. 点击 home 页面上的 Go To Business1 跳转到 business1 页面,此时会加载运行 business1.bundle.js,然后显示 business1 页面。

前置知识

Objective—C 语法简单介绍

Objective—C(以下简称 OC)是一门强类型语言,需要声明变量的类型:

1
NSSttring * appDelegateClassName;

OC 中函数调用非常奇怪,是用 [] 包起来的:

1
self.view.backgroundColor = [UIColor whiteColor];

OC 中也支持函数作为参数:

1
2
3
[Helper loadBundleWithURL:bundleUrl onComplete:^{
[Helper runApplicationOnView:view];
}]

OC 中也有类的概念:

1
2
3
4
5
6
7
8
9
10
11
// 类声明文件 ViewController.h(继承自 UIViewController)
@interface ViewController : UIViewController
...
@end

// 类实现文件 ViewController.m
@implementation ViewController
- (void)viewDidLoad {

}
@end

更多知识请自行补充。

iOS 开发简单介绍

UIView

UIView 是最基础的视图类,管理屏幕上一定的内容展示,作为各种视图类型的父类,提供一些基础的能力,如外观、事件等,它可以布局和管理子视图。下面这个例子实现了在当前页面上新增了一个黄色的子视图:

1
2
3
4
5
6
7
8
9
10
11
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor redColor];
// subview
UIView *view = [[UIView alloc] init];
view.frame = CGRectMake(50, 50, 100, 50);
view.backgroundColor = [UIColor yellowColor];
[self.view addSubview:view];
}
@end

UIViewController

UIViewController 是视图控制器,用于管理视图的层级结构,它自身默认包含一个视图。他可以管理视图的生命周期,视图之间的切换等,且 UIViewController 又可以管理别的 UIViewController。这里有个例子通过 UINavigationController 实现了两个 UIViewController 之间的切换:

React Native 页面加载流程介绍

首先,我们通过以下命令新建一个 RN 项目:

1
npx react-native init demo

我们先来看看入口文件 index.js:

1
2
3
4
5
import {AppRegistry} from 'react-native'
import App from './App'
import {name as appName} from './app.json'

AppRegistry.registerComponent(appName, () => App)

显然,需要了解 AppRegistry.registerComponent 是做了什么,我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Registers an app's root component.
*
* See https://reactnative.dev/docs/appregistry.html#registercomponent
*/
registerComponent(
appKey: string,
componentProvider: ComponentProvider,
section?: boolean,
): string {
let scopedPerformanceLogger = createPerformanceLogger();
runnables[appKey] = {
componentProvider,
run: (appParameters, displayMode) => {
renderApplication(
componentProviderInstrumentationHook(
componentProvider,
scopedPerformanceLogger,
),
appParameters.initialProps,
appParameters.rootTag,
wrapperComponentProvider && wrapperComponentProvider(appParameters),
appParameters.fabric,
showArchitectureIndicator,
scopedPerformanceLogger,
appKey === 'LogBox',
appKey,
coerceDisplayMode(displayMode),
appParameters.concurrentRoot,
);
},
};
if (section) {
sections[appKey] = runnables[appKey];
}
return appKey;
},

从上面的代码来看,它只是将组件存放在了 runnables 中,并没有真正的渲染。那什么时候渲染呢?这就需要看看 native 的代码了,我们打开 ios 目录下的 AppDelegate.m 文件,可以看到:

1
2
3
4
5
6
7
8
9
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
...
RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:@"rnDemo"
initialProperties:nil];
...
}

第一行代码会进行 bridge 的初始化等工作,然后会异步加载 JS 文件并执行。也就是会执行 AppRegistry.registerComponent。

第二行代码会准备一个视图容器用于渲染,并会监听 JS 文件加载。当加载成功时,会调用 JS 代码中的 AppRegistry.runApplication:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)runApplication:(RCTBridge *)bridge
{
NSString *moduleName = _moduleName ?: @"";
NSDictionary *appParameters = @{
@"rootTag" : _contentView.reactTag,
@"initialProps" : _appProperties ?: @{},
};

RCTLogInfo(@"Running application %@ (%@)", moduleName, appParameters);
// 调用 JS 代码中的方法
[bridge enqueueJSCall:@"AppRegistry" method:@"runApplication" args:@[ moduleName, appParameters ] completion:NULL];
}

而 JS 代码中的 AppRegistry.runApplication 会执行 runnables 中相应的 run 方法最终进行页面的渲染:

1
2
3
4
5
6
7
8
runApplication(
appKey: string,
appParameters: any,
displayMode?: number,
): void {
...
runnables[appKey].run(appParameters, displayMode);
},

实现

介绍了这么多准备知识以后,我们终于要开始实现我们的按需加载了,首先介绍一下整体方案。

方案设计


如图所示,在应用启动的时候我们会初始化一个 MyRNViewController 并通过 UINavigationContoller 来管理。当 MyRNViewController 中的试图加载完成后,会通过 Bridge 加载并执行 base.bundle.js 和 home.bundle.js,成功后会执行 runApplication 方法渲染页面。

当点击 Go To Business1 按钮时,会使用 UINavigationContoller 推入一个新的 MyRNViewController,当 MyRNViewController 中的试图加载完成后,会使用相同的 Bridge 加载执行 business1.bundle.js,成功后会执行 runApplication 方法渲染页面。

接下来详细介绍一下。

AppDelegate.m 的改造

如上文所说,应用启动的时候会初始化一个 MyRNViewController 并通过 UINavigationContoller,这一步在 AppDelegate.m 的 application 方法中来实现:

1
2
3
4
5
6
7
8
9
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
...
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
MyRNViewController *vc = [[MyRNViewController alloc] initWithModuleName:@"home"];
self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:vc];
[self.window makeKeyAndVisible];
return YES;
}

MyRNViewController

1
2
3
4
5
6
// MyRNViewController 初始化后会自动调用
- (void)loadView {
RCTRootView *rootView = [Helper createRootViewWithModuleName:_moduleName
initialProperties:@{}];
self.view = rootView;
}

MyRNViewController 初始化的时候会自动调用 loadView 这个方法,该方法创建了一个 RCTRootView 并作为该 ViewController 的默认视图:

1
2
3
4
5
6
7
8
9
10
11
12
+ (RCTRootView *) createRootViewWithModuleName:(NSString *)moduleName initialProperties:(NSDictionary *)initialProperties {
// _sharedBridge 全局共享的 RCTBridge
// 如果还未初始化则初始化
if (!_sharedBridge) {
[self createBridgeWithURL:[NSURL URLWithString:baseUrl]];
}

RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_sharedBridge
moduleName: moduleName
initialProperties: initialProperties];
return rootView;
}

MyRNViewController 的默认视图加载完成后会执行 viewDidLoad 方法:

1
2
3
4
5
6
// MyRNViewController 中的试图加载完成后
- (void)viewDidLoad {
NSString *randStr = [Helper randomStr:2];
NSURL *moduleUrl = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:8080/%@.bundle.js?v=%@", _moduleName, randStr]];
[Helper loadBundle:moduleUrl runAppOnView:self.view];
}

该方法中会去加载执行对应的 bundle 并在当前 view 上执行 runApplication 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
+ (void) loadBundle:(NSString *)bundleUrl runAppOnView:(RCTRootView*)view {
// 确保 base bundle 只加载一次
if (needLoadBaseBundle) {
[Helper loadBundleWithURL:[NSURL URLWithString:baseUrl] onComplete:^{
needLoadBaseBundle = false;
[Helper loadBundleWithURL:bundleUrl onComplete:^{
[Helper runApplicationOnView:view];
}];
}];
} else {
[Helper loadBundleWithURL:bundleUrl onComplete:^{
[Helper runApplicationOnView:view];
}];
}
}

+ (void) loadBundleWithURL:(NSURL *)bundleURL onComplete:(dispatch_block_t)onComplete {
[_sharedBridge loadAndExecuteSplitBundleURL2:bundleURL onError:^(void){} onComplete:^{
NSLog([NSString stringWithFormat: @"%@", bundleURL]);
onComplete();
}];
}

+ (void) runApplicationOnView:(RCTRootView *)view {
[view runApplication:_sharedBridge];
}

这里 loadAndExecuteSplitBundleURL2 是在 react-native 源码中新加的方法,同时还把 (void)runApplication:(RCTBridge *)bridge; 声明为了公共方法,供外部使用。详情见这里。

按需加载

当我们点击 Go To Business1 按钮的时候会触发按需加载,这个又是如何实现的呢?我们来看看 home 页面的代码:

1
2
3
4
5
6
7
8
9
import {View, Text, Button, StyleSheet, NativeModules} from 'react-native';
...
<Button
title='Go To Business1'
onPress={() => {
NativeModules.Navigator.push('business1')
}}
/>
...

这里我们其实是实现了一个 Native Module Navigator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Navigator.h
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface Navigator : NSObject <RCTBridgeModule>

@end
// Navigator.m
#import <UIKit/UIKit.h>

#import "Navigator.h"
#import "Helper.h"
#import "MyRNViewController.h"

@implementation Navigator

RCT_EXPORT_MODULE(Navigator);

/**
* We are doing navigation operations, so make sure they are all on the UI Thread.
* Or you can wrap specific methods that require the main queue like this:
*/
- (dispatch_queue_t)methodQueue
{
return dispatch_get_main_queue();
}

RCT_EXPORT_METHOD(push:(NSString *)moduleName) {
MyRNViewController *newVc = [[MyRNViewController alloc] initWithModuleName:moduleName];
[[Helper getNavigationController] showViewController:newVc sender:self];
}

@end

当调用 push 方法时,会向 UINavigationContoller 中新推入一个 MyRNViewController,之后的逻辑就跟上面说过的类似了。

总结

基于上一篇文章的研究成果,本文对一个实际的 React Native 例子进行了拆包。然后通过改写 native 端的代码,实现了对不同业务包的按需加载。项目完整代码在此。

不过,当前只是简单实现,演示过程而已,实际上还有很多优化可以做:

  • 当前加载过的 bundle 并没有缓存起来,每次都会重新去下载。加上缓存后,还可以做差量更新的优化,即每次发布最新的 bundle 时,计算其与之前版本的差异,客户端加载 bundle 时仅需要在旧 bundle 上应用差异的部分。
  • 所有 bundle 运行在同一个上下文之中,存在全局变量污染、某个 bundle 运行 crash 会导致所有业务奔溃等问题。