DEV Community

Cover image for Don't pay for painfully slow React Native iOS builds anymore.
Davyd NRB
Davyd NRB

Posted on • Updated on

Don't pay for painfully slow React Native iOS builds anymore.

This article discusses the CI/CD ecosystem status for React Native projects (with a focus on iOS). Thank Apple that Xcode, which requires MacOS, is essential to creating iOS apps.

The approaches

Everyone is aware that Xcode is very slow when bundling Objective C code. At the moment we have a lot of strategies to speed up iOS builds. I spread them to two group:

1) Build only Javascript

  • 💉 Building and injecting a JS bundle a basic concept: If the native code wasn’t changed, simply create JS bundle and inject it into the precompiled application. (You must take an extra step to convert a js file to bytecode if you use the Hermes js engine.)
  • ☁️ If you don’t have the time to put the aforementioned strategy into practice above, you can pay Nitro Build Service (x5 Faster). I gave it a try and was quite delighted with their work:

Image description

2) Tricks & Cache

However, all of the above methods must use the Apple build tool or third-party services 💵. But I do have something extra special for you. 😏🎁


YES it’s CodePush!

I myself prefer to concentrate on a free and open-source alternative called CodePush 🙏, which enables us to truly “stop paying” for MacOS runners. Installing new native dependencies or touching native code infrequently is ideal for you. How is this possible?

