Compared with native development, the most obvious disadvantage of React Native is the rendering speed of the page, such as slow page loading and low rendering efficiency. For these problems, they are common problems in development, and they are also points that must be optimized when using React Native to develop cross-platform applications. This introduces a question, how should the performance optimization of React Native be done?
I believe that for this question, most people are very confused when they first see it. Because most people know very little about the principles of React Native other than business development. In fact, after our years of experience, an unoptimized React Native application can be roughly divided into three bottlenecks:
Of course, RN's performance optimizations include optimizations on the JavaScript side and native containers. However, today we mainly focus on optimization from a client-side perspective.
1. React Native environment pre-creation
In the latest React Native architecture, the Turbo Module (communication method under the new architecture) is loaded on demand, while the old framework loads the Native Modules all at once during initialization. At the same time, the Hermes engine abandons JIT. There has also been a marked improvement. If you are interested in the new architecture of React Native, you can refer to: New Architecture of React Native.
Aside from the framework optimizations of these two versions, what else can we do in terms of startup speed? First, let's look at React Native environment pre-creation. In a hybrid project, the relationship between the React Native environment and the loaded page is as shown below.
As you can see from the above figure, in a hybrid application, independent React Native carrier pages have their own independent execution environment. Native domain includes React View, Native Modules; JavaScript domain includes JavaScript engine, JS Modules, business code; Bridge/JSI (new version) is used for intermediate communication.
Of course, there are also multiple pages in the industry that reuse the optimization of one engine. However, there are some problems in reusing one engine for multiple pages, such as JavaScript context isolation, multi-page rendering disorder, and irreversible exception of JavaScript engine. Moreover, the performance of multiplexing is unstable. Considering the input-output ratio, maintenance cost, etc., usually in mixed development, one carrier page and one engine are used.
Usually, a React Native page is roughly divided into the following steps from loading and rendering to display: [React Native environment initialization] -> [Download/load bundle] -> [Execute JavaScript code].
The main tasks included in this step of environment initialization include: creating a JavaScript engine, Bridge, and loading Native Modules (old version). According to our tests, the initialization step is particularly time-consuming in the Android environment. Therefore, the first optimization point we thought of was to create the React Native environment in advance. The process is as follows.
The code involved is as follows:
RNFactory.java
public class RNFactory {
// 单例
private static class Holder {
private static RNFactory INSTANCE = new RNFactory();
}
public static RNFactory getInstance() {
return Holder.INSTANCE;
}
private RNFactory() {
}
private RNEnv mRNEnv;
//App启动时调用init方法,提前创建RN所需的环境
public void init(Context context) {
mRNEnv = new RNEnv(context);
}
//获取RN环境对象
public RNEnv getRNEnv(Context context) {
RNEnv rnEnv = mRNEnv;
mRNEnv = createRNEnv(context);
return rnEnv;
}
}
RNEnv.java
public class RNEnv {
private ReactInstanceManager mReactInstanceManager;
private ReactContext mReactContext;
public RNEnv(Context context) {
// 构建 ReactInstanceManager
buildReactInstanceManager(context);
// 其他初始化
...
}
private void buildReactInstanceManager(Context context) {
// ...
mReactInstanceManager = ...
}
public void startLoadBundle(ReactRootView reactRootView, String moduleName, String bundleid) {
// ...
}
}
When doing pre-creation, we need to pay attention to thread synchronization issues. In a hybrid application, React Native is used from the application level to the page level, so there are many problems in terms of thread safety. Multiple React Native environments will be created concurrently during pre-creation, and there is asynchronous processing in the internal construction of the React Native environment. Some global variables, such as ViewManagersPropertyCache.
class ViewManagersPropertyCache {
private static final Map<Class, Map<String, ViewManagersPropertyCache.PropSetter>> CLASS_PROPS_CACHE;
private static final Map<String, ViewManagersPropertyCache.PropSetter> EMPTY_PROPS_MAP;
ViewManagersPropertyCache() {
}
...
}
The internal CLASS_PROPS_CACHE and EMPTY_PROPS_MAP are all non-thread-safe data structures, and there may be a problem of Map expansion and conversion during concurrency. For example, DynmicFromMap also has this problem.
2. Asynchronous update
Originally, after entering the React Native carrier page, we needed to download the latest version of the JavaScript code package. If there is an update, we must download the latest package and load it. In this process, we will go through two network requests, that is, to get whether there is an update, and if there is a bundle package that downloads a hot update. If the user network is poor, the download of the bundle package will be very slow, and the final waiting time will be long.
Therefore, we adopt an asynchronous update strategy for some special pages. The main idea of the asynchronous update strategy is to selectively download the JavaScript code package in advance before entering the page, and then check whether the JavaScript code package is cached after entering the carrier page. The latest version of the JavaScript code package, if available, is downloaded locally and cached, and will take effect the next time you enter the carrier page.
The figure above shows some of the processes we need to go through to open an RN page. As can be seen in the flow chart, we need two network requests from entering the carrier page to rendering the page. No matter the network speed is fast or slow, this process is relatively long, but after the asynchronous update, our process will become as shown below
In the business page, we can download and cache the JavaScript code package in advance. After the user jumps to the React Native page, we can detect whether there is a cached JavaScript code package. If there is, we will render the page directly. In this way, there is no need to wait for the version number to detect the network interface and the network interface for downloading the latest package, and it does not depend on the user's network condition, which reduces the user's waiting time.
While rendering the page, we asynchronously detect the version of the JavaScript code package. If there is a new version, update and cache it, and it will take effect next time. Of course, the business can also choose to prompt the user that there is a new version of the page after updating the latest package, and whether to choose to refresh and load the latest page.
3. Interface pre-cache
After the React Native environment is initialized and the bundle loading process is optimized, our React Native pages can basically reach the level of opening in seconds. However, after the React Native page is loaded and enters the JavaScript business execution area, most businesses will inevitably interact with the network and request server data for rendering. This part actually has a lot of room for optimization.
First, let's take a look at the React Native loading process with hot update capability.
It can be seen that the whole process is from the initialization of the React Native environment to the hot update, to the execution of the JavaScript business code, and finally to the display of the business interface. The chain is long, and each step depends on the result of the previous step. In particular, the hot update process can involve up to two network calls, which are to detect whether to update and download the latest bundle file.
For this scenario, we thought of an optimization point. Can Native make use of idle CPU resources while waiting for the network to return?
In pure client-side development, we often use the interface data caching strategy to improve the user experience. Before the latest data is returned, the cached data is used for page rendering. Then in React Native, we can also refer to this idea to optimize the entire process.
Let's take a look at how it is implemented. First, when we open the carrier page, parse the pre-request interface configuration data in the corresponding bundle cache, initiate a request to cache the data, and cache the request after the request is successful.
public class RNApiPreloadUtils {
public static void preloadData(String bundleId) {
//根据bundle id解析对应的预请求接口配置,可存在多个接口
List<PrefetchBean> prefetchBeans = parsePrefetchBeans(bundleId);
//请求接口,成功后缓存到本地存储
requestDatas(prefetchBeans);
}
public static String prefetchData(String url) {
//从本地缓存中,根据url获取对应的接口数据
}
}
Then, obtain the corresponding cached data according to the url.
public class PreFetchBusinessModule extends ReactContextBaseJavaModule
implements ReactModuleWithSpec, TurboModule {
public PreFetchBusinessModule(ReactApplicationContext reactContext) {
super(reactContext.real());
}
@ReactMethod
public void prefetchData(String url, Callback callback) {
String data = RNApiPreloadUtils.prefetchData(url);
// 回传数据给 JS
WritableMap resultMap = new WritableNativeMap();
map.putInt("code", 1);
map.putString("data", data);
callback.invoke(resultMap);
}
}
Next, you can call the above method on the JavaScript side. The calling code is as follows:
NativeModules.PreFetchBusinessModule.prefetchData(url, (result)=>{
//获取到结果后,判断是否为空,不为空解析数据后渲染页面
console.info(result);
}
);
4. Unpacking
The JavaScript code package of the React Native page is issued by the hot update platform according to the version number. Every time there is a business change, we need to update the code package through a network request. However, as long as the official version of React Native has not changed, the part related to the React Native source code in the JavaScript code package will not change, so we do not need to issue it every time the business package is updated, and a built-in one is built into the project. Just enough.
Therefore, when we package the JavaScript code, we need to split the package into two parts: one is the Common part, which is the React Native source code part; the other is the business code part, which is the part we need to download dynamically.
After the above split, the Common package is built into the project (at least a few hundred kilobytes in size), and the business code package is dynamically downloaded. Then we use the JSContext environment to load the Common package in the environment after entering the carrier page, and then load the business code package to completely render the React Native page. The following is the loading logic of the iOS native part.
//载体页
- (void)loadSourceForBridge:(RCTBridge *)bridge
onProgress:(RCTSourceLoadProgressBlock)onProgress
onComplete:(RCTSourceLoadBlock)loadCallback{
if (!bridge.bundleURL) return;
//加载新资源
//开始加载bundle,先执行common bundle
[RCTJavaScriptLoader loadCommonBundleOnComplete:^(NSError *error, RCTSource *source){
loadCallback(error,newSource);
}];
}
//common执行完毕
+ (void)commonBundleFinished{
//开始执行buz bundle代码
[RCTJavaScriptLoader loadBuzBundle:self.bridge.bundleURL onComplete:^(NSError *error, RCTSource *source){
loadCallback(error,newSource);
}];
}
//RCTJavaScriptLoader.mm
+ (void)loadBuzBundle:(NSURL *)buzURL
onComplete:(WBSourceLoadBlock)onComplete{
//执行buz包代码
[self executeSource:buzURL onComplete:^(NSError *error){
//执行完毕
onComplete(error);
}];
}
Five, on-demand loading
In fact, we have reduced the size of the dynamically downloaded business code package through the previous unpacking solution. However, some services are still very large, and the size of the service code package is still very large after unpacking, which will still lead to slow download speeds and will be affected by network conditions.
Therefore, we can split the business code package again, and split a business code package into a main package and multiple sub-packages. After entering the page, the JavaScript code resources of the main package are requested first, which can quickly render the first screen page. When the user clicks on a module, the code package of the corresponding module is downloaded and rendered, which can further reduce the loading time.
So, when do you need to split the business code package into a main package and multiple subpackages? Which module should be used as the main package and which module should be used as the sub-package?
In fact, when the business logic is relatively simple, we do not need to split the business code package. At that time, when the business is relatively complex, especially some large-scale projects may need to be unpacked, and the unpacking logic, Usually split by business. For example, let's take this business page with Tabs.
As you can see, the home page of the page contains three Tabs, which represent three different business modules. If the content in these three tabs is similar, we certainly don't need to split the business code package. However, if the content in these three tabs is quite different and the page templates are completely different, we can split the business code package.
6. Other optimizations
In the performance optimization of React Native mobile terminal, in addition to the optimization of React Native environment creation, bundle files, interface data, etc., there is also a big optimization point, that is, React Native runtime optimization.
As we all know, the operating efficiency of the old version of React Native has two pain points: first, the JSC engine is inefficient in interpreting and executing JavaScript code, and the engine starts slowly; second, the communication efficiency between JavaScript and Native is low, especially involving batch UI interaction.
Therefore, the new architecture of React Native uses JSI for communication, replacing JSBridge, without asynchronous serialization and deserialization operations, no memory copy, and can achieve synchronous communication.
In addition, React Native 0.60 and later versions began to support the Hermes engine. Compared with the JSC engine, the Hermes engine has greatly improved the startup speed and code execution efficiency, so next we will focus on the characteristics of the Hermes engine, its optimization methods and how to enable it on the mobile side.
6.1 Start the Hermes engine
Facebook officially launched a new generation of JavaScript execution engine Hermes at the ChainReact 2019 conference. Hermes is a lightweight JavaScript engine optimized for running React Native on mobile. Hermes can execute bytecode and JavaScript.
When analyzing performance data, the Facebook team found that the JavaScript engine was an important factor affecting startup performance and app bundle size. JavaScriptCore was originally designed for desktop browsers. Compared with desktops, mobile capabilities have too many limitations. Therefore, in order to optimize the performance of the mobile terminal from the bottom, the Facebook team chose to build its own JavaScript engine Hermes.
According to a set of data of the Hermes engine officially given at the Chain React conference, it can be seen that Hermes is indeed powerful:
The time from page startup to user actionable (Time To Interact: TTI) is reduced from 4.3s to 2.01s;
App download size reduced from 41MB to 22MB;
Memory footprint, reduced from 185MB to 136MB.
Hermes' optimization is mainly reflected in the two points of bytecode precompilation and abandoning JIT. First, let's look at bytecode precompilation. The general process of executing a piece of JavaScript code by modern mainstream JavaScript is: [read source file] -> [parse and convert into bytecode] -> [execute bytecode].
However, parsing source code to convert bytecode at runtime is a waste of time, so Hermes opts for a precompiled approach to generate bytecode during compilation. In this way, on the one hand, unnecessary conversion time is avoided; on the other hand, the extra time can be used to optimize the bytecode, thereby improving the execution efficiency.
The second point is to abandon the JIT. In order to speed up execution efficiency, mainstream JavaScript engines now use a JIT compiler to optimize JavaScript code by converting it into machine code at runtime. The Faceback team believes that the JIT compiler has two main problems:
To preheat at startup, it will affect the startup time;
Will increase the engine size and runtime memory consumption.
However, it should be noted here that if JIT is abandoned, the execution efficiency of plain-text JavaScript code will be reduced. Abandoning the JIT means abandoning the compiler optimization of the plain-text JavaScript code by the Hermes engine at runtime. Of course, Hermes also brings some problems. The first is that the bytecode file compiled by Hermes is much larger than the plain text JavaScript file. The second point is that it takes a long time to execute plain text JavaScript.
So how do we turn on Hermes? In addition to referring to the official documentation to quickly turn on Hermes, let's focus on how to turn on the Hermes engine in a hybrid project, taking Android as an example.
1. The first step is to obtain the hermes.aar file (directory node_modules/hermes-engine).
2. In the second step, put hermes-cppruntime-release.aar and hermes-release.aar in the libs directory of the project, and then add dependencies in the module's build.gradle. The two aars are mainly hermes and libc+ +_shared so file.
dependencies {
implementation(name:'hermes-cppruntime-release', ext:'aar')
implementation(name:'hermes-release', ext:'aar')
}
3. The third step is to set up the JavaScript engine.
ReactInstanceManagerBuilder builder = ReactInstanceManager.builder()
.setApplication((Application) context.getApplicationContext())
.addPackage(new MainReactPackage())
.setRedBoxHandler(mExceptionHandler)
.setUseDeveloperSupport(RNDebugSwitcher.getInstance().isDebug())
.setInitialLifecycleState(LifecycleState.BEFORE_CREATE)
.setJavaScriptExecutorFactory(new HermesExecutorFactory()); // 设置为 hermes
Finally, run the bytecode bundle file compiled by hermes. And this step is divided into several small steps:
Package JavaScript into bundle files.
react-native bundle --platform android --entry-file index.android.js
--bundle-output ./bundles/index.android.bundle --assets-dest ./bundles
--dev false
Use hermes-engine to convert bundle files to bytecode files. Download hermes-engine, use the hermesc name to convert.
./hermesc -emit-binary -out index.android.bundle.hbc
xxx/react-native/app/bundles/index.android.bundle
Finally, you also need to rename the bundle file. The method is to delete the index.android.bundle in the previous bundle directory, and then rename the current index.android.bundle.hbc to index.android.bundle.
6.2 Engine reuse
In hybrid applications, React Native is changed from application-level use to page-level use, and each page uses a React Native engine (including JSC/Hermes, Bridge/JSI). In addition to high memory usage, the creation of React Native engine takes time. It is also more serious. Therefore, another common optimization in React Native is engine reuse optimization.
Taking Android as an example, the direct expression of the React Native engine is the ReactInstanceManager, which initializes the React Native-related environment internally. In hybrid applications, page loading is generally performed with the hot update strategy, so the ability of JSC/Hermes to dynamically load scripts is used. From this scenario, it seems that an engine can run different bundle files to achieve the purpose of reuse. There are also many pits for engine reuse. For example, the common ones are as follows:
- The cost of creating and reusing the engine may result in inconsistent performance of many pages, and the speed of the first entry and subsequent entry is inconsistent, so such experience problems still need special investigation and optimization;
- When multiple pages are in the foreground at the same time, for example, the different pages of the home page TAB use React Native pages, there will be inexplicable synchronization problems;
- When reusing the content of the React Native container, the global variables of the previous session will be maintained, which may easily cause business logic errors. When the same engine loads different bundles, it may be unknown whether the JavaScript context and the newly loaded code can achieve 100% isolation and pollution-free. Simultaneous multi-page JavaScript context isolation. At present, a big pit that causes reuse actually comes from the mixing of multiple pages in the JavaScript context, which is prone to errors;
- Irreversible exceptions may occur in JSC/Hermes at any time, so abnormal status identification during engine maintenance is also a problem.
The above are some common points of React Native optimization discussed today, including environment pre-creation, asynchronous update, interface pre-cache, unpacking, on-demand loading, Hermes engine, engine reuse, etc. These methods are very practical in actual business. Of course, the React Native framework is also constantly optimizing and iterating on itself to pursue a higher level of performance.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。