Skip to content

Instantly share code, notes, and snippets.

@kelset
Last active June 21, 2023 19:25
Show Gist options
  • Save kelset/2eb61161a68d1ab35337d1eaa9a05e78 to your computer and use it in GitHub Desktop.
Save kelset/2eb61161a68d1ab35337d1eaa9a05e78 to your computer and use it in GitHub Desktop.
This is kind of a blogpost about my experience of diving deep to improve some timings for an iOS React Native app

Improving times for both iOS build and CI for a React Native app

Intro

Hello there.

So, if you are here you probably saw my previous tweet where I asked for tips & tricks on improving the timing on an iOS/React Native app build time.

What will follow was how I mixed those suggestions + some good old GoogleSearch-fu + me deep diving on this for ~2 days.

For the project I'm working on, this led to:

  • ~8 mins improvement on the CI where we are doing E2E using Detox
  • ~4 mins improvement on our AppCenter build time for a signed release of the iOS build

DISCLAIMER

First off: this is not a proper blog post. I'm writing it as a gist so that it's clear that it's something quick and dirty. Don't expect it to be perfect.

Second, but actually more important: I'm not fully confident of all these things. What I mean is that quite a few of these changes I've done following my guts over actually being 100% sure that they are in fact correct (I mean yes ofc I've tested the whole thing, and did CI builds and etc, but yeah still not confident). So, please take everything with a massive grain of salt - and don't even think about complaining to me if you follow some of these steps and your build breaks - that's 100% on you.

(actually, if you see anything wrong please let me know!)

I'm using Xcode 11 and React Native 0.61.x at the time of writing.

1. iOS build time

So, I've started my research on improving the build time of this iOS React Native app in the Xcode world: the reason for it is quite simple - I am pretty sure that just as us in the RN world have a billion different ways and approaches to improving our apps, there must be something similar happening over in the Apple garden.

Turns out, my assumption was true (check the sources at the bottom).

My approach boiled down to opening Xcode, generate builds "with Timing Summary" (top menu -> Product -> Perform action -> Builds with Timing Summary) - this because as every perf improvement you always need to benchmark what you want to improve all along the process - and then changing configurations in the Build Settings tab of our project.

Here are some of the "most relevant" changes that I've made:

(if you search for the flag via the Xcode interface it will find it, in the fancy gui of xcode it may have a different name - also, if you enable the right side panel on Xcode when you select the option it will show you the docs for it SO READ THOSE CAREFULLY)

IPHONEOS_DEPLOYMENT_TARGET = 13.0;

I'm working on a new app that will only work on iOS 13 by choice, so no reason not to just target this. Inspired by Radek.

ASSETCATALOG_COMPILER_OPTIMIZATION = time;

Basically, I want to optimize focused on time when I am given a choice. It's likely that it will make the app slightly bigger but I don't mind.

DEBUG_INFORMATION_FORMAT = dwarf;

I've enabled this only on debug as I don't need the dSyms. Inspired by this article

On Release, compiler triple-combo:

GCC_OPTIMIZATION_LEVEL = fast;
GCC_PRECOMPILE_PREFIX_HEADER = YES;
GCC_UNROLL_LOOPS = YES;

Basically, these will make the GCC compiler go faster.

LLVM_LTO = YES;

This is an interesting one (btw, in the gui this is the monolithic option). I mean, look at its doc:

Enabling this setting allows optimization across file boundaries during linking. * No: Disabled. Do not use link-time optimization. * Monolithic Link-Time Optimization: This mode performs monolithic link-time optimization of binaries, combining all executable code into a single unit and running aggressive compiler optimizations. * Incremental Link-Time Optimization: This mode performs partitioned link-time optimization of binaries, inlining between compilation units and running aggressive compiler optimizations on each unit in parallel. This enables fast incremental builds and uses less memory than Monolithic LTO.

Parallelize build

The only other Xcode side change I've done is not in the build settings, but in the "Edit Scheme..." menu. In the build "left side menu" there, you can find an option to "parallelize build" unselected, and I've activated it.

Pods

So, funny story, Pods have a post-install feature. It basically allows you to align the config of each pod to your liking. You just need to add it at the end of your Podfile, like so:

post_install do |installer|
  # Improve the Pods project setting to match with the ones we want to have for the project
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'RCT_ENABLE_INSPECTOR=0'
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'ENABLE_PACKAGER_CONNECTION=0'
    end
  end
end

I've done only a couple of changes to make each Pod be configured more "similarly" to the main project - but it seems that you can really take it to the next level. I mean, look at this!

2. CI build time

On CI there are a couple of other things I've done to improve the timing.

...what if I don't need to cache?

So, this may be controversial/not actionable for you. But basically, we had 2 jobs to do the E2E testing (one to setup, the other to run them) - but looking at the timing of caching (saving & restoring) and because of Detox needing to be cleaned & re-build (post restoring cache), we basically were wasting a huge amount of time.

Merging those two jobs back into one removed the need to cache and other quicks we had to do. It was a really EZ win with a massive impact.

Start Metro bundler separately

It may sound surprising - and I was really surprised to notice a 25 seconds gain from this - but if you have the Metro bundler instance already running before the ios build step, the build is quite faster (if you want to dive deeper into why, check your Xcode project "Build phases" tab, you should see a "Start Packager" step).

You can easily do this on CircleCI via:

      - run:
          name: Start Metro Packager (Background)
          background: true
          command: react-native start

(I've done this similarly in the appcenter-pre-build script)

3. Next steps (that I haven't taken yet)

Other random things that could be done to improve the overall speed:

  • make the bundling faster by having smaller images, maybe using something like ImageOptim
  • add some babel plugins to remove unused code (since we don't have tree shaking like when you use Haul/Webpack) like babel-plugin-transform-remove-console
  • etc etc etc (sorry, I know there are a billion different ones but can't think of them right now)

4. Final Notes

I hope you have found these tips & tricks useful. They have been quite beneficial for me, and I'm basically writing this quick gist to keep memory of them in a way that will help me again in the future.

If you liked this, you may want to follow me over on twitter, early next year I'll basically work on an eBook all about mobile/react native performances and yes, it will be an open source repo. My DMs are open, but in case you are not on twitter but still want to reach out - you actually can now! Write me at notkelset@kelset.dev.

Sources

All the above was inspired, in no particular order, by the responses to the tweet linked at the start (thanks to everyone who wrote there!) and quite a bit of links I've found using my magical google-fu:


If you liked this experiment / have questions / want to add your feedback to this, please leave a comment below!

@rotemmiz
Copy link

rotemmiz commented Dec 3, 2019

The iOS compilation tricks and metro warmup sound like neat tricks, we will try them and report back.

Re Detox, correct me if I'm wrong, but you only refers to your CircleCI build steps, and the need to save and restore caches between steps, right?

  1. Did you try parallel test execution with Detox? It cut down our test suite execution time by at least half.
  2. Did you try playing with Metro worker count? Our calculation showed this might be sub optimal, and when playing with it manually, we gained 50% bundle time decrease, from 180 to 120 seconds.
  3. On a service like CircleCI, where all agent images are preprovisioned and non changeable, it might be a good idea to use xcrun simctl clone and prepare more simulators so Detox won't waste time in creating them while running tests. This also saved us about 1 minute for every worker.
  4. For Android Emulators, we found that Emulator 29.2.1 is necessary to overcome previous performance issues Google had with some of their services, Emulators spent about 60-70 seconds in 200-400% CPU upon fast boot. The new Emulator version solves that and runs much faster.

@kelset
Copy link
Author

kelset commented Dec 4, 2019

👋 @rotemmiz!

Thanks for your questions!

you only refers to your CircleCI build steps, and the need to save and restore caches between steps, right?

yeah - I mean, that was a clear bottleneck in our CircleCI config.

did you try parallel test execution with Detox?

I haven't yet - you are referring to this correct? https://github.com/wix/Detox/blob/master/docs/Guide.ParallelTestExecution.md#parallel-test-execution

Did you try playing with Metro worker count?

never heard of this before! I'll play around with it a bit - thanks for pointing out that option :)

it might be a good idea to use xcrun simctl clone and prepare more simulators so Detox won't waste time in creating them while running tests.

Interesting! At the moment I actually do start our simulator before the detox commands via - run: xcrun simctl boot "iPad Air (3rd generation)" || true

For Android Emulators,

We actually don't use Android for this project - but thanks for sharing!

@kelset
Copy link
Author

kelset commented Dec 10, 2019

other things I want to try:

  • since it's ios 13 only now, I should be able to reduce the architectures I'm targeting to be only the 64 bits ones
  • I want to try and "pre-build" the JS bundle when I spawn Metro (currently I'm only spawning it)

@oblador
Copy link

oblador commented Jan 21, 2020

@kelset In my experience building the final JS bundle instead of relying on a metro process is even faster and more reliable. If you do that you can also cache the binary itself, swap out the JS bundle and re-sign the app bundle and save a ton of time.

@kelset
Copy link
Author

kelset commented Jan 21, 2020

Interesting! How did you set that up Joel? :3

@oblador
Copy link

oblador commented Jan 22, 2020

Basically make a hash of the git tree id of some files and folders that might change the binary, typically ios/android folders and the yarn.lock file. Then we'd check if a binary of that hash exists in our artifact storage (which has a fairly high hit ratio, like maybe 95%) or populate it with a newly compiled one. Then use the react-native cli to create a JS bundle, unpack the app bundle, replace the JS bundle and static assets, zip it again, sign it and off to testing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment