Adding React Native to a Complex App — Part 3: Android
This third article in an expert content series takes a deep dive into the technical details involved in adding React Native to an existing Android app
So, you’re a developer working on adding React Native to an existing complex app, and you’re currently looking at Android? Our content series is here to help you successfully complete this journey.
Previously in our series:
In part 1 of this guide, we looked at planning: you should have a clear, considered strategy-
In part 2 of this guide, we looked under the hood of React Native: hopefully you’ve got a basic understanding of the pieces we’re working with.
1. Preparation
We’ll need to do a lot of delicate surgery in the Android app’s build process, so preparation is key.
1.1 Prerequisites
Before starting, you should:
Have at least a basic familiarity with Android projects in Android Studio. You don’t need to be an Android expert. However, it’ll help a huge amount if you already know your
build.gradle
from yourapp/build.gradle
, and if you’re comfortable using Android Studio to navigate Android files: its quirky Project view really does help when you’re used to it (tip: enable “Always select open file”), and its “Problems” and “Logcat” windows, in particular, will be invaluable. Google’s App Basics guides are a good place to start.Have the React Native Android development environment fully set up, and check your version of Android Studio is recent.
1.2 Have working ‘clean’ projects for comparison
It’s a very good idea to keep clean, working examples of the two projects you intend to merge:
Working native app
Have a separate repo, fork or clone with the native app as it is without React Native. Get this set up, built and confirmed to work and keep that working project as a comparison. You don’t want to spend hours debugging a build error, only to find out the problem was with something on your machine and unrelated to your actual work.
Working React Native app
Also, have a minimal working React Native app build of the intended React Native version, with a few required native dependencies installed (particularly, common low-level dependencies like react-native-screens
, react-native-gesture-handler
and react-native-reanimated
if you intend to use these).
This way you’ll be able to see much more clearly what issues came from the project merge:
If there are problems with your local developer setup, or required preparation steps for the native app, or other non-merge-related issues, you’ll catch them before adding additional complexity.
If/when you encounter issues, it’s easy to compare your current working branch with two complete configurations you know worked.
1.3 Fragment or Activity?
On Android, you can inject React Native into an Activity
, or a Fragment
(or multiple of either, or both…):
An Activity
is like a full-screen window
In a simple conventional React Native app, everything React Native is often entirely within the one “Main” Activity, with maybe a custom splash screen as the only other Activity in the app.
Many older Android apps clunked between dozens of different Activities when navigating between screens. However, many modern native Android apps only open a new Activity when navigating to something that feels like a fundamentally different sub-app, like when a videoconferencing app launches a video call.
A Fragment
can sit within an Activity and doesn’t need to be full screen
It’s like a regular view, but smarter, directly plugged into the application context and its position in the navigation history. They’re what react-native-screens
uses under the hood for navigation screens, and most times you see an app with tabs or drawers (in native Android or in React Native), those screens under the tabs and drawers are implemented as Fragments.
This is a crucial decision!
In part 1 of this series , we discussed how this decision interplays with some of the strategies that can be taken for adding React Native to a native app. Planning this right is important! Don’t proceed unless you have a clear understanding of how this will work: projects can get in a frightful mess if they start lobbing in activities or fragments without a clear, coherent plan.
2. Making it build
Once you’re clear on your approach, it’s time to replace your React Native project’s ./android
directory with the Android native app, make a commit in a new branch to easily track and revert changes from the original, clean native app, and begin the deceptively short React Native “Integration with Existing Apps” guide. If you’re using Fragments, start with the same guide but switch to the Integration with an Android Fragment guide when it starts talking about ReactRootView
.
Here are some common problems to look out for.
2.1 Variants, modes, packages and flavours
In a conventional React Native app, your bundle ID matches your app ID and your package name (e.g. com.company.app
), the main activity is called MainActivity
(in e.g. package com.company.app
), and it has “debug” and “release” modes, and that’s that. React Native assumes this pattern is followed unless told otherwise.
Longstanding Android apps are often a lot more complicated. A company might have reused code or assets by writing multiple separate apps into one project (sometimes even one app
directory), switching between apps using “flavors” like customer
, agent
, salesdeck
for wholly different apps with a little shared code.
There may be many build environments, including multiple pre-release intermediaries like staging and UAT, and all this would be selected from the Android Studio “Build variants” window with camelcase concatenated names like “customerDev”, “agentStaging”, “salesdeckUAT”.
An app variant might be listed on the Play Store with a simple bundle ID like com.company.app
that dates back to its very first release, but after years of rewrites and modernisation initiatives, you might find its current main activity is named Launchpad2Activity
in package biz.revampInitiative2017.customerApp.ux.screens
, and debug builds are generated with app IDs like com.company.customerUxRevampInitiative.v3.devInternal
.
2.1.1 Declare your “debuggable variants”
If you use build variants with names other than “debug” and “release”, React Native needs to know which should be treated like “debug” (load JavaScript on the fly via Metro, enable debugging features) and which should be treated like “release” (bundle minified JavaScript as a static resource, disable debugging features). By default, it treats all variants as “release” unless their name includes the substring “ debug
”.
You need to add a react
block to app/build.gradle
, with a debuggablePlugins key with an array of the full variant names that should run React Native in “debug” mode, as listed in the Build Variants window. For example (as of React Native 0.71):
This is documented , but only deep in the general docs for the React Native Gradle Plugin.
2.1.2 Fixing the Metro appName
React Native assumes that every build has the same unique app ID which is the same as what will be released to Play Store, but many Android projects use different names or suffixes for pre-release variants. If this is the case, once a build succeeds, your local React Native Metro server may simply fail to connect and send the debug app any JavaScript because it doesn’t recognise the name of the app trying to connect to it.
Tell Metro the actual app ID of the app that it should send your JavaScript to by creating or updating a react-native.config.js
file with:
2.1.3 Fixing npm run android
If your app uses build variants, npm run android
isn’t going to work without configuration, and npm run start
then a
in the terminal is never going to work.
You could commit to only running dev builds through Android Studio, or if you want to use the React Native CLI, head to the React Native CLI run android
documentation and write customised scripts, for example:
2.1.4 What’s the main activity name?
You may see errors like this, indicating your --main-activity
flag is wrong:
“error: Activity class MainActivity does not exist”
If the full name of your app’s main activity isn’t obvious, open the AndroidManifest.xml
and look for the block that contains an that contains . That’s the activity block describing your app’s main activity, and its android:name
is its name (and also, in Android Studio, cmd-click on the name takes you to the activity’s file).
If the name starts with a .
then it’s an incomplete name suffix: to get the full name, it needs to be added to the app’s package namespace, which you’ll find either:
In
app/build.gradle
’snamespace
key under theandroid
block
Or
In
AndroidManifest.xml
in thepackage
key of the<manifest
tag
For example, an AndroidManifest.xml
like this:
The above implies a main activity name like biz.revampInitiative2017.ux.customerApp.screens.Launchpad2Activity
.
2.2 Gradle plugins
Every step of the Android build, from pulling in dependencies to compilation, is handled by Gradle, launched via /gradlew
(or /gradle.bat
on Windows), then configured in build.gradle
files. Your project has a top-level build.gradle
responsible for setting up the build environment, then running many nested builds — most importantly, app/build.gradle
to build the app code, but also many React Native dependencies and Android dependencies contain their own build.gradle
(much like how many JS dependencies have package.json with their own post-install scripts), with various dependencies, plugins and build scripts.
2.2.1 Cleaning and re-syncing
Note that any time you change any Gradle files, if you’re using Android Studio, you’ll need to “re-sync” before it’ll warn you of issues.
Sometimes you’ll also need to run gradlew clean
( gradle.bat clean
on Windows) to run a cleanup command (clears the build directory, plus any custom cleanup added to your native app’s gradlew
runner). You can save yourself some time with an NPM command like:
2.2.2 AGP versions
The most important of the many Gradle plugins is the “AGP” (“Android Gradle Plugin” or com.android.tools.build:gradle
). Everything, from different Android Studio versions to lines of build script logic, will have been written with particular version ranges of the AGP in mind, and, unfortunately, this sometimes gets flakey. For example, see this issue on React Native 0.71’s issues with AGP prior to 7.4.1 .
But first, the easy part — if you see an error like this:
“The project is using an incompatible version (AGP 7.4.1) of the Android Gradle plugin. Latest supported version is AGP 7.3.1”
Your version of Android Studio is incompatible, so, change your version of Android Studio. Your local dev environment is the easiest, most side-effect-free thing to change: don’t change the config to match your personal tools, change your tools to match the config. Google has a table of Android Studio and AGP version compatibility .
The challenge comes if React Native and your native app need different versions. It’s a good idea to take the versions that work in your clean builds, and keep both, with one commented out and comments on where they’re from, to help immediate and future debugging, and to try to persist with whichever is newer (so long as it’s in the same major range), for example:
Here, there’s a good chance that it’ll work — with a slim, but non-zero chance that the native app contains some quirky action that depends on the side effect of a bug fixed after 7.2.1, or a rarely used feature accidentally regressed.
The problems will come if either React Native or the native app need different major versions, and the version required by one breaks the other:
It’s not possible to mix versions. There’s probably no simple alternative here to biting the bullet and updating the native app project to adapt to these breaking changes. The good news is, this is a common enough task that there are several resources available, including:
The official AGP Upgrade Assistant, which can fix most issues automatically in Android Studio
Extensive documentation on breaking changes in each past AGP release
Extensive documentation on Gradle version compatibility, with upgrade guides for specific version bumps (because upgrading the Android Gradle Plugin probably also implies upgrading the version of Gradle itself that will handle the build).
2.3 Duplicate and clashing dependencies
Unlike NPM, which can resolve multiple versions of the same dependency (although sometimes this causes side effects which make us wish it wouldn’t…), Android’s Gradle builds require just one version of each dependency in the final build.
This could pose a problem if your native app and React Native require conflicting versions of a shared nested dependency, or if one includes static files of a dependency another pulls down from a repository.
There are three ways to analyze your dependency tree and find the cause of clashes or duplicates:
2.3.1 Gradle’s dependency analyzer
You can use Gradle’s CLI to generate a dependency tree. In the /android
directory, run ./gradlew :app:dependencies
to see trees for every build mode. If your native app has many build variants or flavours, you can filter this with the --configuration
flag that takes one of the headers of the full output, which are made from camel case concatenation of a complete build variant name plus a “classpath” name (this will usually be RuntimeClasspath
, browse the full verbose without the --configuration
flag to see alternatives). For example:
cd android && ./gradlew :app:dependencies --configuration debugRuntimeClasspath && cd ..
./gradlew :app:dependencies --configuration customerStagingRuntimeClasspath
2.3.2 Android Studio’s “Analyze Dependencies”
You can right-click app
in the Android Project view then choose “Analyze -> Analyze Dependencies”. This has a UI, but it is quite difficult to use, requiring a lot of digging through complex deep-nested trees and subtrees, and the initial analysis required to populate it is very slow.
2.3.3 Fixing nested dependencies
Once you find the root cause of the dependency error, you’ll need to either align versions of the top-level dependency, remove one top-level dependency, or if neither of these simple fixes is an option, you can force a dependency to use a particular version of a nested dependency by adding a resolutionStrategy
block like this just above the dependencies
block in the build.gradle
file that contains the top-level dependency with the problematic nested dependency:
An alternative, more hands-on option is to disable transitive dependencies of a dependency completely with the { transitive = false }
option. This maximises control but requires you to do a lot of extra work, manually specifying all nested dependencies:
2.4 Check for Problems
Android Studio has a “Problems” window that highlights warnings and errors across the project and is worth watching. By default, it only looks at the current file, but its “Project Errors” tab will show any major problems from anywhere, including files you’ve never touched.
The first thing to check any time you see problems reported, is whether there is a banner along the top reminding you that some Gradle config changed and it needs to re-sync. This easy (but easy-to-forget) step will fix a lot of problems you see.
2.4.1 Unexpected type errors after a config change
Problems can sometimes emerge after a re-sync, in unexpected places such as deep in native app logic you’ve never touched. A common cause here is if a change in the version of a dependency or SDK causes some typing detail to change, which caused a cascade of tiny changes that ended in a type error. For example, an argument from some method in a library may have changed between “optional” and “required”, and seven steps down from where that library is used, some native code might start failing type checks because now, it sees a variable that could be null
passed somewhere that can’t handle null
.
Most issues like this should be possible to fix with some tweaks to either abort the process if the variable is null, or to provide a typesafe default. Kotlin’s “Elvis operator” ( ?:
- roughly equivalent to ??
in JavaScript) is very useful here, as is this pattern:
2.4.2 “Cannot resolve symbol” errors
You might see errors like this in the Problems window:
“cannot resolve symbol 'PackageList'”
“cannot resolve symbol 'buildConfig’”
The good news for these is, they’re not real problems. Both of these are generated during the first build, so do a build, and the warnings should just go away.
2.5 Integrating Kotlin
Kotlin is a great language to work with, but it is an extra compilation and complication in your build. Most React Native Android packages were historically written in Java, and most React Native Android documentation assumes the native app is overwhelmingly Java, but most modern Android development is done mostly in Kotlin.
2.5.1 Failures to import Kotlin from Java
You might unexpectedly see errors like “Cannot find symbol” or “Package does not exist” during a build or Gradle sync. Confusingly, this error may have never appeared in your Problems window, and this exact setup might have worked fine a day ago, or for a colleague with the same setup.
More confusingly, if you try to debug this in Android Studio, the error takes you to a file where the imported class or package quite clearly does exist — probably deep in some part of your native app that has always worked fine and which you haven’t touched.
How do you debug this (when the code looks fine, worked fine, and you can’t even search on it because the problems are specific to your app)?
Take a closer look: are all the problems in Kotlin files imported from Java files?
If so, the problem is likely to do with timing in your build. Something is parsing Java before the Kotlin files have been compiled into a form Java can understand.
Check the order of the Kotlin-related dependencies and plugin calls in your build.gradle
files. Perhaps you did something like this:
This could cause the React plugin to look down the tree of Java files that contain React imports, and if these have dependencies on Kotlin files, it’ll fail to load them. Crucially, it might work at first if a recent build left compiled versions of those Kotlin files in a cache, then seemingly randomly, it stops working when the cache expires or when a colleague pulls down your commit from a repository.
Make sure your Kotlin-related plugins are as early as possible everywhere they’re defined, usually immediately after the generic Android ones.
3. Making it run
Getting a working build is a vital step, but there are a number of sources of possible native-side runtime errors that could see your app instantly crash to the home screen, or silently stop before any React Native code becomes visible.
3.1 Stop React Native being optimised away
We know React Native is essential to the app, but Android tooling that follows code paths defined in Java and Kotlin might have no way of seeing that essential React Native dependencies will be used by code invoked from C++ or JavaScript, and may strip it out.
3.1.1 Code shrinking with ProGuard / R8
Almost all Android apps use ProGuard or R8 to remove unused code, similar to treeshaking in JS web projects. Note that ProGuard and R8 use largely the same configuration files and options, so while R8 has largely replaced ProGuard under the hood since around 2019 (AGP version 3.4), you’ll still mostly work with ProGuard files.
If your project contains a proguard-rules.pro
file (probably under “Gradle Scripts” in Android Studio), add something like this to the end. If you get errors about other specific classes being unexpectedly missing, add them too in a similar way:
These are some known React Native libraries that R8/ProGuard often incorrectly thinks are unused, because they’re called from native binary or JavaScript that it can’t follow. Android’s Code Shrinking docs have additional troubleshooting steps if these don’t work.
3.1.2 Dynamic Feature Modules
Android can compartmentalise a whole app module, complete with dependencies and Java/Kotlin code, and treat it as optional, downloaded on the fly via Play Feature Delivery if a user activates that feature.
Unfortunately, this doesn’t seem to play nicely with React Native’s features for bundling device-specific C++ code and seems to result in runtime errors due to missing libraries.
If React Native being in a dynamic feature module is a strict requirement, follow issues like this for updates. However, at the time of writing it looks like at least the core React Native dependencies need to be ever-present in the default build.
3.1.3 Third-party code obfuscation, like DexGuard
Some high-security apps go another level, and use tools to obfuscate and/or encrypt their source code, often to protect internal assets on rooted devices or try to prevent reverse engineering.
If your app uses these, the good news is, DexGuard , probably the most widely used such tool, talks about having supported React Native since 2019 . The bad news is, there’s still a lot that can go wrong. In particular, while obfuscation tools tend to do a good job encrypting and decrypting the code itself, they often change filenames and other such meta details that tools like React Native might be hardcoded to expect to be a certain way (for example, index.android.bundle
).
Since DexGuard and similar alternatives are generally closed-source paid products, there’s a strong chance you’ll need to contact their technical support about any issues if your app uses it.
3.2 Properly allow ClearText traffic
React Native’s guide includes an instruction to add android:usesCleartextTraffic="true"
to the AndroidManifest <application>
tag in debug builds, to allow JavaScript to be loaded from your local machine’s dev Metro server in development mode.
This may not be enough if your app has <network-security-config>
declared (probably somewhere in app/src/main/res/xml
— or do a project-wide search for network-security-config
), as this finer-grain configuration overrides the above flag. If so, add a block like this (be sure to either remove it in production builds or scope it to non-production build variants):
3.3 Fixing SoLoader failures
As discussed in part 2 of this series , React Native uses a lot of C++ libraries and uses a homespun Facebook/Meta tool, SoLoader, to load these at runtime based on the needs of the host device. This can cause extremely difficult to debug runtime failures, with error messages like:
“java.lang.UnsatisfiedLinkError: couldn’t find DSO to load: libhermes.so”
…or…
“java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__emutls_get_address"”
These issues have a very similar meaning: __emutls_get_address
is generally the very first symbol accessed in a modern .so
library, so both errors suggest that either one or more (probably, all) .so
C++ libraries haven’t been prepared and bundled correctly for the current device, or, they’ve been built by something too old that predates __emutls_get_address
.
3.3.1 Pinning NDK version
The most important piece of this puzzle is the NDK ( also discussed in Part 2 ). It needs to be correctly versioned in at least two places:
1: The top-level ./build.gradle
needs to specify ndkVersion
for the whole project, like this:
These lines of config fix the version used for the whole project then explicitly pass that version to the Android Gradle Plugin during the app stage of the build. Without this, a default NDK version may be chosen that might not be suitable and might not match the version used internally within React Native dependencies that have their own Gradle build steps that use the NDK.
3.3.2 Check for libc++_shared.so
conflicts
__emutls_get_address
usually comes specifically from a bundled libc++_shared.so
library. React Native usually manages the sourcing and bundling here itself, but if your native app comes with its own, older libc++_shared.so
file, or has very strict requirements, React Native’s attempts to pick a suitable libc++_shared.so
may be replaced by this file which might lack this expected __emutls_get_address
symbol.
Search your native app folders for hard copies of this file, or for build rules like pickFirst
that might force a particular version to be used, and if this does exist, see if it’s strictly necessary: it may be that this was added long ago to force what was then a relatively new version, and that forcing the newer version expected by React Native will work just as well.
3.3.3 JNI bundling options
If it still doesn’t work, the issue could be with JNI (Java Native Interface), which bridges between the Java / Kotlin Android system and the raw machine code produced by the C++ .so
libraries.
Start by searching your build.gradle
files for jniLibs
settings — for example, the native app might have an over-broad jniLibs.excludes
pattern that causes React Native libraries to be excluded.
If your app’s minSdkVersion
is higher than 23, it may be worth trying adding useLegacyPackaging
as follows, which forces all .so
files to be compressed into the built bundle. At the time of writing, React Native’s minSdkVersion
is 21, where this behaviour is the default, but if your native app requires a higher minimum version, the default will change. Adding this flag will revert it to the behaviour React Native expects:
3.3.4 Ensure React Native and SoLoader are up to date
Unfortunately, Facebook/Meta’s homespun SoLoader library is itself buggy and has issues where it behaves incorrectly for certain Android architectures.
In particular:
Around React Native version 0.70, there was an issue with React Native’s internal Gradle config stripping out required
.so
libraries.Versions of SoLoader before
com.facebook.soloader:soloader:0.10.5
(shipped with React Native 0.71.5) had a known issue in production on some Android devices, and although the issue is supposedly fixed, there are still some reports of similar-looking issues.
If your version of React Native is below 0.71.5, upgrade (following guidance on any required native changes) . You can also ensure an up-to-date version of SoLoader is being used by:
Using the dependency debugging tools described in section 2.2.3 above and searching the output for
com.facebook.soloader:soloader
If it’s old, forcing a recent version to be used by adding a block like this to your app
build.gradle
:
3.4 Routine, ongoing debugging
Hopefully, after all of this, the app should build and run as expected. If you encounter further issues, here’s a few tips to keep in mind:
Don’t forget to look for errors in Android Studio’s LogCat window, since React Native debugging tools won’t help if the error is before React Native even starts
Don’t forget to scroll up through the LogCat firehose because often, the error with the useful debugging information is a long way before the fatal error that caused the crash
Keep an eye on Android Studio’s “Problems” window which should flag any issues that emerge within the app’s Java and Kotlin logic
Good luck!
In part 4 , we’ll look at troubleshooting specific to iOS apps.
Does your organisation want to get the benefits of React Native?
Our experts can help incorporate React Native into your organisation. If your organisation wants to reduce its time to market, cut its maintenance costs and boost its product reliability, contact us today . We’d love to help level up your organisation.
Insight, imagination and expertly engineered solutions to accelerate and sustain progress.
Contact