Android多个React-Native模块的实现及源码解读

这里我们废话不多说,只围绕主题讲一些技术方面的干货.(本文基于React-Native0.36.0版本)

我们之所以在native app中引入react-native(以下简称RN)框架,是为了将native app中的一些不确定的UI布局,逻辑,业务,流程等等因素,交由远端来控制.也就是说,RN的bundle文件都是由远端下发,然而我们为了最优化展现RN页面,往往都会提前下载好所需要的bundle文件以节省网络交互时间.所以这篇博客我们是基于RN各模块(ComponentName)所对应的JS bundle文件已经下载到本地目标文件夹的前提下来写的.关于bundle文件的版本管理等我们在文末会详细介绍.

ReactNativeHost

我们将RN库引入工程之后,第一件事情就是改造Application类.我们需要在自己的Application中实现一个接口—-ReactApplication

1
2
3
public interface ReactApplication {
ReactNativeHost getReactNativeHost();
}

这个接口中只有一个方法,而ReactNativeHost是一个抽象类,其中有两个抽象方法需要实现(一会将提到).这个方法返回ReactNativeHost对象,这个对象里面可以指定RN的调试模式,以及native给JS暴露的一些通信模块,同时还可以指定当前上下文加载的bundle文件路径.为了达到多个RN模块的切换,我们在Application中维护了一个的map(为什么这么做?紧接着会介绍):

private HashMap<String, ReactNativeHost> mReactHostMap = MapBuilder.newHashMap();

来看看我们是怎么实现上面的接口以及如何维护这个map的:

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
public String gReactNativeBundlePath = "myBundlePath...";
@Override
public ReactNativeHost getReactNativeHost() {
synchronized (gReactNativeBundlePath) {
if (!mReactHostMap.containsKey(gReactNativeBundlePath)) {
ReactNativeHost host = new ReactNativeHost(this) {
@Override
protected boolean getUseDeveloperSupport() {
return BuildConfig.REACT_DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
return Arrays.asList(new MainReactPackage(), new PatientReactPackage());
}
@Override
protected String getJSBundleFile() {
return gReactNativeBundlePath;
}
};
mReactHostMap.put(gReactNativeBundlePath, host);
}
return mReactHostMap.get(gReactNativeBundlePath);
}
}

先来看看我们创建的ReactNativeHost的实现:

  • getUseDeveloperSupport

    抽象方法,用来控制RN调试开关的,一般直接复用BuildConfig.DEBUG开关就行,如果有冲突就自行新建一个buildConfigField(如这里的BuildConfig.REACT_DEBUG).

  • getPackages

    用于指定JS和native通信的ReactPackage,在ReactPackage中可以指定native和JS通信的一些module.其中MainReactPackage是RN已经封装好一些native module和view manager等.

  • getJSBundleFile

    用于ReactNativeHost创建ReactInstanceManager时指定对应的本地JS bundle文件路径.如果返回null,则从getBundleAssetName接口取assets中的对应文件(一般仅用于调试).

接下来我们看看为什么要用维护映射map的方式来实现多个RN模块的切换.

ReactActivity

当RN页面构建的时候,RN提供了ReactActivity组件来展示页面.值得一提的是,ReactActivity是一个抽象类,但是此类中没有抽象方法,像getMainComponentName这样需要子类中实现的方法却没有加抽象标识,这应该是facebook的RN团队疏忽了.在ReactActivity类中,可以看到以下代码:

1
2
3
4
5
6
private final ReactActivityDelegate mDelegate = this.createReactActivityDelegate();
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.mDelegate.onCreate(savedInstanceState);
}

这里createReactActivityDelegate时,会将ReactActivity中指定的RN模块名(即getMainComponentName)传入ReactActivityDelegate,紧接着是调用ReactActivityDelegate对应的生命周期onCreate,来看看里面都做了些什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected void onCreate(Bundle savedInstanceState) {
if(this.getReactNativeHost().getUseDeveloperSupport() && VERSION.SDK_INT >= 23 && !Settings.canDrawOverlays(this.getContext())) {
Intent serviceIntent = new Intent("android.settings.action.MANAGE_OVERLAY_PERMISSION");
this.getContext().startActivity(serviceIntent);
FLog.w("React", "Overlay permissions needs to be granted in order for react native apps to run in dev mode");
Toast.makeText(this.getContext(), "Overlay permissions needs to be granted in order for react native apps to run in dev mode", 1).show();
}
if(this.mMainComponentName != null) {
this.loadApp(this.mMainComponentName);
}
this.mDoubleTapReloadRecognizer = new DoubleTapReloadRecognizer();
}

第一个if块中的代码很简单,就是当RN在调试模式下,针对系统在SDK23以上创建RN调试悬浮窗的权限判断,没有权限则请求用户授权.Android官方文档:

