Improving Flutter's iOS build times on CI
CareApp uses CI to test every commit of our mobile app, and build and deploy every merge to master, for iOS and Android. A few weeks ago I felt very seen by this tweet:
A build/deploy time of 2m50s (including infrastructure) makes me antsy as hell. I have no idea how people deal with builds that take double digit minutes and longer
— JT (@jtango18) October 6, 2020
This motivated me to do some digging and find out what we could do to improve our pipeline. What follows is a series of optimisations we made to speed up CI. We use CircleCI, but what follows should apply to any CI service.
The starting point
A merge to master triggers CI to run tests, build our Android and iOS apps, and deploy to TestFlight and Play Store internal testing. We use Fastlane to manage code signing and deployment.
The full pipeline was taking around 30 minutes, as shown below:
The graph shows the following critical path:
- Test (6:33)
- Build iOS (15:08)
- Deploy iOS (8:16)
The android builds are fairly fast and, as we run iOS and Android builds in parallel, don’t contribute to the overall pipeline duration.
Improvement 1: Stop building iOS twice!
If you follow Flutter’s documentation for building iOS on CI, you will end up building the iOS app twice:
On iOS an extra build is required since flutter build builds an .app rather than archiving .ipas for release
So your CI script may look something like this:
# install deps
flutter pub get
# build a non-signed .app
flutter build ios --release --no-codesign --build-number $BUILD_NUMBER
# sign and deploy with fastlane
cd ios
chruby 2.7
gem install bundler:2.1.4
bundle install --path vendor/bundle
# install certificates and profiles into CI's keychain
bundle exec fastlane ios match
# build a signed app
bundle exec fastlane ios build
The issue is that the iOS app will be build twice! Once during flutter build
, and again by Fastlane to get a signed IPA.
This documentation is out of date!
Flutter recently introduced a new argument to build ios
: --config-only
. When run with this parameter, flutter build
configures the XCode project, but does not actually build the .app. Putting that argument in place is trivial:
flutter build ios --release --no-codesign --config-only --build-number $BUILD_NUMBER
The rest of the script above stays the same.
Now, fastlane ios build
is the only time the iOS app is actually compiled, greatly improving build times.
Improvement 2: Caching rubygems for Fastlane
CircleCI, like most CI services, allow you to cache dependencies between jobs. Usually this is project dependencies, but you should also consider caching your deployment tools.
In our case, Fastlane is required for both the build_ios
and deploy_ios
steps. If we could cache Fastlane and its dependencies, we should see an improvement to both build steps.
CircleCI allows you to create several caches, and so we add one for RubyGems:
- restore_cache:
keys:
- gem-cache-v2-{{ arch }}-{{ .Branch }}-{{ checksum "./ios/Gemfile.lock" }}
- gem-cache-v2-{{ arch }}-{{ .Branch }}
- gem-cache-v1
- build_or_deploy
- save_cache:
key: gem-cache-v2-{{ arch }}-{{ .Branch }}-{{ checksum "./ios/Gemfile.lock" }}
paths:
- ./ios/vendor/bundle
As we’ll see, caching RubyGems makes a massive improvement to subsequent build times.
Improvement 3: Persisting generated files between steps
We use json_serializable to automatically generate toJson
and fromJson
methods on some of our data classes and Redux state. The one downside is we find running build_runner
is slow (about 2:00 for us). This is compounded by the fact that the generated files are needed in every CI step.
The generated files are dart files, so they should be the same whether generated on Linux or macOS. Therefore, we can generate these once and safely reuse them in later CI steps.
On CircleCI we use Workspaces like so:
- persist_to_workspace:
root: .
paths:
- ./lib/store/*/*.g.dart
- ./lib/store/*.g.dart
CircleCI has a limitation that it can’t recursively glob files to persist. Therefore, we need to specify all the paths where generated files can be found. Thankfully for us, all our generated files are in a single place in our code.
In subsequent build steps we can restore the workspace. This puts the generated files into place, and our Flutter code will compile.
- attach_workspace:
at: .
12 Minutes Faster
With those three changes in place, we’ve taken around 12 minutes off our full build pipeline:
As you can see, we get a massive speed increase in build and deploy steps for iOS. Caching the generated dart files also improves building for Android. While this wasn’t on the critical path and so doesn’t affect the overall time, it does mean that building for Android uses fewer CI credits, which is also good.
Things we haven’t tried
There is probably more we can do to improve this further, here are some things we haven’t tried.
-
Caching flutter packages. We still run
flutter pub get
in each step that needs it. It may be possible to cache the installed flutter packages, but we have not explored that yet. -
Using larger executors. The times above use
medium
class MacOS executors for building iOS, andmedium
class Docker executors for the test and Android steps. An easy win is to bump those up to large. Without further investigation though, it is unclear how much of a speed boost this would give, since there is still a lot of time waiting on network resources, rather than CPU. Also our non-master CI steps are simpler. Developers get test feedback in around 4 minutes, which is ok.
Thanks for reading! Please share any neat tricks have you found for making Flutter builds faster.