Oct 29

对ReactNative引擎进行持有/复用/与限制等讨论

Lrdcq , 2020/10/29 21:12 , 程序 , 閱讀(3328) , Via 本站原創
一般现代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实例的构造方法:
- (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的概念在应用/共享级的动态化场景下统领全局。
logo