React Native 作为一个跨平台开发解决方案,宣称具有接近原生的用户体验。但是在实际的开发中我们发现,从原生页面第一次跳转到 React Native 页面时,会出现短暂的"白屏"现象。如果是从一些性能较差的 Android 手机上跳转至一个较为复杂的 React Native 页面的话,这种白屏现象会更加明显。那么这种白屏的现象是怎么产生的呢?我们能不能通过技术手段减少"白屏"的持续时间呢?下面我们就来探究一下这两个问题。
白屏现象是怎么产生的?
首先我们关注一下 React Native 页面第一次被渲染到屏幕上时,发生了什么。当用户第一次跳转到 React Native 页面时,主要经历了四个步骤:
- iOS/Android 原生端会对应地创建一个根视图(RCTRootView/ReactRootView)作为 UIViewController/ReactActivity 的内容视图;
- 接下来原生端会创建一个 Bridge 实例(RCTBridge/ReactInstanceManager);
- 用户在这个时候就可以看到页面跳转的动画了,但是与此同时原生端会调用 JavaScript 端的 runApplication() 方法创建 React.Component 实例,目前还没有完成第一次渲染,所以此时内容视图上还是空白的;
- 直到 JavaScript 端执行完第一次渲染,来到了 componentDidMount 生命周期,页面才完全渲染完毕,达到可以响应用户交互的状态。
在这四个步骤执行期间,也就是从创建根视图作为原生端的内容视图开始,到 JavaScript 端第一次渲染完毕为止这段时间,就是"白屏"现象的持续时间。讲到这里我们就可以了解到:由于原生端刚准备好"画布"的时候页面就对用户可见了,所以用户看到的"白屏"其实是空白的一张"画布"。经过环境创建和 JS 端绘制以后,"画布"上才渲染出实际的业务页面,这个时候白屏现象才会结束。

上述过程中,第二步的"创建 Bridge 实例"和第三步的"创建 React.Component 直至第一次渲染完毕",是消耗时间较长的两个关键阶段。我们用一个较为复杂的 React Native 页面为例,经过测算,在 Android release 包下得出了一个具体时间统计结果:

我们从这个数据可以看出,对于一个较为复杂的 React Native 页面,第一次加载的白屏时间大约在 422 毫秒左右,这显然是非常影响用户体验的。但是经过分析我们发现,其中时间占比最大的是"创建 Bridge 实例"这一步,这个步骤由于在执行注册原生模块、加载 JSBundle 等一系列初始化任务,所以非常的耗费时间,我们简称这个阶段为"初始化阶段";而"触发 JS 端渲染直到第一次渲染完毕"这个阶段耗费的时间其实是与页面的复杂程度相关的,我们简称这个阶段为"JS 端渲染阶段"。
React Native"初始化阶段"时间过长只会在第一次加载 RN 页面时造成影响;而如果要加载的 RN 页面非常复杂,使得"JS 端渲染阶段"时长较长,那么不论什么时候跳转到这个页面都有可能会出现白屏现象。
如何采用预加载方式解决"白屏"问题?
那么该怎么解决白屏问题呢?可能你已经想到了预加载方案,其实,还有另外一个解决方案,就是拆包。我们知道预加载方案是通过提前加载的方式让用户感知到的白屏时间缩短,而拆包方案可以减小包体积,直接减少加载时间。当然,拆包方案还有更多的用途,下期视频会为你详细介绍。本期内容我们就聚焦预加载方案,解决白屏问题。
具体怎么做呢?我们可以在用户还没有通过交互触发跳转到 React Native 页面的时候,就将"初始化阶段"和"JS 端渲染阶段"执行完毕;在用户交互触发跳转的时候,就把已经渲染好的 React Native 视图直接展示在屏幕上,达到快速展示视图的效果。
我们把整个过程分为"预加载阶段"和"使用预渲染视图阶段"。

你可以看到,在预加载阶段,当用户浏览原生页面的时候,我们就提前创建好 React Native 页面的根视图。然后触发 Bridge 实例的初始化流程、触发 JS 端渲染流程,再将根视图存放在缓存池中。
而在使用预渲染视图阶段,当用户交互触发页面跳转的时候,到缓存池中取出预加载的根视图,然后更新根视图的 props,触发 JS 端更新渲染流程,最后设置根视图为内容视图,展示给用户。

