Think, which is the most repetitive task for an iOS developer? To me, to compile and test. This process happens every time a developer press the build button, and the longer code base, the longer it takes to compile.
Apple released on WWDC18, along with Xcode 10, a new build system, which involved a huge improvement, optimizing the build time with incremental and parallel compilations. However, for some companies, it is not enough.
The most common way to improve our compile times¹ is by tweaking the project’s configuration, but you can also improve them by changing the build system.
What I’m going to try to explain is how, in my last project, by changing the build system I was able to reduce the build times, reduced the complexity of project configuration. As a result, we realized that our productivity while developing increased.
DRY. This principle tries to state that you should reuse your code whenever you can. µFeatures uses this approach to design a layered application based on frameworks. Using these guidelines is a good starting point. However, as we add more and more modules to the Project, it will start to be harder to maintain. In this situation, Xcode doesn’t scale very well. Having said that, there are tools called Dependency managers, that can help managing the project’s configuration.
- Cocoapods is one of the first and most used dependency managers. You can integrate third party librares (pods) but also local pods. This definition is detailed in the
podspecfile that is written in Ruby. It downloads all source files from remote dependencies and greatly increases the compilation time.
- Carthage is cleaner but limited. It generates a fat framework to be linked it to our project. Usually, these projects have a dynamic link, so we must copy them into the final binary we generate, this will increase the launch time. Dependencies are resolved with git tags, thus preventing the creation of a monorepo project.
- XcodeGen this tool auto-generates an Xcode project from a single
JSONfile. It can be combined with Carthage and/or CocoaPods. It is a flexible tool, but for large projects could be hard to maintain as it is based in one config file.
- tuist similar approach than XcodeGen but uses swift files for configure the project. It also allows to split the configuration file in different files in the Project folders. This makes it much easier to maintain. It offers a swift beautiful interface, and a lot of integrations with other dependency managers. This tool is a safe, native way to extend Xcode at scale, but still uses Xcode as a build system.
Bazel Build System
First of all, Bazel does not replace the compiler, it is a build system. To use it you will have to install Xcode, as Bazel will need all the developer tools such as compilers, linkers, system frameworks embedded in Xcode toolchains.
This build system allows you to automate tasks, compile and execute tests in a deterministic environment. It is based on a set of rules that define a series of actions, in order to produce results. In the
WORKSPACE file is where all these rules will be imported. This is an example of how it would look like for an iOS project:
The rules are imported from repositories or http files. In those, you can also find all the versions and check the documentation. For example, these are the rules I used in my project: rules_apple, rules_swift, and apple_support.
You can create and share custom rules that can be imported from your git repository or downloaded as a zip file. These rules are written in a language called Starlark. This language is intended as a configuration language. Starlark is a Python’s dialect, which makes it easier to adopt. It also offers features such as: Deterministic evaluation, Hermetic execution, Parallel evaluation and it is based on simplicity.
Now that we have our
WORKSPACE configured, we have to indicate Bazel that we are going to use the iOS platform. To do so, we have to add the option
--apple_platform_type=ios to the build command.
Every time you want to build an iOS target, this option needs to be indicated in the build command. To avoid this, you could specify it in a
.bazelrc file were all the default options will be described. In this file I usually specify two different configurations, one for Debug and another one for Release.
The next step would be writing the
BUILD files, which will define the project configuration. These will be our single source of truth. You could split the project in modules. If you do so, you can use one
BUILD file for the whole project or several
BUILD files, one for each module.
As my project is divided in several modules, I chose the second option because it is easier to maintain as the
BUILD files are smaller. Since all dependencies are explicit in BUILD files, Bazel has a directed acyclic graph of all the source files and build targets. It is constructed from the leaves up to the root.
Another thing that I have done in the
BUILD files is to specify different flavours for each environment. This allows me to, for example, use a different AppIcon for Staging or Production, avoiding having to us compilation flags per flavour. However, you should be careful with this approach, as you will need to maintain as many files as flavours you have. It is also interesting to use it if you need to support different platforms.
In the next snippet, you can see an example of a very tiny app where the source code is in the Sources folder and where the
Info.plist file is in the root directory.
Firstly we tell the Starlark interpreter what functions needs to load, as those will be use later in the BUILD file.
Secondly, we can see two functions that define how to build an app. On the one hand, we create a static library called AppLib with all source code files and resources. On the other hand, we create the bundle that is going to encapsulate the library and the necessary files to create the ipa. These definitions tell us how the build system works.
Now let’s generate some unit tests to our AppLib. In Xcode, a unit test is a new target, and it is also necessary to link the library we are going to test. This definition will be very repetitive and we can create a custom rule
As you can see, create a test bundle is very similar to an application. It is necessary to create an independent library with the source code of the tests and link it to the source code’s library that we are going to test. Now we are going to use the new rule in the
We could produce the targets with the following commands:
bazel build :App and
bazel test :AppTests. These commands can only be invoked from where our
WORKSPACE file is located. The execution of these commands will generate a structure for our work path with symbolic links. Inside these folders has the artifacts from the execution of these commands, like DerivedData on Xcode.
When we execute the build or test commands on our terminal, Bazel generates a signature (hash) for our source files. These will be checked on future executions to see if any file is different. If our module has no differences, Bazel uses a cached version. To make the most out of Bazel, it is very important to separate your project in small modules instead of having a monolith.
From the moment Bazel has a reproducible task, we will be able to cache the output of these tasks. This is another key point. We can share intermediate build results between teams on a remote server. Bazel offers different technologies to share that cache: Nginx, Docker image and Google Cloud Storage. This option can drastically reduce build and test times between CI and local development.
With Tulsi you can generate a project for Xcode from the
BUILD files. It is pretty flexible, and you can create the whole project with some or all the targets. When you generate a project with Tulsi, in the settings build phases tab you won’t find the Compile Sources, Link Binary with Libraries and Copy Bundle Resources sections. Instead, there is a script that runs Bazel as a build system. So, you’ll get the same consistency running from the terminal as from Xcode.
Another key point is that we can ignore the
xcworkspace from our remote version control. This eliminates many problems when merging changes from other branches between devs.
There is another way to generate a project from
BUILD files with Xcode as a build system. XCHammer generates a
YAML file with the definition of the whole project, and with XcodeGen we can generate the project with Xcode as a build system.
In addition, the Pinterest team has developed a tool to translate
podspec files into
BUILD definitions. PodToBUILD is one of the easiest ways to port a project to Bazel with one of the tools most used by the community. These tools are supported by the community and in constant development.
The Bazel team has also developed an extension for vscode that allows
.bzl files to be formatted and lint. It allows us to navigate between the targets of the project and launch the build or test tasks from the editor itself.
Bazel is an excellent tool that allows us to move fast without compromising the quality.
On the one hand, we no longer have the “it works on my machine” issues between devs or CI because all will have a sandboxed development environment. We can rely on Bazel to detect that a module needs to be rebuilt. Moreover, it will not redo a clean build as there will be cached modules. On the other hand, in small projects, it would introduce unnecessary overhead.
Bazel has very active development, but as a third-party tool, we run the risk that Apple breaks it with future Xcode or Swift updates. Also, if different developers are using different versions of Bazel, you could not benefit from their remote cached modules. To avoid that, it will be necessary to create a wrapper to ensure that everyone is using the same version.
There are many tweaks to improve build times and to have an optimal project configuration with other tools. But in my opinion, in the long run, investing time in alternative build systems will pay off. I do not think there are good or bad tools, simply there are tools more suitable for your needs than others.
You can see an example of a tiny application built with Bazel on GitHub.