Oct
29
一般现代ReactNative二次封装框架除了处理bundle热更新,下发等,有一个设定是考虑到大量混合开发的情况,RN的应用级使用会改成页面级使用,以降低应用颗粒度的同时更自然的混合运行。但是这样做有一个存在疑问的地方,即每一个页面都会是用一个RN引擎即一个JSCore,内存占用高不说,每个页面的引擎创建成本也是相当高的,从性能/用户体验上均难以接受。因此会做“引擎复用”,即直观理解jscore复用这个事情。如果不对facebook的RN本身做大改而是基本基于其现有的api做二封的形式来完成,那引擎复用的方案/实现/与限制显然受到RN本身影响。因此本笔记讨论的是ReactNative到底做了啥,可以做到啥,哪儿来的限制。
为什么可以做引擎复用
通过引言部分其实这个回答很明显:当然是facebook的设计上本来就允许引擎复用。
- 具体来说,RN的设计上允许的是一个引擎替换页面进行渲染的。
- 当然引擎到页面的设计是无直接耦合的。
具体展开看,对于Android,这个设计是很自然的。因为安卓需要考虑,如果Activity在后台被回收,恢复的时候再重建的情况下,场景恢复,最简单的方案就是设计成Activity是可拆解的,jscore长期保留。
Android的RN的几个关键类关系如下:(不同版本有些不一样)
但是,基本可以理解为,上层界面层以Activity为核心载体,通过ReactDelegate进行界面层解耦合;另外一侧ReactInstanceManager是引擎和界面的实际耦合层,通过ReactDelegate耦合view的话,实际渲染context是ReactInstanceManager决定的。不过,实际提供的方法上并没有实现替换上层mActivity引用的能力,只有ReactInstanceManager保留的替换能力,并且是通过onHostResume在声明周期接口上标记的。
对于iOS来说,关键类如下:
界面上以RCTRootView为核心载体,通过RCTUIManager代理持有,而RCTBridge则是引擎本身。RCTUIManager提供了registerRootView来替换rootview。
以上设计的核心:view载体与引擎解耦,构成了引擎复用的基础。
引擎复用的范围与方案
除了布局侧,既然希望引擎可复用,我们需要关注引擎初始化过程中传入了哪些信息是不可编辑的。这些信息会构成我们引擎复用是的边界范围。
关注iOS的实际RCTBridge实例的构造方法:
这些入参,基本上锁定了RN引擎的:
- bundle,一个引擎初始化时即明确指定了加载哪一个bundle,引擎在初始化过程中完成加载过程并暴露过程回调。
- 桥列表,一个引擎可以使用的桥在初始化时就决定了,后面再独立懒加载添加的不会起作用。
- bundle初始化的额外入参,即launchOptions。
- 其他比如自定义jsc之类的,没啥必要定制化。
Android的情况和iOS几乎一样。
因此结论上,如果保持RN引擎设计不变,运行流程不做大改,一定是一个bundle一个引擎。
当然,一个bundle本身可以承载多个页面,bundle加载进引擎后,实际启动的哪个rootApp依赖的是界面渲染时RCTRootView的initWithBridge:moduleName:传入的moduleName决定的。显然对于同一个RCTBridge,我只需要创建不同的view就可以渲染出不同的页面了。
因此,实际上会存在:
- 同时存在复数个相同或者不同的页面(View->VC)使用同一个引擎。
- 该引擎最后一个页面关闭后,再打开,可以使用同一个引擎。
针对以上场景,引擎和界面的绑定关系就不再是一对一的了,而是可能存在一对多,或者一对零。这里也要分Android和iOS分开讨论。
- iOS是通过registerRootView去注册当前引擎操作的view的,当然我能从view找到vc。
- 假设我们一个vc有一个全屏的rnview,我有三个vc共用一个bundle,那么我在每个vc的viewdidappear时把自己的rootview注册到引擎上,引擎就可以继续执行该root组件相关的渲染逻辑了。
- 这里有一个坑是如果我一个vc有多个rnrootview,他们用同一个bundle,但是就没办法使用同一个引擎了。因此在这个特殊情况下,我们还是一个bundle多个引擎。
- 对于Android来说设计更复杂,因为安卓目前RN上层设计是基于Activity的,但是明显要对齐功能,基于RootView(至少用fragment去实现)去做更合理。因此在ReactDelegate这一层需要进行改写重新组合。
- 编写LRDReactDelegate,核心持有ReactRootView和ReactInstanceManager和其他东西。打通所有Activity / Fragement /直接使用View的场景下的生命周期。
- 由于ReactInstanceManager是依赖的Activity注册,因此一个bundle同时应该对应一个Activity,按理说和iOS不同可以做到同一个界面上多个rntootview使用同一个引擎。
- 大概如下图:
实际可用复用逻辑
结合以上讨论,我们的整体引擎复用逻辑可以用以下伪代码描述:
当然,bundleName是关键因素,其实还有其他会影响引擎复用的关键因素,包括:
- bundle加载方式,远端server还是本地下载好的包。
- bundle版本,如果上一个引擎同名bundle的版本和现在已更新的不一致,当然也要新引擎。
- 特殊的引擎复用开关来解决复用问题。
引擎复用带来的改变/风险与突破的方向
引擎复用带来的最大改变在js感知到的应用生命周期上——JS各项生命周期不固定了。
- JS毕竟是一门基础上面向过程的语言,每一个引擎启动本质上是运行了一大堆面向过程的静态代码,我们视这个过程为JS环境的生命周期的初始化过程。
- 如果没有引擎复用,页面级别的JS环境生命周期就和页面声明周期一直,每次打开页面都会重新运行,每个页面组件只会初始化。但是复用后,JS环境生命周期有可能后续就不会执行了,而JS页面组件的生命周期执行了两次。
- 同理,在一个引擎里,由于页面可以开多次,那同一个JS上下文中,同一个页面组件存在多个实例。
那么,实际落到js上,以前很多习惯的用法就不可行了:
- 比如在静态代码中进行数据/单例元素初始化动作,由于打开新页面可能复用的旧引擎,因此和页面相关的初始化动作必须写到页面生命周期中,而不是静态代码随便丢。
- 我们的redex,全局放store,或者cache,或者eventbus,已经不可行了,不同页面会串数据。因此所有页面数据必须放在页面组件中,或者最差需要通过页面标记(viewtag)做数据隔离。
- 由于我们的页面存在后台的情况,对应的比如引擎目前承担了3个页面组件,但是当前绑定的是顶层组件,那么如果我们后台的页面也去调用某个桥,涉及到界面或者context,那有可能调用到错误的界面,产生预期之外的效果。具体举个例子的话,比如我们某个页面进入后台时正在发送pending的网络请求,完成后调用某个桥startactivity。那这个时候,可能发生的事情包括:使用当前activity的context打开的新页面,找不到activity打开失败,或者符合预期的行为。也不是说有问题,但是在页面进入后台后,js引擎还存活就意味着js有机会在后台执行逻辑,而这些逻辑在界面上的表现:a. 无法表现。 b. 表现到当前显示的页面。都需要case by case的确认预期并处理。
另外对于native来说:
- 本身jscore就会有一定内存泄漏的问题,多个引擎必然会放大这类问题。对应着多引擎的好处即可以更小颗粒度的引擎管理,因此相比于上面的伪代码,需要更进行的当下运行状态和引擎池的管理。
- 创建和复用引擎的成本可能会导致不少页面,第一次进入和后续进入的速度,表现不一致,因此这类体验问题还需要专项排查并优化。
- jscore随时有可能发生不可逆转的异常。因此引擎维护的过程中异常状态识别也是一个问题。
- 当然在这些基础之上,为了保障在各类内存/运行异常发生后业务操作连贯性,脱离于引擎的js上下文环境储存-重建过程也需要考虑。
整体设计上,业务设计需要在性能与可维护性上做平衡,才能在多引擎复用的RN环境下收益最大化。
当然如果以下点工程上实现高可用的突破,会使得引擎复用收益更佳,包括:
1. 同一个引擎加载不同bundle。虽然RN引擎确实是和bundle绑定了,但是jscore本身确实具有运行时继续动态载入代码的能力。目前没这么做的主要原因,之前的js上下文与新加载进去的代码能否实现100%隔离无污染可能是未知数。不过通过js构建打包工具的完善收口,理论上是可以无缝完成的。
2. 多页面js上下文隔离。目前引起复用的一大坑其实来源于js上下文多个页面混在一起,容易出错。通过js构建方式,或者通过jscore使用方式,如v8worker之内的方案若能实现数据隔离的话,引擎复用易用性会大大提升。当然还需要保留页面之间特有的通信能力。
3. 引擎同时操作多个view的能力。当前RN引擎设计操作view是一一对应的,虽然有其优势在,单着其实是RN原本设计延伸而来的。考虑到后续jsi的通信方式,在2方向的基础上,其实完全能做到一个引擎同时操纵个多界面的。这样就和native能力对齐了。
4. 跨bundle通信能力。多bundle之后,bundle之间的通信与数据共享能力往往是通过桥方案实现的,但是桥方案往往也对应着大量性能瓶颈。如果能在一定层面上实现jsvalue互通贡献,包括特定场景下和native逻辑互通,多引擎副作用的页面通信成本也迎刃而解了。最理想的情况是有一个类似于小程序的app的概念在应用/共享级的动态化场景下统领全局。
为什么可以做引擎复用
通过引言部分其实这个回答很明显:当然是facebook的设计上本来就允许引擎复用。
- 具体来说,RN的设计上允许的是一个引擎替换页面进行渲染的。
- 当然引擎到页面的设计是无直接耦合的。
具体展开看,对于Android,这个设计是很自然的。因为安卓需要考虑,如果Activity在后台被回收,恢复的时候再重建的情况下,场景恢复,最简单的方案就是设计成Activity是可拆解的,jscore长期保留。
Android的RN的几个关键类关系如下:(不同版本有些不一样)
但是,基本可以理解为,上层界面层以Activity为核心载体,通过ReactDelegate进行界面层解耦合;另外一侧ReactInstanceManager是引擎和界面的实际耦合层,通过ReactDelegate耦合view的话,实际渲染context是ReactInstanceManager决定的。不过,实际提供的方法上并没有实现替换上层mActivity引用的能力,只有ReactInstanceManager保留的替换能力,并且是通过onHostResume在声明周期接口上标记的。
对于iOS来说,关键类如下:
界面上以RCTRootView为核心载体,通过RCTUIManager代理持有,而RCTBridge则是引擎本身。RCTUIManager提供了registerRootView来替换rootview。
以上设计的核心:view载体与引擎解耦,构成了引擎复用的基础。
引擎复用的范围与方案
除了布局侧,既然希望引擎可复用,我们需要关注引擎初始化过程中传入了哪些信息是不可编辑的。这些信息会构成我们引擎复用是的边界范围。
关注iOS的实际RCTBridge实例的构造方法:
- (instancetype)initWithDelegate:(id<RCTBridgeDelegate>)delegate
bundleURL:(NSURL *)bundleURL
moduleProvider:(RCTBridgeModuleListProvider)block
launchOptions:(NSDictionary *)launchOptions;
@protocol RCTBridgeDelegate <NSObject>
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge;
@optional
- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge;
- (BOOL)shouldBridgeUseCustomJSC:(RCTBridge *)bridge;
- (BOOL)bridge:(RCTBridge *)bridge didNotFindModule:(NSString *)moduleName;
- (void)loadSourceForBridge:(RCTBridge *)bridge
onProgress:(RCTSourceLoadProgressBlock)onProgress
onComplete:(RCTSourceLoadBlock)loadCallback;
- (void)loadSourceForBridge:(RCTBridge *)bridge withBlock:(RCTSourceLoadBlock)loadCallback;
- (NSDictionary<NSString *, Class> *)extraLazyModuleClassesForBridge:(RCTBridge *)bridge;
@end
这些入参,基本上锁定了RN引擎的:
- bundle,一个引擎初始化时即明确指定了加载哪一个bundle,引擎在初始化过程中完成加载过程并暴露过程回调。
- 桥列表,一个引擎可以使用的桥在初始化时就决定了,后面再独立懒加载添加的不会起作用。
- bundle初始化的额外入参,即launchOptions。
- 其他比如自定义jsc之类的,没啥必要定制化。
Android的情况和iOS几乎一样。
因此结论上,如果保持RN引擎设计不变,运行流程不做大改,一定是一个bundle一个引擎。
当然,一个bundle本身可以承载多个页面,bundle加载进引擎后,实际启动的哪个rootApp依赖的是界面渲染时RCTRootView的initWithBridge:moduleName:传入的moduleName决定的。显然对于同一个RCTBridge,我只需要创建不同的view就可以渲染出不同的页面了。
因此,实际上会存在:
- 同时存在复数个相同或者不同的页面(View->VC)使用同一个引擎。
- 该引擎最后一个页面关闭后,再打开,可以使用同一个引擎。
针对以上场景,引擎和界面的绑定关系就不再是一对一的了,而是可能存在一对多,或者一对零。这里也要分Android和iOS分开讨论。
- iOS是通过registerRootView去注册当前引擎操作的view的,当然我能从view找到vc。
- 假设我们一个vc有一个全屏的rnview,我有三个vc共用一个bundle,那么我在每个vc的viewdidappear时把自己的rootview注册到引擎上,引擎就可以继续执行该root组件相关的渲染逻辑了。
- 这里有一个坑是如果我一个vc有多个rnrootview,他们用同一个bundle,但是就没办法使用同一个引擎了。因此在这个特殊情况下,我们还是一个bundle多个引擎。
- 对于Android来说设计更复杂,因为安卓目前RN上层设计是基于Activity的,但是明显要对齐功能,基于RootView(至少用fragment去实现)去做更合理。因此在ReactDelegate这一层需要进行改写重新组合。
- 编写LRDReactDelegate,核心持有ReactRootView和ReactInstanceManager和其他东西。打通所有Activity / Fragement /直接使用View的场景下的生命周期。
- 由于ReactInstanceManager是依赖的Activity注册,因此一个bundle同时应该对应一个Activity,按理说和iOS不同可以做到同一个界面上多个rntootview使用同一个引擎。
- 大概如下图:
实际可用复用逻辑
结合以上讨论,我们的整体引擎复用逻辑可以用以下伪代码描述:
class Engine {
public Object innerEngine;//引擎本身
public String bundleName;//引擎名字
public RootView view;//引擎当前渲染对象,view or null
public Timer timer;//销毁倒计时timer
}
class EngineManager {
private Map<String, Engine> engines;//引擎池
//当某个rootview要绑定引擎时
public void bindEngine(String bundleName, RootView view) {
Engine engine = engines.get(bundleName)
if (engine && engine.view != null) {//如果引擎当前在使用,则不可用
engine = null;
}
if (engine == null) {
engine = new Engine(bundleName);
if (engines.get(bundleName)) {//如果重名的特殊情况,用一个临时key
engines.add(bundleName + Math.random(), engine);
} else {
engines.add(bundleName, engine);
}
}
engine.view = view;
engine.timer.stop();//停止引擎弃用的倒计时
engine.timer = null;
engine.refresh();
}
//当某个rootview要取消绑定时
public void stopEngine(String bundleName, RootView view) {
Engine engine = engines.get(bundleName)
if (engine && engine.view == view) {
engine.view = null;
engine.timer = new Timer(5 * 60 * 1000, new Function() {//倒计时5分钟,如果5分钟内引擎没被再次使用,就移出引擎池,销毁
engines.remove(bundleName);
});
}
}
//当内存警告时
public void onMemeryWarning() {
//找出剩余销毁时间最短的引擎
Time leftTime = MAX_DOUBLE;
Engine minLeftEngine = null;
for (Engine engine in engines) {
if (engine.view == null && engine.timer.leftTime < leftTime) {
minLeftEngine = engine;
leftTime = engine.timer.leftTime;
}
}
//执行销毁
if (minLeftEngine) {
minLeftEngine.timer.fire();
}
}
}
class RNPage {
public BundleInfo bundle;
public RootView rnview;
public void onResume/viewDidAppear() {
EngineManager.shared().bindEngine(bundle.name, rnview);
}
public void onPause/viewDidDisappear() {
EngineManager.shared().stopEngine(bundle.name, rnview);
}
}
当然,bundleName是关键因素,其实还有其他会影响引擎复用的关键因素,包括:
- bundle加载方式,远端server还是本地下载好的包。
- bundle版本,如果上一个引擎同名bundle的版本和现在已更新的不一致,当然也要新引擎。
- 特殊的引擎复用开关来解决复用问题。
引擎复用带来的改变/风险与突破的方向
引擎复用带来的最大改变在js感知到的应用生命周期上——JS各项生命周期不固定了。
- JS毕竟是一门基础上面向过程的语言,每一个引擎启动本质上是运行了一大堆面向过程的静态代码,我们视这个过程为JS环境的生命周期的初始化过程。
- 如果没有引擎复用,页面级别的JS环境生命周期就和页面声明周期一直,每次打开页面都会重新运行,每个页面组件只会初始化。但是复用后,JS环境生命周期有可能后续就不会执行了,而JS页面组件的生命周期执行了两次。
- 同理,在一个引擎里,由于页面可以开多次,那同一个JS上下文中,同一个页面组件存在多个实例。
那么,实际落到js上,以前很多习惯的用法就不可行了:
- 比如在静态代码中进行数据/单例元素初始化动作,由于打开新页面可能复用的旧引擎,因此和页面相关的初始化动作必须写到页面生命周期中,而不是静态代码随便丢。
- 我们的redex,全局放store,或者cache,或者eventbus,已经不可行了,不同页面会串数据。因此所有页面数据必须放在页面组件中,或者最差需要通过页面标记(viewtag)做数据隔离。
- 由于我们的页面存在后台的情况,对应的比如引擎目前承担了3个页面组件,但是当前绑定的是顶层组件,那么如果我们后台的页面也去调用某个桥,涉及到界面或者context,那有可能调用到错误的界面,产生预期之外的效果。具体举个例子的话,比如我们某个页面进入后台时正在发送pending的网络请求,完成后调用某个桥startactivity。那这个时候,可能发生的事情包括:使用当前activity的context打开的新页面,找不到activity打开失败,或者符合预期的行为。也不是说有问题,但是在页面进入后台后,js引擎还存活就意味着js有机会在后台执行逻辑,而这些逻辑在界面上的表现:a. 无法表现。 b. 表现到当前显示的页面。都需要case by case的确认预期并处理。
另外对于native来说:
- 本身jscore就会有一定内存泄漏的问题,多个引擎必然会放大这类问题。对应着多引擎的好处即可以更小颗粒度的引擎管理,因此相比于上面的伪代码,需要更进行的当下运行状态和引擎池的管理。
- 创建和复用引擎的成本可能会导致不少页面,第一次进入和后续进入的速度,表现不一致,因此这类体验问题还需要专项排查并优化。
- jscore随时有可能发生不可逆转的异常。因此引擎维护的过程中异常状态识别也是一个问题。
- 当然在这些基础之上,为了保障在各类内存/运行异常发生后业务操作连贯性,脱离于引擎的js上下文环境储存-重建过程也需要考虑。
整体设计上,业务设计需要在性能与可维护性上做平衡,才能在多引擎复用的RN环境下收益最大化。
当然如果以下点工程上实现高可用的突破,会使得引擎复用收益更佳,包括:
1. 同一个引擎加载不同bundle。虽然RN引擎确实是和bundle绑定了,但是jscore本身确实具有运行时继续动态载入代码的能力。目前没这么做的主要原因,之前的js上下文与新加载进去的代码能否实现100%隔离无污染可能是未知数。不过通过js构建打包工具的完善收口,理论上是可以无缝完成的。
2. 多页面js上下文隔离。目前引起复用的一大坑其实来源于js上下文多个页面混在一起,容易出错。通过js构建方式,或者通过jscore使用方式,如v8worker之内的方案若能实现数据隔离的话,引擎复用易用性会大大提升。当然还需要保留页面之间特有的通信能力。
3. 引擎同时操作多个view的能力。当前RN引擎设计操作view是一一对应的,虽然有其优势在,单着其实是RN原本设计延伸而来的。考虑到后续jsi的通信方式,在2方向的基础上,其实完全能做到一个引擎同时操纵个多界面的。这样就和native能力对齐了。
4. 跨bundle通信能力。多bundle之后,bundle之间的通信与数据共享能力往往是通过桥方案实现的,但是桥方案往往也对应着大量性能瓶颈。如果能在一定层面上实现jsvalue互通贡献,包括特定场景下和native逻辑互通,多引擎副作用的页面通信成本也迎刃而解了。最理想的情况是有一个类似于小程序的app的概念在应用/共享级的动态化场景下统领全局。