从这张图你就可以看到,第一个"预加载阶段"就包含了 React Native 的初始化阶段和 JS 端渲染阶段,这时用户还在浏览原生页面,所以对于这部分工作是没有感知的。当第二阶段"使用预渲染视图阶段"时,用户交互发生页面跳转后,我们需要将已经加载好的视图直接设置为内容视图,展示出来。由于耗时较长的初始化阶段和 JS 端渲染阶段已经在第一阶段完成了,所以这一阶段的耗时非常短。下面我们就分阶段讲述一下实现的核心细节以及一些需要注意的关键点。
预加载阶段
先来看预加载阶段的实现细节,React Native 在两个原生端初始化 Bridge 实例的方法略有不同:
在 iOS 端,需要开发者创建一个代理实例设置好 JSBundle 的资源路径,然后调用-initWithDelegate: launchOptions:方法将代理类和启动配置传入,就可以构造出一个 RCTBridge 实例,触发初始化流程。这个实例需要开发者自己用变量存下来,以便后续创建根视图时使用;
在 Android 端,React Native 使用了单例模式和建造者模式进行了懒加载,所以一般情况下虽然我们在 Application 对象一创建就构造了 ReactNativeHost 实例,但直到第一次调用 ReactNativeHost 的 getReactInstanceManager() 方法时才开始触发初始化的流程。
所以我们需要在还没有跳转到 React Native 页面的时候,就调用 initWithDelegate() 方法构造一个 RCTBridge 实例或调用一下 ReactNativeHost 实例的 getReactInstanceManager() 方法,就能让初始化的工作提前。


接下来我们需要定义一个预渲染视图的管理类,这个类提供一个预加载的 API cache() 方法,接收要预渲染视图的 componentName 以及页面初始化时的 props。在获取到这些信息之后我们就可以创建根视图,并将已经初始化的 Bridge 实例、componentName 以及其他的页面信息作为参数,传入根视图调用 runApplication() 方法,开始触发 JavaScript 端的渲染流程。
与此同时呢,每一个预加载出来的根视图,都需要用与 componentName 和初始化 props 相关的唯一的字符串作为 Key 值来保存起来,方便用户交互跳转到 React Native 页面的时候根据 componentName 和初始化 props 进行取用。
使用预渲染的视图阶段
在来看使用预渲染的试图阶段有哪些实现细节。加载预渲染的视图需要我们对页面跳转的流程进行拦截。当检测到要跳转的 React Native 页面对应的 componentName、初始化 props 与缓存池中的预渲染视图匹配时,要优先取用预渲染视图作为新页面的内容视图,这样就能快速地展示出我们预渲染好的视图。如果没找到匹配的预渲染视图,那我们需要按照原有流程重新建立一个新的根视图返回,作为新页面的内容视图。


但是在 iOS 端,如果视图只是被实例化出来但没加载到屏幕上时,是无法触发视图的渲染的,就达不到"预渲染"的目的了。所以这个时候需要把预渲染的视图先放置在当前页面的下面进行渲染,利用当前页面视图的遮挡,让用户察觉不到异常。等用户交互跳转到 React Native 页面时,再把底层渲染好的视图取出来放置在上面,展示给用户。

然而当跳转到 React Native 页面时,可能会从当前页面携带一些数据作为新页面的 props 用于渲染,但我们的预渲染视图是在用户浏览当前页面的时候,就已经悄悄创建好了的,创建预渲染视图时这些数据可能还没有根据用户的交互准备好。所以我们需要在跳转到 React Native 页面时,拦截跳转的流程,如果匹配到了预渲染视图,需要对预渲染视图的 props 进行更新。
在 Android 端,我们可以调用 ReactRootView 的 setAppProperties() 方法,把更新后的 props 对象传递给 JavaScript 端;在 iOS 端,我们可以通过原生模块发送事件把 props 传递给 JavaScript 端,再把 props 分发到对应的 Component 实例的 componentWillReceiveProps 方法中。所以对于预渲染的页面,我们应该注意在 componentWillReceiveProps 生命周期接收页面发生实际跳转时传递新的 props 来更新视图。
需要注意的是,当预渲染的视图被加载到屏幕上以后,预渲染视图管理类就应该把这个视图从缓存池中移除,防止这个视图被多次匹配命中。并且在 Android 端,因为我们创建 ReactRootView 实例时使用的是当前页面的上下文对象,所以如果我们在当前页面预加载了下一个可能跳转的 React Native 页面视图以后,即使最后用户直接退出了当前页面,没有按照期望跳转至预加载好的 React Native 页面,我们也应该在当前页面的 Activity 销毁时,把使用了当前页面 Activity 上下文对象创建的预渲染视图全部销毁,否则会带来内存泄漏的问题。
总的来说,对于这两个阶段的核心实现,我们要记住,我们在跳转到 React Native 页面前进行了页面的"预加载",那么不论最后用户有没有跳转到对应的 React Native 页面,我们都应该把预渲染视图从缓存池中移除。所以我们的预渲染视图管理类应该提供对预渲染视图缓存池的添加、取用和移除方法。
总结及延伸点
React Native 预加载方案通过在前置页面触发 React Native 的初始化并预渲染视图,达到了快速展示复杂 React Native 页面的目的。这个方案虽然可以带来和原生页面跳转一样的体验,但是不适用于没有前置页面的纯 React Native 应用,并且对开发方式也会带来一定的改变。
事实上,对于没有前置页面的纯 React Native 应用,我们可以在 Android 端启用 Hermes 引擎加载二进制的 JavaScript 代码打包产物,或者通过拆包的方式来减少 React Native 初始化的时间,尽可能地减少用户等待的时间,带来更好的用户体验。