From e268883fdc11b44d8e06616d045a342d0101eb45 Mon Sep 17 00:00:00 2001 From: Daniel Mueller Date: Fri, 13 Oct 2017 17:22:00 -0700 Subject: [PATCH] Improve support for unbundle feature Summary: unbundle is a useful feature, and it should be exposed. In order to get the most use out of we expose it as an option at build time in the Build Phase on XCode and the project.ext.react config in the build.gradle. Because it is best used with inline requires we add a section under performance that describes how inline requires can be implemented and how to use with the unbundling feature. Testing: - Added a section of the doc which explains how the feature can be enabled - Use the instructions, build a build on iOS + android (using release so that the bundle is created) and confirm that the bundle has the binary header information. Closes https://github.com/facebook/react-native/pull/15317 Differential Revision: D6054642 Pulled By: hramos fbshipit-source-id: 067f4d2f78d91215709bd3e3636f460bc2b17e99 --- docs/Performance.md | 262 ++++++++++++++++++++++++++++++++++ react.gradle | 19 ++- scripts/react-native-xcode.sh | 11 +- 3 files changed, 287 insertions(+), 5 deletions(-) diff --git a/docs/Performance.md b/docs/Performance.md index c23c06812..4fc80edee 100644 --- a/docs/Performance.md +++ b/docs/Performance.md @@ -352,3 +352,265 @@ In the second scenario, you'll see something more like this: Notice that first the JS thread thinks for a bit, then you see some work done on the native modules thread, followed by an expensive traversal on the UI thread. There isn't an easy way to mitigate this unless you're able to postpone creating new UI until after the interaction, or you are able to simplify the UI you're creating. The react native team is working on a infrastructure level solution for this that will allow new UI to be created and configured off the main thread, allowing the interaction to continue smoothly. + +## Unbundling + inline requires + +If you have a large app you may want to consider unbundling and using inline requires. This is useful for apps that have a large number of screens which may not ever be opened during a typical usage of the app. Generally it is useful to apps that have large amounts of code that are not needed for a while after startup. For instance the app includes complicated profile screens or lesser used features, but most sessions only involve visiting the main screen of the app for updates. We can optimize the loading of the bundle by using the unbundle feature of the packager and requiring those features and screens inline (when they are actually used). + +### Loading JavaScript + +Before react-native can execute JS code, that code must be loaded into memory and parsed. With a standard bundle if you load a 50mb bundle, all 50mb must be loaded and parsed before any of it can be executed. The optimization behind unbundling is that you can load only the portion of the 50mb that you actually need at startup, and progressively load more of the bundle as those sections are needed. + +### Inline Requires + +Inline requires delay the requiring of a module or file until that file is actually needed. A basic example would look like this: + +#### VeryExpensive.js +``` +import React, { Component } from 'react'; +import { Text } from 'react-native'; +// ... import some very expensive modules + +// You may want to log at the file level to verify when this is happening +console.log('VeryExpensive component loaded'); + +export default class VeryExpensive extends Component { + // lots and lots of code + render() { + return Very Expensive Component; + } +} +``` + +#### Optimized.js +``` +import React, { Component } from 'react'; +import { TouchableOpacity, View, Text } from 'react-native'; + +let VeryExpensive = null; + +export default class Optimized extends Component { + state = { needsExpensive: false }; + + didPress = () => { + if (VeryExpensive == null) { + VeryExpensive = require('./VeryExpensive').default; + } + + this.setState(() => ({ + needsExpensive: true, + })); + }; + + render() { + return ( + + + Load + + {this.state.needsExpensive ? : null} + + ); + } +} +``` + +Even without unbundling inline requires can lead to startup time improvements, because the code within VeryExpensive.js will only execute once it is required for the first time. + +### Enable Unbundling + +On iOS unbundling will create a single indexed file that react native will load one module at a time. On Android, by default it will create a set of files for each module. You can force Android to create a single file, like iOS, but using multiple files can be more performant and requires less memory. + +Enable unbundling in Xcode by editing the build phase "Bundle React Native code and images". Before `../node_modules/react-native/packager/react-native-xcode.sh` add `export BUNDLE_COMMAND="unbundle"`: + +``` +export BUNDLE_COMMAND="unbundle" +export NODE_BINARY=node +../node_modules/react-native/packager/react-native-xcode.sh +``` + +On Android enable unbundling by editing your android/app/build.gradle file. Before the line `apply from: "../../node_modules/react-native/react.gradle"` add or amend the `project.ext.react` block: + +``` +project.ext.react = [ + bundleCommand: "unbundle", +] +``` + +Use the following lines on Android if you want to use a single indexed file: + +``` +project.ext.react = [ + bundleCommand: "unbundle", + extraPackagerArgs: ["--indexed-unbundle"] +] +``` + +### Configure Preloading and Inline Requires + +Now that we have unbundled our code, there is overhead for calling require. require now needs to send a message over the bridge when it encounters a module it has not loaded yet. This will impact startup the most, because that is where the largest number of require calls are likely to take place while the app loads the initial module. Luckily we can configure a portion of the modules to be preloaded. In order to do this, you will need to implement some form of inline require. + +### Adding a packager config file + +Create a folder in your project called packager, and create a single file named config.js. Add the following: + +``` +const config = { + getTransformOptions: () => { + return { + transform: { inlineRequires: true }, + }; + }, +}; + +module.exports = config; +``` + +In Xcode, in the build phase, include `export BUNDLE_CONFIG="packager/config.js"`. + +``` +export BUNDLE_COMMAND="unbundle" +export BUNDLE_CONFIG="packager/config.js" +export NODE_BINARY=node +../node_modules/react-native/packager/react-native-xcode.sh +``` + +Edit your android/app/build.gradle file to include `bundleConfig: "packager/config.js",`. + +``` +project.ext.react = [ + bundleCommand: "unbundle", + bundleConfig: "packager/config.js" +] +``` + +Finally, you can update "start" under "scripts" on your package.json to use the config: + +`"start": "node node_modules/react-native/local-cli/cli.js start --config ../../../../packager/config.js",` + +Start your package server with `npm start`. Note that when the dev packager is automatically launched via xcode and `react-native run-android`, etc, it does not use `npm start`, so it won't use the config. + +### Investigating the Loaded Modules + +In your root file (index.(ios|android).js) you can add the following after the initial imports: +``` +const modules = require.getModules(); +const moduleIds = Object.keys(modules); +const loadedModuleNames = moduleIds + .filter(moduleId => modules[moduleId].isInitialized) + .map(moduleId => modules[moduleId].verboseName); +const waitingModuleNames = moduleIds + .filter(moduleId => !modules[moduleId].isInitialized) + .map(moduleId => modules[moduleId].verboseName); + +// make sure that the modules you expect to be waiting are actually waiting +console.log( + 'loaded:', + loadedModuleNames.length, + 'waiting:', + waitingModuleNames.length +); + +// grab this text blob, and put it in a file named packager/moduleNames.js +console.log(`module.exports = ${JSON.stringify(loadedModuleNames.sort())};`); +``` + +When you run your app, you can look in the console and see how many modules have been loaded, and how many are waiting. You may want to read the moduleNames and see if there are any surprises. Note that inline requires are invoked the first time the imports are referenced. You may need to investigate and refactor to ensure only the modules you want are loaded on startup. Note that you can change the Systrace object on require to help debug problematic requires. + +``` +require.Systrace.beginEvent = (message) => { + if(message.includes(problematicModule)) { + throw new Error(); + } +} +``` + +Every app is different, but it may make sense to only load the modules you need for the very first screen. When you are satisified, put the output of the loadedModuleNames into a file named packager/moduleNames.js. + +### Transforming to Module Paths + +The loaded module names get us part of the way there, but we actually need absolute module paths, so the next script will set that up. Add `packager/generateModulePaths.js` to your project with the following: +``` +// @flow +/* eslint-disable no-console */ +const execSync = require('child_process').execSync; +const fs = require('fs'); +const moduleNames = require('./moduleNames'); + +const pjson = require('../package.json'); +const localPrefix = `${pjson.name}/`; + +const modulePaths = moduleNames.map(moduleName => { + if (moduleName.startsWith(localPrefix)) { + return `./${moduleName.substring(localPrefix.length)}`; + } + if (moduleName.endsWith('.js')) { + return `./node_modules/${moduleName}`; + } + try { + const result = execSync( + `grep "@providesModule ${moduleName}" $(find . -name ${moduleName}\\\\.js) -l` + ) + .toString() + .trim() + .split('\n')[0]; + if (result != null) { + return result; + } + } catch (e) { + return null; + } + return null; +}); + +const paths = modulePaths + .filter(path => path != null) + .map(path => `'${path}'`) + .join(',\n'); + +const fileData = `module.exports = [${paths}];`; + +fs.writeFile('./packager/modulePaths.js', fileData, err => { + if (err) { + console.log(err); + } + + console.log('Done'); +}); +``` + +You can run via `node packager/modulePaths.js`. + +This script attempts to map from the module names to module paths. Its not foolproof though, for instance, it ignores platform specific files (\*ios.js, and \*.android.js). However based on initial testing, it handles 95% of cases. When it runs, after some time it should complete and output a file named `packager/modulePaths.js`. It should contain paths to module files that are relative to your projects root. You can commit modulePaths.js to your repo so it is transportable. + +### Updating the config.js + +Returning to packager/config.js we should update it to use our newly generated modulePaths.js file. +``` +const modulePaths = require('./modulePaths'); +const resolve = require('path').resolve; +const fs = require('fs'); + +const config = { + getTransformOptions: () => { + const moduleMap = {}; + modulePaths.forEach(path => { + if (fs.existsSync(path)) { + moduleMap[resolve(path)] = true; + } + }); + return { + preloadedModules: moduleMap, + transform: { inlineRequires: { blacklist: moduleMap } }, + }; + }, +}; + +module.exports = config; +``` + +The preloadedModules entry in the config indicates which modules should be marked as preloaded by the unbundler. When the bundle is loaded, those modules are immediately loaded, before any requires have even executed. The blacklist entry indicates that those modules should not be required inline. Because they are preloaded, there is no performance benefit from using an inline require. In fact the javascript spends extra time resolving the inline require every time the imports are referenced. + +### Test and Measure Improvements + +You should now be ready to build your app using unbundling and inline requires. Make sure you measure the before and after startup times. diff --git a/react.gradle b/react.gradle index 109b0dab9..64b2f02f8 100644 --- a/react.gradle +++ b/react.gradle @@ -5,6 +5,7 @@ def config = project.hasProperty("react") ? project.react : []; def cliPath = config.cliPath ?: "node_modules/react-native/local-cli/cli.js" def bundleAssetName = config.bundleAssetName ?: "index.android.bundle" def entryFile = config.entryFile ?: "index.android.js" +def bundleCommand = config.bundleCommand ?: "bundle" // because elvis operator def elvisFile(thing) { @@ -13,6 +14,7 @@ def elvisFile(thing) { def reactRoot = elvisFile(config.root) ?: file("../../") def inputExcludes = config.inputExcludes ?: ["android/**", "ios/**"] +def bundleConfig = config.bundleConfig ? "${reactRoot}/${config.bundleConfig}" : null ; void runBefore(String dependentTaskName, Task task) { Task dependentTask = tasks.findByPath(dependentTaskName); @@ -79,12 +81,21 @@ gradle.projectsEvaluated { // Set up dev mode def devEnabled = !(config."devDisabledIn${targetName}" || targetName.toLowerCase().contains("release")) + + def extraArgs = extraPackagerArgs; + + if (bundleConfig) { + extraArgs = extraArgs.clone() + extraArgs.add("--config"); + extraArgs.add(bundleConfig); + } + if (Os.isFamily(Os.FAMILY_WINDOWS)) { - commandLine("cmd", "/c", *nodeExecutableAndArgs, cliPath, "bundle", "--platform", "android", "--dev", "${devEnabled}", - "--reset-cache", "--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir, *extraPackagerArgs) + commandLine("cmd", "/c", *nodeExecutableAndArgs, cliPath, bundleCommand, "--platform", "android", "--dev", "${devEnabled}", + "--reset-cache", "--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir, *extraArgs) } else { - commandLine(*nodeExecutableAndArgs, cliPath, "bundle", "--platform", "android", "--dev", "${devEnabled}", - "--reset-cache", "--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir, *extraPackagerArgs) + commandLine(*nodeExecutableAndArgs, cliPath, bundleCommand, "--platform", "android", "--dev", "${devEnabled}", + "--reset-cache", "--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir, *extraArgs) } enabled config."bundleIn${targetName}" || diff --git a/scripts/react-native-xcode.sh b/scripts/react-native-xcode.sh index bfc419d11..121eb5f2f 100755 --- a/scripts/react-native-xcode.sh +++ b/scripts/react-native-xcode.sh @@ -70,6 +70,14 @@ fi [ -z "$CLI_PATH" ] && export CLI_PATH="$REACT_NATIVE_DIR/local-cli/cli.js" +[ -z "$BUNDLE_COMMAND" ] && BUNDLE_COMMAND="bundle" + +if [[ -z "$BUNDLE_CONFIG" ]]; then + CONFIG_ARG="" +else + CONFIG_ARG="--config $(pwd)/$BUNDLE_CONFIG" +fi + nodejs_not_found() { echo "error: Can't find '$NODE_BINARY' binary to build React Native bundle" >&2 @@ -105,7 +113,8 @@ fi BUNDLE_FILE="$DEST/main.jsbundle" -$NODE_BINARY "$CLI_PATH" bundle \ +$NODE_BINARY $CLI_PATH $BUNDLE_COMMAND \ + $CONFIG_ARG \ --entry-file "$ENTRY_FILE" \ --platform ios \ --dev $DEV \