Building Mixed-Language iOS Project with Buck
At Airbnb, we recognize that developer experience is key to good engineering. Our team specifically — Mobile Developer Infra — has a goal of optimizing our mobile apps’ build times.
In June, we got Buck to successfully build our iOS app. This was a huge milestone for us: until we started working on it, Buck did not support mixed-language iOS projects, and our iOS codebase consisted of a pretty even mix of Swift and Objective-C. With this change, we are seeing 50% faster CI builds and a 30% smaller app size.
Hitting this milestone was a complicated process. In this post, we’d like to share technical details about what kind of challenges we faced, and how are we using Buck in our iOS codebase. Hopefully this is useful to others interested in a similar undertaking.
How an iOS App is Built
When we looked into how exactly Xcode builds an iOS project, we found this awesome post, which explains the process in detail.
In short, the build process includes these steps:
- Passing bridging header files (maintained by the author) and Swift source files to the
swift
tool, generating two kinds of files:
a).o
, machine code files for generating the final executable binary.
b)*-Swift.h
, containing all classes and interfaces defined in Swift code. These can be imported explicitly in Objective-C files that want to use functionality defined in Swift code. - Passing all Objective-C source files and
*-Swift.h
files toclang
tool, it generating.o
files for each Objective-C file. - Passing all
.o
files told
command, which will link all machine code files and generate the final executable.
Differences Between Buck and Xcode
Buck uses roughly the same process outlined above to build iOS projects. However, there is one crucial difference that makes it more challenging to support a mixed-language projects like ours.
Xcode builds each module independently, producing dynamically-linked frameworks. For a particular module M
, the executable binary and related resources/assets end up under App.app/Framework/M.framework
in the final app folder.
Buck treats these modules as static libraries, linking them all together and producing a single executable binary. This approach can effectively reduce the binary size since:
a) If multiple modules are using the same resource/asset, it doesn’t need to copy the same file to each *.framework
folder.
b) It can strip more unused symbols, since all libraries are linked together statically.
This works perfectly in Objective-C-only or Swift-only projects. Unfortunately, this optimization creates problems in mixed-language projects.
Flag -import-underlying-module Does Not Work
The-import-underlying-module
build flag causes implicit imports of Objective-C files into Swift, within the same module. This flag unfortunately doesn’t work with Buck.
When Xcode generates frameworks, it generates module.modulemap
and .hmap
header map files to indicate header locations. The swift
tool later uses these files to import Objective-C headers. However, since Buck doesn’t generate independent frameworks, it doesn’t generate these files. Thus, the -import-underlying-module
flag doesn’t work in the swift
tool.
This means that we have to explicitly pass in bridging headers to the swift
tool. Doing this, however, results in a few more problems.
Unable to Use Bridging Header
Consider this example: A.h
contains the line #import “B.h”
, but B.h
is put under folderB/
. This works perfectly with Xcode with the help of .hmap
. But in Buck, this doesn’t work since it is not able to locate B.h
.
In this PR, we updated Buck to allow it to generate header maps for use by the swift
tool, allowing the tool to locate header files and import them.
Unable to Locate Bridging Header Inside *-Swift.h
When the swift
tool generates *-Swift.h
files, it explicitly imports bridging headers for Objective-C definitions. This breaks Buck builds.
According to Apple’s code, when using the-import-underlying-module
flag, the generated *-Swift.h
files import project headers. For example:
When providing bridging headers explicitly however (as we need to do with Buck) the generated *-Swift.h
files end up importing bridging header files directly:
As you can see, the imported path is a relative one. When another file imports this *-Swift.h
file, it won't be able to locate the bridging header.
In this commit, we updated Buck to pass -iquote buckRootPath
as a compiler argument. That specifically tells the swift
tool to look for the bridging header files at buckRootPath
.
@import
Does Not Work
There are two ways to import header files into Objective-C, #import
and @import
. @import
doesn’t work in Buck since Buck doesn’t generate module.modulemap
.
This actually requires us to replace @import M
with #import <M/M.h>
and/or #import <M/M-Swift.h>
. This is simple enough for our own source code. However, it's a bit trickier for generated code. *-Swift.h
files, for instance, always use @import
.
To address this, we used an admittedly hacky solution, introducing this script to perform the replacement on the fly. In this commit, we added to Buck’s apple_library
build rule a new objc_header_transform_script
parameter, which allowed us to invoke the replacement script on all the *-Swift.h
files.
This change knocked down our last big blocker for using Buck.
BUCK Sample
To help illustrate the work we’ve done, we created this sample project, feel free to clone it and test it out for yourself!
Building a Mixed-Language Library
As mentioned before, when building a mixed-language library, we need to pass the bridging header file into theapple_library
build rule.
Building CocoaPods
We treat each pod as an individual library, using a different build rule for each one.
In most cases, the pod includes its source code, so we can simply use apple_library
to build it.
When a pod only provides a compiled binary (e.g. libSample.a
), we use the prebuilt_cxx_library
build rule.
When a pod provides a framework file, we use the prebuilt_apple_framework
build rule. More examples of this build rule can be found here.
What’s Next?
There are still some challenges ahead of us:
- Enabling
buck project
to generate Xcode project files. Our planned workflow involves using Xcode during local development and Buck in CI. - Upgrading Buck to build libraries as modules, so that
-import-underlying-module
works and our hack can be removed. - Optimizing the Buck cache for iOS. We found some room for improvement in the Buck caching mechanism, and will continue invest in it.
- Performing further analysis on how much we gain from switching from Xcode to Buck.