1
2
3
4
5
Note: If the app targets API level 23 or higher, the app user must explicitly grant this permission to the app through a permission management screen. The app requests
the user's approval by sending an intent with action
ACTION_MANAGE_OVERLAY_PERMISSION. The app can check whether it has this authorization by calling
Settings.canDrawOverlays().

第二个if块是最关键的.通过ReactActivityDelegate来loadApp,这也是最耗时的操作,展现RN页面慢/白屏的根源.这里主要是创建ReactRootView以及初始化React上下文环境.

1
2
3
4
5
6
7
8
9
protected void loadApp(String appKey) {
if(this.mReactRootView != null) {
throw new IllegalStateException("Cannot loadApp while app is already running.");
} else {
this.mReactRootView = this.createRootView();
this.mReactRootView.startReactApplication(this.getReactNativeHost().getReactInstanceManager(), appKey, this.getLaunchOptions());
this.getPlainActivity().setContentView(this.mReactRootView);
}
}

如何优化RN的性能和展现效率,主要就是针对这一个耗时方法进行优化即可.可以对ReactRootView进行缓存管理以及将创建React上下文环境提前预处理.
我们来看看上面的遗留问题—-为什么要用维护映射map的方式来实现多个RN模块的切换.在启动RN应用时startReactApplication需要传入ReactNativeHost中的ReactInstanceManager对象,我们来看看源码(ReactNativeHost.java):

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
public ReactInstanceManager getReactInstanceManager() {
if(this.mReactInstanceManager == null) {
this.mReactInstanceManager = this.createReactInstanceManager();
}
return this.mReactInstanceManager;
}
protected ReactInstanceManager createReactInstanceManager() {
Builder builder = ReactInstanceManager.builder().setApplication(this.mApplication).setJSMainModuleName(this.getJSMainModuleName()).setUseDeveloperSupport(this.getUseDeveloperSupport()).setRedBoxHandler(this.getRedBoxHandler()).setUIImplementationProvider(this.getUIImplementationProvider()).setInitialLifecycleState(LifecycleState.BEFORE_CREATE);
Iterator jsBundleFile = this.getPackages().iterator();
while(jsBundleFile.hasNext()) {
ReactPackage reactPackage = (ReactPackage)jsBundleFile.next();
builder.addPackage(reactPackage);
}
String jsBundleFile1 = this.getJSBundleFile();
if(jsBundleFile1 != null) {
builder.setJSBundleFile(jsBundleFile1);
} else {
builder.setBundleAssetName((String)Assertions.assertNotNull(this.getBundleAssetName()));
}
return builder.build();
}

可以发现,ReactNativeHost中的ReactInstanceManager只在创建时读取bundle路径等信息.也就约等于一个ReactNativeHost对应一个bundle入口文件.这就是为什么我们以维护一个映射map的方式来实现native app中多个RN模块的切换.

继续来看看创建React上下文环境的实现逻辑(XReactInstanceManagerImpl.java):

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
public void createReactContextInBackground() {
Assertions.assertCondition(!this.mHasStartedCreatingInitialContext, "createReactContextInBackground should only be called when creating the react application for the first time. When reloading JS, e.g. from a new file, explicitlyuse recreateReactContextInBackground");
this.mHasStartedCreatingInitialContext = true;
this.recreateReactContextInBackgroundInner();
}
private void recreateReactContextInBackgroundInner() {
UiThreadUtil.assertOnUiThread();
if(this.mUseDeveloperSupport && this.mJSMainModuleName != null) {
final DeveloperSettings devSettings = this.mDevSupportManager.getDevSettings();
if(this.mDevSupportManager.hasUpToDateJSBundleInCache() && !devSettings.isRemoteJSDebugEnabled()) {
this.onJSBundleLoadedFromServer();
} else if(this.mBundleLoader == null) {
this.mDevSupportManager.handleReloadJS();
} else {
this.mDevSupportManager.isPackagerRunning(new PackagerStatusCallback() {
public void onPackagerStatusFetched(final boolean packagerIsRunning) {
UiThreadUtil.runOnUiThread(new Runnable() {
public void run() {
if(packagerIsRunning) {
XReactInstanceManagerImpl.this.mDevSupportManager.handleReloadJS();
} else {
devSettings.setRemoteJSDebugEnabled(false);
XReactInstanceManagerImpl.this.recreateReactContextInBackgroundFromBundleLoader();
}
}
});
}
});
}
} else {
this.recreateReactContextInBackgroundFromBundleLoader();
}
}

第二个if块的关键代码就分析到这.


最后一句是调试模式下,注册一个DoubleTapReloadRecognizer,按两下R键重新加载bundle.处理逻辑是在DevSupportManager(通过DevSupportManagerFactory.create创建)的handleReloadJS方法中处理的.最终实现逻辑(XReactInstanceManagerImpl.java):

1
2
3
4
5
6
7
8
9
10
11
private void recreateReactContextInBackground(com.facebook.react.cxxbridge.JavaScriptExecutor.Factory jsExecutorFactory, JSBundleLoader jsBundleLoader) {
UiThreadUtil.assertOnUiThread();
XReactInstanceManagerImpl.ReactContextInitParams initParams = new XReactInstanceManagerImpl.ReactContextInitParams(jsExecutorFactory, jsBundleLoader);
if(this.mReactContextInitAsyncTask == null) {
this.mReactContextInitAsyncTask = new XReactInstanceManagerImpl.ReactContextInitAsyncTask(null);
this.mReactContextInitAsyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new XReactInstanceManagerImpl.ReactContextInitParams[]{initParams});
} else {
this.mPendingReactContextInitParams = initParams;
}
}

bundle管理

主要根据以上流程实现即可,同时要兼具安全性考量.验证文件安全性.

调试Android UI性能

我们尽最大的努力来争取使UI组件的性能如丝般顺滑,但有的时候这根本不可能做到。要知道,Android有超过一万种不同型号的手机,而在框架底层进行软件渲染的时候是统一处理的,这意味着你没办法像iOS那样自由。不过有些时候,你还是可以想办法提升应用的性能(有的时候问题根本不是出在原生代码上!)

要想解决应用的性能问题,第一步就是搞明白在每个16毫秒的帧中,时间都去哪儿了。为此,我们会使用一个标准的Android性能分析工具systrace,不过在此之前……

请先确定JS的开发者模式已经关闭!

你应该在应用的日志里看到__DEV__ === false, development-level warning are OFF, performance optimizations are ON等字样(你可以通过adb logcat来查看应用日志)

使用Systrace进行性能分析

Systrace是一个标准的基于标记的Android性能分析工具(如果你安装了Android platform-tool包,它也会一同安装)。被调试的代码段在开始和结束处加上标记,在执行的过程中标记会被记录,最后会以图表形式展现统计结果。包括Android SDK自己和React Native框架都已经提供了标准的标记供你查看。

收集一次数据

注意:

Systrace从React Native v0.15版本开始支持。你需要在此版本下构建项目才能收集相应的性能数据。

首先,把你想分析的、运行不流畅的设备使用USB线链接到电脑上,然后操作应用来到你想分析的导航/动画之前,接着这样运行systrace:

1
$ <AndroidSDK所在目录>/platform-tools/systrace/systrace.py --time=10 -o trace.html sched gfx view -a <你的应用包名>

对于此命令做一个简单的说明:

  • time参数控制本次数据收集的持续时间,单位是秒。
  • schd, gfx, 和view是我们所关心的Android SDK内置的tag(标记的集合):schd提供了你的设备的每个CPU核心正在做什么的信息,gfx提供了你的图形相关信息,譬如每帧的时间范围,而view提供了一些关于视图布局和渲染相关性能的信息。
  • -a <你的应用包名>启用了针对应用的过滤。在这里填写你用React Native创建的应用包名。你的应用包名可以在你应用中的AndroidManifest.xml里找到,形如com.example.app

译注:实际上,AndroidManifest.xml里的应用包名会被app/build.gradle里的applicationId取代。如果二者不一致,应当以app/build.gradle里的为准。

一旦systrace开始收集数据,你可以操作应用执行你所关心的动画和操作。在收集结束后,systrace会给你提供一个链接,你可以在浏览器中打开这个链接来查看数据收集的结果。

查看性能数据

在浏览器中打开数据页面(建议使用Chrome),你应该能看到类似这样的结果:

Example

提示: 你可以使用WSAD键来滚动和缩放性能数据图表。

启用垂直同步高亮

接下来你首先应该启用16毫秒帧区间的高亮。在屏幕顶端点击对应的复选框:

Enable VSync Highlighting

然后你应该能在屏幕上看到类似上图的斑马状条纹。如果你无法看到这样的条纹,可以尝试换一台设备来进行分析:部分三星手机显示垂直同步高亮存在已知问题,而Nexus系列大部分情况都相当可靠。

找到你的进程

滚动图表直到你找到你的应用包名。在上面的例子里,我正在分析com.facebook.adsmanager,由于内核的线程名字长度限制,它会显示成book.adsmanager

在左侧,你应该能看到一系列线程对应着右边的时间轴。有3到4个线程是我们必须关注的:UI线程(名字可能是UI Thread或者是你的包名), mqt_jsmqt_native_modules。如果你在Android 5.0以上版本运行,我们还需要关注Render(渲染)线程。

UI 线程

标准的Android布局和绘制都在UI线程里发生。右侧显示的线程名字会是你的包名(在我的例子里是book.adsmanager)或者UI Thread.你在这个线程里看到的事件可能会是一些Choreographer, traversals或者DispatchUI

