Intro

This post is going to look at one approach to optimising a CI/CD pipeline for a React Native app. This could well turn into a series of posts looking at different approaches, most of which can compliment each other.

The approach we are discussing here is concerned with injecting a newly built Javascript bundle into a prebuilt iOS app package instead of building the whole iOS app from scratch. Building apps can take a while, iOS apps in particular. To get a better idea of how long a React Native pipeline for both Android and iOS builds takes to run check out this post

What is React Native?

React Native is a library developed by Facebook to facilitate the use of React in iOS and Android mobile app development. It doesn’t completely preclude the need for platform (iOS/Android) or languages (Java/Swift/Objective C) specific knowledge but it does provides a React friendly environment to develop mobile applications predominantly in Javascript.

Why do we want to do this?

When we build our platform native apps with React Native it must compile iOS and/or Android packages with a Javascript bundle (the React bit) included. This can take a while, iOS build time in particular. Given that, optimising the iOS build process is our priority here. I would argue that if Android build time is an issue that’s a good problem to have and you are doing pretty well.

Packing our iOS app takes (too) long, do we have to build it? Yes, but maybe not all of the time. There are two parts to a React Native app, the native app itself and the Javascript bundle. Pretty much all the functionality the bundle seeks to implement is dependent on the underlying OS, so it requires the native platform dependencies to be of any use.

What are our native dependencies?

So we always need a native app package, fine. But how many of the underlying native dependencies change between builds/releases of your app? Let’s have a look at these dependencies might look like:

  • The UI frameworks React Native depends on are bundled in the core starter project and the OS itself.
  • Most apps will also have other native dependencies - commonly to support navigation, authentication, configuration, monitoring, analytics or to integrate with third party SDKs like Facebook or Google Maps.
  • You might have written some of your own Native/Turbo modules.
  • Native dependencies also look like new splash screen assets, string assets, permissions or capability configurations, supported OS versions etc.

How many dependencies change between builds?

Some or all of these could change between builds due to platform/third party version upgrades, using new SDKs, design asset changes etc. There are a quite a few factors that could necessitate changes in native dependencies between builds. Lets ask a slightly different question - how often do they change between builds? In an app with a certain level of maturity the answer is usually not that often at all.

Ask yourself what work is most commonly done on your app between builds, this will be relevant later.

How long does the Javascript bundle take to build?

About 3-5 minutes for a decent sized app, nice. If no native dependencies have changed can we just rebuild our bundle instead of building the whole app? That is the question I asked and the internet didn’t give me a straight answer so we figured it out for ourselves.

How do we do this?

Below are the steps we need to take to do this successfully, we look at each step in bit more detail

  1. Check if any native dependencies have changed
  2. Build a new Javascript bundle
  3. Unpackage the prebuilt app
  4. Inject the Javascript bundle into the package
  5. Repackage the app
  6. Resign the app

Have the native dependencies changed?

To decide whether we want to inject a new bundle or build the whole app we need to have a mechanism to detect when the native dependencies we care about have changed. The suggested approach is to hash all of the files that would signal this.

In the repository to go with this post there are some CLI tools to help you with this. In the config folder there is a pretty solid configuration to get you started but make sure you identify all native dependencies your app has and update this. The work that is commonly done on your app between builds will help you here.

Using the generateNativeHash script with the required arguments will produce something along the lines of:

In most CI/CD services there is the capability to save/store artifacts between pipeline executions. Using the hash of this output file to store your built app for iOS could be an idea as you will want it in future executions. Saving this output file itself can be useful to help debug any false positives/negatives dependency changes between executions. A note on this, false positives are fine but false negatives could break your app.

Build a new Javascript Bundle

We have looked at one mechanism to check if we want rebuild our whole app. If we don’t we only want to build a Javascript bundle and inject it into a prebuilt app. Running the below command (if you are using yarn and create-react-native-app) will get this done for us. There are two outputs of this command, the bundle itself and any assets it uses/depends on.

Unpackage the old app

If you have got here you should have a prebuilt .ipa package for your app, most likely from a previous pipeline execution. Unpackaging this is actually pretty straight forward as it can be treated like a .zip, with same tooling you would manipulate them with.

Inject the new bundle

Repackage the app

Resign the app

Here we are using Fastlane to resign the app - the Fastfile is here

Once you have got to this point you can carry on as normal and test/deploy your app as you are doing already.

Other/complimentary strategies - https://dev.to/retyui/react-native-how-speed-up-ios-build-4x-using-cache-pods-597c

Get in contact

If you have comments, questions or better ways to do anything that I have discussed in this post then please get in contact.