I use the next strategy:

  • If native code (usually ./android/**, ./ios/**) was changed, we would apply the classic flow: Build a whole app for Android and iOS that you should already have.
  • However, if only the Javascript code was modified, what would be the point of creating new native code? Instead, create a Javascript bundle and deploy it to CodePush. Of course, you don’t need to use MacOS runners to compile JS. Cheapest Linux should suffice 💰.

After a successful build, your team members may quickly apply a deployed CodePush release by using the app’s dev menu.

Demo repo: https://github.com/retyui/CodePushCiCd (ci/cd Github Actions, dev-menu, client part)


Configuration project

The first is to create a new iOS and Android app in the appcenter: https://appcenter.ms/orgs/MyOrganizationTest/applications/create

Image description

After that, you need to create standard deployments:

Image description

By default, two staging and production deployments will be created:

Image description

The final step in the admin panel is to collect the production deployment keys of iOS and Android apps. (will be used on the JS side later). Open the CodePush page and tap on a spanner icon next to the Deployment select

Image description

Image description

// constants.ts
import {Platform} from 'react-native';

const CODE_PUSH_IOS_PROD_KEY = 'Gbsg8cTjdcSWOwgJEOEHqk8VE1x6ITThqvNe0';
const CODE_PUSH_ANDROID_PROD_KEY = 'Ob7LrQg_w-l4w1SOLDYT5XBw76_6Pz-NVCed1';

export const CODE_PUSH_PROD_KEY = Platform.select({
  ios: CODE_PUSH_IOS_PROD_KEY,
  default: CODE_PUSH_ANDROID_PROD_KEY,
});
Enter fullscreen mode Exit fullscreen mode

Client side (core logic)

Begin by adding a react-native-code-push module. Then continue with the native parts:

Now let’s write some code. I created a simple hook useSyncOnAppStart that will encapsulate all logic that you need and should be utilized in the app’s root component:

// App.tsx
import {useSyncOnAppStart} from './codepush/useSyncOnAppStart'

export detault function App(){
  useSyncOnAppStart(); // Init CodePush sync on App mount

  return <View/>;
}
Enter fullscreen mode Exit fullscreen mode
// codepush/useSyncOnAppStart.ts
import {useEffect} from 'react';
import {AppState, AppStateStatus} from 'react-native';
import CodePush from 'react-native-code-push';

import {CODE_PUSH_PROD_KEY} from '../constants';

const noop = () => {};

const isDeploymentNotFoundError = (error: Error) =>
  error?.message?.includes?.('No deployment found');

function syncOnAppStart() {
  async function start() {
    try {
      // see comment#1
      const runningPackage = await CodePush.getUpdateMetadata(
        CodePush.UpdateState.RUNNING,
      );

      const isNonProduction =
        runningPackage?.deploymentKey &&
        runningPackage.deploymentKey !== CODE_PUSH_PROD_KEY;

      // see comment#2
      const nonProdConfig = {
        // Non-Prod sync (To enable this variant use "Dev Menu" to change deployment)
        deploymentKey: runningPackage?.deploymentKey,
        installMode: CodePush.InstallMode.IMMEDIATE,
        updateDialog: {
          // Ask mobile team member before install new version for custom deployment
        },
      };

      // see comment#3
      const prodConfig = {
        // Prod sync (↓↓↓ Default values, see: https://github.com/microsoft/react-native-code-push/blob/master/docs/api-js.md#codepushoptions)
        installMode: CodePush.InstallMode.ON_NEXT_RESTART,
        deploymentKey: CODE_PUSH_PROD_KEY,
        rollbackRetryOptions: {
          maxRetryAttempts: 1,
          delayInHours: 24,
        },
      };

      // see comment#4
      const status = await CodePush.sync(
        isNonProduction ? nonProdConfig : prodConfig,
      );

      return status;
    } catch (error) {
      // see comment#5
      if (isDeploymentNotFoundError(error)) {
        return CodePush.clearUpdates();
      }
      // see comment#6
      trackError(error);
    }
  }

  // see comment#7
  const onAppStateChange = async (newState: AppStateStatus) => {
    if (newState === 'active') {
      await start();
    }
  };

  let unsubscribe = noop;

  start()
    .catch(noop)
    .finally(() => {
      const subscription = AppState.addEventListener(
        'change',
        onAppStateChange,
      );

      unsubscribe = () => subscription.remove();
    });

  return () => {
    unsubscribe();
  };
}

export const useSyncOnAppStart = (): void => {
  useEffect(() => {
    const unsubscribe = syncOnAppStart();

    return unsubscribe;
  }, []);
};
Enter fullscreen mode Exit fullscreen mode

Comment#1: Get information about the installed package first. Comment#2: When dealing with non-production packages, we employ codepush.InstallMode.IMMEDIATE, which displays an Update Alert to the user. Comment#3: For production builds app will use default options. Comment#4: Run code push sync (read docs: CodePush.sync(...)). Comment#5: The “No deployment found” problem occasionally occurs when a developer still has an install package from a deleted development deployment. Comment#6: Send an error to you for error tracking (like Sentry). Comment#7: If a new codepush release was made while the app was being used, add an app state listener to keep everything in sync.

Client side (dev-menu)

This section will be lot easier. To make applying custom builds simple, I built the react-native-code-push-dev-menu module.

# Install
yarn add react-native-code-push-dev-menu
# or npm install react-native-code-push-dev-menu
Enter fullscreen mode Exit fullscreen mode

Usage example:

// DevMenuScreen.tsx
import {
  CodePushDeMenuButton,
  configurateProject,
} from 'react-native-code-push-dev-menu';

configurateProject({
  readonlyAccessToken: Platform.select({
    // Read-only access tokens 
    // https://docs.microsoft.com/en-us/appcenter/api-docs/#creating-an-app-center-app-api-token
    ios: '128009dc42ded5e71ef21e007a24eb67b5c3279f',
    default: '42f471742864bd9c1917f322918b163a90d13904',
  }),
  appCenterAppName: Platform.select({
    ios: 'MyApp-iOS',
    default: 'MyApp-Android',
  }),
  appCenterOrgName: 'MyOrganizationTest',
});

function DevMenuScreen() {
  return (
    <SafeAreaView>
      <CodePushDeMenuButton />
      // Other dev things
    </SafeAreaView>
  );
}
Enter fullscreen mode Exit fullscreen mode

CI/CD

Automated CodePush builds were set up last. In admin of appcener you need to create APPCENTER_ACCESS_TOKEN https://appcenter.ms/settings/apitokens with fill access

Image description

Next, let’s build the config file scripts/envs.sh

#!/bin/bash
export APP_CENTER_ORG_NAME=MyOrganizationTest

export APP_CENTER_APP_NAME_IOS=MyApp-iOS
export APP_CENTER_APP_NAME_ANDROID=MyApp-Android
Enter fullscreen mode Exit fullscreen mode

The following step is to write a build script called scripts/codepush-non-prod.sh that will create a new deployment (using the branch name as the deployment name) and bundle a JS bundle that will be deployed in that environment.

#!/bin/bash

set -x # all executed commands are printed to the terminal
set -e # immediately exit if any command has a non-zero exit status

source "./scripts/envs.sh"


if [ -z "$DEPLOYMENT_NAME" ]; then
    echo "Please sure that DEPLOYMENT_NAME exists"
    exit 1
fi

if [ "$DEPLOYMENT_NAME" == "Production" ]; then
    echo "You can't use reserved name 'Production' for deployment"
    exit 1
fi

if [ -z "$APPCENTER_ACCESS_TOKEN" ]; then
    echo "Please sure that APPCENTER_ACCESS_TOKEN exists"
    exit 1
fi

if [ "$PLATFORM" != "ios" ] && [ "$PLATFORM" != "android"  ]
then
    echo "Please sure that you set PLATFORM env variable (ios | android)"
    exit 1
fi

APP_CENTER_APP_NAME="$APP_CENTER_APP_NAME_ANDROID"

if [ "$PLATFORM" == "ios" ]; then
    APP_CENTER_APP_NAME="$APP_CENTER_APP_NAME_IOS"
fi

# Create new Codepush Deployment
appcenter codepush deployment add -a "$APP_CENTER_ORG_NAME/$APP_CENTER_APP_NAME" "$DEPLOYMENT_NAME" || true # Ignore "deployment named test-build already exists" error
# Create JS bundle
appcenter codepush release-react -a "$APP_CENTER_ORG_NAME/$APP_CENTER_APP_NAME" -d "$DEPLOYMENT_NAME" --target-binary-version "*" --description "$DESCRIPTION"
Enter fullscreen mode Exit fullscreen mode

You may now do a local test:

# Required
export APPCENTER_ACCESS_TOKEN=xxxx
export DEPLOYMENT_NAME=my-branch-name
# Optional
export DESCRIPTION="My desc..."

PLATFORM=ios ./scripts/codepush-non-prod.sh     # Codepush release for iOS
PLATFORM=android ./scripts/codepush-non-prod.sh # Codepush release for Android
Enter fullscreen mode Exit fullscreen mode

And if you use a Github Actions a create a .github/workflows/non_prod_codepush.yml file:

name: Mobile CodePush

on:
  pull_request:
    paths-ignore:
      - 'android/**'
      - 'ios/**'
  workflow_dispatch:


concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  android_beta_mobile_build:
    name: Build Non-Prod
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 1

      - name: Yarn cache
        uses: actions/cache@v3
        id: node_cache
        with:
          path: node_modules
          key: ${{ runner.os }}-yarn-${{ hashFiles('./yarn.lock') }}

      - name: Install node_modules
        run: yarn install --frozen-lockfile
        if: steps.node_cache.outputs.cache-hit != 'true'


      - name: Create Beta CodePush Release
        env:
          APPCENTER_ACCESS_TOKEN: ${{ secrets.APPCENTER_ACCESS_TOKEN }}
          DEPLOYMENT_NAME: ${{ github.head_ref || github.ref_name }} # `head_ref` pull_request event, `ref_name` workflow_dispatch event
          DESCRIPTION: Made by - ${{ github.actor }}
          NODE_ENV: production
        run: |
          npm install -g appcenter-cli@2.12.0
          PLATFORM=android ./scripts/codepush-non-prod.sh
          PLATFORM=ios ./scripts/codepush-non-prod.sh
Enter fullscreen mode Exit fullscreen mode

Additionally, a manual run may be used to test it on CI:

Image description


Let’s review what was created 🥳🤩🎂🎉

  • All PRs that just modify js will be released to CodePush (branch name will be used as Deployment name);
  • You may quickly apply those changes by using a Dev Menu; 🤩
  • You do not need to use MacOS to create a codepush release;🎉

If you have any questions, please feel free to ask them in the comments section.

MurAmur ©

Top comments (0)