UI Thread Example

JS线程

这是用于执行JavaScript代码的线程。根据Android系统版本或者设备的不同,线程名可能是mqt_js或者<...>。如果看不到对应的名字的话,寻找类似JSCallBridge.executeJSCall这样的事件。

JS Thread Example

原生模块线程

这里是用于原生模块执行代码(譬如UIManager)的线程,线程名可能是mqt_native_modules<...>。在后一种情况下,寻找类似NativeCall, CallJavaModuleMethod, 还有onBatchComplete这样的事件名:

Native Modules Thread Example

额外的:渲染线程

如果你在使用Android L(5.0)或者更高版本,你应该还会在你的应用里看到一个渲染线程。这个线程真正生成OpenGL渲染序列来渲染你的UI。这个线程的名字可能为RenderThread或者<...>,在后一种情况下,寻找类似DrawFramequeueBuffer这样的事件:

Render Thread Example

寻找导致卡顿的罪魁祸首

一个流畅的动画应该看起来像这样:

Smooth Animation

每个背景颜色不同的部分我们称作“一帧”——记住要渲染一个流畅的帧,我们所有的界面工作都需要在16毫秒内完成。注意没有任何一个线程在靠近帧的边界处工作。类似这样的一个应用程序就正在60FPS(帧每秒)的情况下流畅表现。

如果你发现一些起伏的地方,譬如这样:

Choppy Animation from JS

注意在上图中JS线程基本上一直在执行,并且超越了帧的边界。这个应用就没法以60FPS渲染了。在这种情况下,问题出在JS中

你还有可能会看到一些类似这样的东西:

Choppy Animation from UI

在这种情况下,UI和渲染线程有一些重负荷的工作,以至于超越了帧的边界。这可能是由于我们每帧试图渲染的UI太多了导致的。在这种情况下,问题出在需要渲染的原生视图上

并且,你还应该能看到一些可以指导接下来优化工作的有用的信息。

JS的问题

如果你发现问题出在JS上,在你正在执行的JS代码中寻找线索。在上面的图中,我们会发现RCTEventEmitter每帧被执行了很多次。这是上面的数据统计放大后的内容:

Too much JS

这看起来不是很正常,为什么事件被调用的如此频繁?它们是不同的事件吗?具体的答案取决于你的产品的代码。在许多情况下,你可能需要看看shouldComponentUpdate的介绍。

TODO: 我们还在准备更多的JS性能分析的工具,会在将来的版本中加入。

原生UI问题

如果你发现问题出在原生UI上,有两种常见的情况:

  1. 你每帧在渲染的UI给GPU带来了太重的负载,或者:
  2. 你在动画、交互的过程中不断创建新的UI对象(譬如在scroll的过程中加载新的内容)

GPU负担过重

在第一种情况下,你应该能看到UI线程的图表类似这样:

Overloaded GPU

注意DrawFrame花费了很多时间,超越了帧的边界。这些时间用来等待GPU获取它的操作缓存。

要缓解这个问题,你应该:

  • 检查renderToHardwareTextureAndroid的使用,有这个属性的View的子节点正在进行动画或变形会导致性能大幅下降(譬如Navigator提供的滑动、淡入淡出动画)。
  • 确保你没有使用needsOffscreenAlphaCompositing,这个默认是关闭的,因为它在大部分情况下都会带来GPU消耗的大幅提升。

如果这还不能帮你解决问题,你可能需要更深入的探索GPU到底在做什么。参见Tracer for OpenGL ES

在UI线程创建大量视图

如果是第二种情况,你可能会看到类似这样的结果:

Creating Views

注意一开始JS线程工作了很久,然后你看到原生模块线程干了些事情,最后带来了UI线程的巨大开销。

这个问题并没有什么简单直接的优化办法,除非你能把创建UI的步骤推迟到交互结束以后去进行,或者你能直接简化你所要创建的UI。React Native小组正在架构层设法提供一个方案,使得新的UI视图可以在主线程之外去创建和配置,这样就可以使得交互变得更加流畅。

还是没搞定?

如果你还是很迷惑或者不知如何进展,你可以在Stack Overflow的react-native标签下提交一个问题。如果你在这里得不到响应,或者找到了一个核心组件的问题,你可以提交一个Github issue

react-native性能

使用React Native替代基于WebView的框架来开发App的一个强有力的理由,就是为了使App可以达到每秒60帧(足够流畅),并且能有类似原生App的外观和手感。因此我们也尽可能地优化React Native去实现这一目标,使开发者能集中精力处理App的业务逻辑,而不用费心考虑性能。但是,总还是有一些地方有所欠缺,以及在某些场合React Native还不能够替你决定如何进行优化(用原生代码写也无法避免),因此人工的干预依然是必要的。
本文的目的是教给你一些基本的知识,来帮你排查性能方面的问题,以及探讨这些问题产生的原因和推荐的解决方法。

