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:

  1. 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.
  2. Passing all Objective-C source files and *-Swift.h files to clang tool, it generating .o files for each Objective-C file.
  3. Passing all .o files to ld 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 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:

  1. Enabling buck project to generate Xcode project files. Our planned workflow involves using Xcode during local development and Buck in CI.
  2. Upgrading Buck to build libraries as modules, so that -import-underlying-module works and our hack can be removed.
  3. Optimizing the Buck cache for iOS. We found some room for improvement in the Buck caching mechanism, and will continue invest in it.
  4. Performing further analysis on how much we gain from switching from Xcode to Buck.

If you have any questions / feedback, feel free to contact us. If you would like to help us work on these challenges, join us!