关于“帧”你所需要知道的

老一辈人常常把电影称为“移动的画”,是因为视频中逼真的动态效果其实是一种幻觉,这种幻觉是由一组静态的图片以一个稳定的速度快速变化所产生的。我们把这组图片中的每一张图片叫做一帧,而每秒钟显示的帧数直接的影响了视频(或者说用户界面)的流畅度和真实感。iOS设备提供了每秒60的帧率,这就留给了开发者和UI系统大约16.67ms来完成生成一张静态图片(帧)所需要的所有工作。如果在这分派的16.67ms之内没有能够完成这些工作,就会引发‘丢帧’的后果,使界面表现的不够流畅。

下面要讲的事情可能更为复杂:请先调出你应用的开发菜单,打开Show FPS Monitor. 你会注意到有两个不同的帧率.

JavaScript 帧率

对大多数React Native应用来说,业务逻辑是运行在JavaScript线程上的。这是React应用所在的线程,也是发生API调用,以及处理触摸事件等操作的线程。更新数据到原生支持的视图是批量进行的,并且在事件循环每进行一次的时候被发送到原生端,这一步通常会在一帧时间结束之前处理完(如果一切顺利的话)。如果JavaScript线程有一帧没有及时响应,就被认为发生了一次丢帧。 例如,你在一个复杂应用的根组件上调用了this.setState,从而导致一次开销很大的子组件树的重绘,可想而知,这可能会花费200ms也就是整整12帧的丢失。此时,任何由JavaScript控制的动画都会卡住。只要卡顿超过100ms,用户就会明显的感觉到。

这种情况经常发生在Navigator的切换过程中:当你push一个新的路由时,JavaScript需要绘制新场景所需的所有组件,以发送正确的命令给原生端去创建视图。由于切换是由JavaScript线程所控制,因此经常会占用若干帧的时间,引起一些卡顿。有的时候,组件会在componentDidMount函数中做一些额外的事情,这甚至可能会导致页面切换过程中多达一秒的卡顿。

另一个例子是触摸事件的响应:如果你正在JavaScript线程处理一个跨越多个帧的工作,你可能会注意到TouchableOpacity的响应被延迟了。这是因为JavaScript线程太忙了,不能够处理主线程发送过来的原始触摸事件。结果TouchableOpacity就不能及时响应这些事件并命令主线程的页面去调整透明度了。

主线程 (也即UI线程) 帧率

很多人会注意到,NavigatorIOS的性能要比Navigator好的多。原因就是它的切换动画是完全在主线程上执行的,因此不会被JavaScript线程上的掉帧所影响。(阅读关于为何你仍然需要使用Navigator

同样,当JavaScript线程卡住的时候,你仍然可以欢快的上下滚动ScrollView,因为ScrollView运行在主线程之上(尽管滚动事件会被分发到JS线程,但是接收这些事件对于滚动这个动作来说并不必要)。

性能问题的常见原因

console.log语句

在运行打好了离线包的应用时,控制台打印语句可能会极大地拖累JavaScript线程。注意有些第三方调试库也可能包含控制台打印语句,比如redux-logger,所以在发布应用前请务必仔细检查,确保全部移除。

有个babel插件可以帮你移除所有的console.*调用。首先需要使用npm install babel-plugin-transform-remove-console --save来安装,然后在项目根目录下编辑(或者是新建)一个名为·.babelrc`的文件,在其中加入:

1
2
3
4
5
6
7
{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}

这样在打包发布时,所有的控制台语句就会被自动移除,而在调试时它们仍然会被正常调用。

开发模式 (dev=true)

JavaScript线程的性能在开发模式下是很糟糕的。这是不可避免的,因为有许多工作需要在运行的时候去做,譬如使你获得良好的警告和错误信息,又比如验证属性类型(propTypes)以及产生各种其他的警告。

缓慢的导航器(Navigator)切换

如之前说,Navigator的动画是由JavaScript线程所控制的。想象一下“从右边推入”这个场景的切换:每一帧中,新的场景从右向左移动,从屏幕右边缘开始(不妨认为是320单位宽的的x轴偏移),最终移动到x轴偏移为0的屏幕位置。切换过程中的每一帧,JavaScript线程都需要发送一个新的x轴偏移量给主线程。如果JavaScript线程卡住了,它就无法处理这项事情,因而这一帧就无法更新,动画就被卡住了。

长远的解决方法,其中一部分是要允许基于JavaScript的动画从主线程分离。同样是上面的例子,我们可以在切换动画开始的时候计算出一个列表,其中包含所有的新的场景需要的x轴偏移量,然后一次发送到主线程以某种优化的方式执行。由于JavaScript线程已经从更新x轴偏移量给主线程这个职责中解脱了出来,因此JavaScript线程中的掉帧就不是什么大问题了 —— 用户将基本上不会意识到这个问题,因为用户的注意力会被流畅的切换动作所吸引。

不幸的是,这个方案还没有被实现。所以当前的解决方案是,在动画的进行过程中,利用InteractionManager来选择性的渲染新场景所需的最小限度的内容。

InteractionManager.runAfterInteractions的参数中包含一个回调,这个回调会在navigator切换动画结束的时候被触发(每个来自于Animated接口的动画都会通知InteractionManager,不过这个就超出了本文的讨论)。

你的场景组件看上去应该是这样的:

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
class ExpensiveScene extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {renderPlaceholderOnly: true};
}
componentDidMount() {
InteractionManager.runAfterInteractions(() => {
this.setState({renderPlaceholderOnly: false});
});
}
render() {
if (this.state.renderPlaceholderOnly) {
return this._renderPlaceholderView();
}
return (
<View>
<Text>Your full view goes here</Text>
</View>
);
}
_renderPlaceholderView() {
return (
<View>
<Text>Loading...</Text>
</View>
);
}
};

你不必被限制在仅仅是做一些loading指示的渲染,你也可以绘制部分的页面内容 —— 例如,当你加载Facebook应用的时候,你会看见一个灰色方形的消息流的占位符,是将来用来显示文字的地方。如果你正在场景中绘制地图,那么最好在场景切换完成之前,显示一个灰色的占位页面或者是一个转动的动画,因为切换过程的确会导致主线程的掉帧。

ListView初始化渲染太慢以及列表过长时滚动性能太差

这是一个频繁出现的问题。因为iOS配备了UITableView,通过重用底层的UIViews实现了非常高性能的体验(相比之下ListView的性能没有那么好)。用React Native实现相同效果的工作仍正在进行中,但是在此之前,我们有一些可用的方法来稍加改进性能以满足我们的需求。

initialListSize

这个属性定义了在首次渲染中绘制的行数。如果我们关注于快速的显示出页面,可以设置initialListSize为1,然后我们会发现其他行在接下来的帧中被快速绘制到屏幕上。而每帧所显示的行数由pageSize所决定。

pageSize

在初始渲染也就是initialListSize被使用之后,ListView将利用pageSize来决定每一帧所渲染的行数。默认值为1 —— 但是如果你的页面很小,而且渲染的开销不大的话,你会希望这个值更大一些。稍加调整,你会发现它所起到的作用。

scrollRenderAheadDistance

“在将要进入屏幕区域之前的某个位置,开始绘制一行,距离按像素计算。”

如果我们有一个2000个元素的列表,并且立刻全部渲染出来的话,无论是内存还是计算资源都会显得很匮乏。还很可能导致非常可怕的阻塞。因此scrollRenderAheadDistance允许我们来指定一个超过视野范围之外所需要渲染的行数。

removeClippedSubviews

“当这一选项设置为true的时候,超出屏幕的子视图(同时overflow值为hidden)会从它们原生的父视图中移除。这个属性可以在列表很长的时候提高滚动的性能。默认为false。(0.14版本后默认为true)”

这是一个应用在长列表上极其重要的优化。Android上,overflow值总是hidden的,所以你不必担心没有设置它。而在iOS上,你需要确保在行容器上设置了overflow: hidden

我的组件渲染太慢,我不需要立即显示全部

这在初次浏览ListView时很常见,适当的使用它是获得稳定性能的关键。就像之前所提到的,它可以提供一些手段在不同帧中来分开渲染页面,稍加改进就可以满足你的需求。此外要记住的是,ListView也可以横向滚动。

在重绘一个几乎没有什么变化的页面时,JS帧率严重降低

如果你正在使用一个ListView,你必须提供一个rowHasChanged函数,它通过快速的算出某一行是否需要重绘,来减少很多不必要的工作。如果你使用了不可变的数据结构,这项工作就只需检查其引用是否相等。

同样的,你可以实现shouldComponentUpdate函数来指明在什么样的确切条件下,你希望这个组件得到重绘。如果你编写的是纯粹的组件(返回值完全由props和state所决定),你可以利用PureRenderMixin来为你做这个工作。再强调一次,不可变的数据结构在提速方面非常有用 —— 当你不得不对一个长列表对象做一个深度的比较,它会使重绘你的整个组件更加快速,而且代码量更少。

由于在JavaScript线程中同时做很多事情,导致JS线程掉帧

“导航切换极慢”是该问题的常见表现。在其他情形下,这种问题也可能会出现。使用InteractionManager是一个好的方法,但是如果在动画中,为了用户体验的开销而延迟其他工作并不太能接受,那么你可以考虑一下使用LayoutAnimation

Animated的接口一般会在JavaScript线程中计算出所需要的每一个关键帧,而LayoutAnimation则利用了Core Animation,使动画不会被JS线程和主线程的掉帧所影响。

举一个需要使用这项功能的例子:比如需要给一个模态框做动画(从下往上划动,并在半透明遮罩中淡入),而这个模态框正在初始化,并且可能响应着几个网络请求,渲染着页面的内容,并且还在更新着打开这个模态框的父页面。了解更多有关如何使用LayoutAnimation的信息,请查看动画指南

注意:

  • LayoutAnimation只工作在“一次性”的动画上(”静态”动画) – 如果动画可能会被中途取消,你还是需要使用Animated

在屏幕上移动视图(滚动,切换,旋转)时,UI线程掉帧

当具有透明背景的文本位于一张图片上时,或者在每帧重绘视图时需要用到透明合成的任何其他情况下,这种现象尤为明显。设置shouldRasterizeIOS或者renderToHardwareTextureAndroid属性可以显著改善这一现象。
注意不要过度使用该特性,否则你的内存使用量将会飞涨。在使用时,要评估你的性能和内存使用情况。如果你没有需要移动这个视图的需求,请关闭这一属性。

使用动画改变图片的尺寸时,UI线程掉帧

在iOS上,每次调整Image组件的宽度或者高度,都需要重新裁剪和缩放原始图片。这个操作开销会非常大,尤其是大的图片。比起直接修改尺寸,更好的方案是使用transform: [{scale}]的样式属性来改变尺寸。比如当你点击一个图片,要将它放大到全屏的时候,就可以使用这个属性。

Touchable系列组件不能很好的响应

有些时候,如果我们有一项操作与点击事件所带来的透明度改变或者高亮效果发生在同一帧中,那么有可能在onPress函数结束之前我们都看不到这些效果。比如在onPress执行了一个setState的操作,这个操作需要大量计算工作并且导致了掉帧。对此的一个解决方案是将onPress处理函数中的操作封装到requestAnimationFrame中:

1
2
3
4
5
6
7
handleOnPress() {
// 谨记在使用requestAnimationFrame、setTimeout以及setInterval时
// 要使用TimerMixin(其作用是在组件unmount时,清除所有定时器)
this.requestAnimationFrame(() => {
this.doExpensiveAction();
});
}

分析

你可以利用内置的分析器来同时获取JavaScript线程和主线程中代码执行情况的详细信息。

对于iOS来说,Instruments是一个宝贵的工具库,Android的话,你可以使用systrace,参见调试Android UI性能

Dive into React Native performance

Dive into React Native performance

React Native allows you to build iOS and Android apps in JavaScript using React and Relay‘s declarative programming model. This leads to more concise, easier-to-understand code; fast iteration without a compile cycle; and easy sharing of code across multiple platforms. You can ship faster and focus on details that really matter, making your app look and feel fantastic. Optimizing performance is a big part of this. Here is the story of how we made React Native app startup twice as fast.

Why the hurry?

With an app that runs faster, content loads quickly, which means people get more time to interact with it, and smooth animations make the app enjoyable to use. In emerging markets, where 2011 class phones on 2G networks are the majority, a focus on performance can make the difference between an app that is usable and one that isn’t.

Since releasing React Native on iOS and on Android, we have been improving list view scrolling performance, memory efficiency, UI responsiveness, and app startup time. Startup sets the first impression of an app and stresses all parts of the framework, so it is the most rewarding and challenging problem to tackle.

Always be measuring

We converted the Events Dashboard feature in the Facebook for iOS app to React Native (navigate to the More tab in the app and tap Events to see it). This was the perfect candidate for testing performance because the native product was already highly optimized and provided a typical “interactive list of items” experience.

Fig. 1: The Events Dashboard screen

Next, we set up an automated CT-Scan performance test that helped us navigate to the rightmost tab, which then opens and closes the Events Dashboard 50 times. During each of these iterations, we are able to measure the time it takes from tapping the Events button to events being visible on the screen. We also added more detailed performance markers to give us a good idea of which steps in the startup process were slow and taking up CPU time.

Here is an overview of some of the steps we are measuring:

  • Native Initialization: Initialize the JavaScript virtual machine and all the native modules (disk cache, network, UI manager, etc.).
  • JS Init + Require: Read the minified JavaScript bundle file from disk and load it into the JavaScript virtual machine, which will parse it and generate bytecode as it requires the initial modules (mostly React, Relay, and their dependencies).
  • Before Fetch: Load and execute the Events Dashboard application code, build the Relay query, and kick off reading from the on-disk cache.
  • Fetch: Fetch data from the on-disk cache.
  • JS Render: Instantiate all the React components and send them to the native UI manager module for display.
  • Native Render: Calculate view sizes by computing the FlexBox layout on the shadow thread; create and position the views on the main thread.

    Fig. 2: Events Dashboard startup performance

Our golden rule from then on: Never regress the test. We run it continuously to track performance improvements and regressions, and developers can run it on a specific commit to get a detailed performance analysis before pushing the change. Other tests have been set up to measure scroll performance and memory usage in the same way.

What happens on startup

With automated performance tracking in place, we needed a tool that could give us more details on what exactly needed improvement during startup. We added detailed start/stop performance markers throughout our frameworks, collected the data, and used the catapult viewer to identify hot spots and blocking interactions across threads. You can trigger profiling on your app from the developer menu.

With React Native, your code is executed on the JavaScript thread. Whenever you want to write data to the disk, make a network request, or access any other native resource (like the camera), your code needs to call a native module. When you render your components with React, they will be forwarded to the UI manager native module, which will then perform layout and create the resulting views on the main thread. The bridge will forward your call to the module and call back to your code, if needed. In React Native, all native calls have to be asynchronous to avoid blocking the main thread or the JS thread.

In the below Events Dashboard startup visualization, we can see that the app, which is running on the JS queue, triggers a cache read for the events to be displayed, which is triggered on the async local storage queue. Once it gets the cached data back, the app renders the events cells on the JS queue with React, which then passes it on to the shadow queue for layout and finally to the main queue for view creation. This example shows multiple cache reads (using one common read operation may be faster) and a few React render operations on the JS thread that might be consolidated.

Fig. 3: Events Dashboard startup visualization

Performance improvements

Here are a few of the most significant efficiency and scheduling improvements we have made to reach our results, with links to the relevant commits.

Doing less

  • Cleanup Require/Babel helpers (high impact): Removes helper code executed during require() that was specific to our website and not needed for React Native.

  • Avoid copying and decoding strings when loading the bundle (medium impact): Passing a UTF-8 string to the JavaScriptCore virtual machine will cause it to trigger a slower conversion to UCS-2 format. Encoding it in ASCII format instead will avoid the conversion. Getting rid of the intermediate NSString representation also improves performance by avoiding one more conversion. We discovered these improvements through extensive benchmarking of the bundle loading step.

  • Stripping DEV-only modules (low impact): Unlike compiled code, JavaScript doesn’t have a preprocessor that can strip debugging features in release mode. Using a Babel transform, we were able to remove code living behind DEV statements, effectively reducing bundle size, which improves JavaScript parse time.

  • Generate event descriptions on the server (low impact): Instead of fetching data to generate a sentence describing which friends are coming to an event, generate it on the server, which reduces the data we have to receive and parse, and avoid all the client-side processing to generate the sentence.

Scheduling

  • Lazy requires (low impact): Instead of executing all JavaScript module require calls up front, trigger a require call only the first time we need it. This optimization effectively avoids requiring modules that are never used, and it has also proved to be successful on the web.

  • Relay incremental cache read (high impact): Relay was initially written for the web and had only an in-memory response cache. The first on-disk response cache was reading the entire cache from the disk. By reading only the content required to fulfill a particular query, we significantly reduced the I/O overhead and native-to-JS bridge traffic.

  • De-batching bridge calls, batch Relay calls (high impact): We initially thought that sending JS calls to native in batches would reduce the overhead of calling over the native-to-JS bridge, but performance analysis showed the overhead of JS calls to native was not a bottleneck: In fact, delaying UI or cache read calls to batch them with later calls also delayed work on the native thread, which harmed performance. In other cases, like the Relay cache read fetching data for multiple keys, batching proved to be a significant improvement.

  • Early UI flushing (low impact): We also batched UI updates to enforce consistency, but sending layout commands as soon as they are ready proved to be more efficient because the native UI manager can work in parallel with the JavaScript thread.

  • Lazy native modules loading (low impact): Initialize a native module only the first time we use it, which avoids initializing the modules we do not need.

  • Lazy touch bindings on text components (low impact): Binding touch event callbacks takes a significant amount of time. Instead of doing all that work up front, we are now only binding the touch down event (when you first touch a target) and bind all the other callbacks only when you start touching the element.

  • Defer popular events query (medium impact): The first screen of information is populated by the events query, and we will then show popular events after these. Deferring that query reduces contention when populating the screen with events.

Prepare for light-speed

A few months ago, Events Dashboard startup took two seconds on the iPhone 5. After a lot of work from the React Native Performance, React Native, React, and Relay teams in London, Menlo Park, and New York, Events Dashboard startup is now twice as fast. Most of the improvements we made were done at the framework level, which means your React Native app will automatically benefit when migrating to the latest version of React Native.

These improvements are just the beginning: We continue to work on making every part of the stack faster, from JavaScript parse time to data-fetching performance. And you can contribute, learn how to make your apps faster, and ask any questions you may have in our community!