How to build an iOS framework for distribution with Bazel

Tech
Dan Bodnar
By
Dan Bodnar
|
20 Jul 2023

As developers, we often face the need to modularize our work, share code between projects or teams, create standards, and speed up project compilation times. One way to achieve these steps is to create frameworks. This is an entire paradigm so there’s no reason to focus on the general approach, but rather on how to create iOS frameworks ready for distribution with Bazel.

Table of contents

What to know about Bazel

In just a few words, Bazel is a multi-language, extensible build system described as fast and correct. Correct because of its “incremental and parallel execution”. Fast because it only (re)builds what is needed thanks to “advanced caching” and “optimized dependency analysis”. Additionally, it's independent because it does not rely on any other build system. Specifically for iOS development, Bazel does not rely on Xcode or xcodebuild. In fact, it's actually showcased as an alternative for Xcode. However, Xcode is still needed for some other developer tools and compilers, so don’t delete it just yet.

Bazel is friendly because its configuration files are easy to read and they make perfect sense. Even for big projects, it’s really easy to understand how the project works, what the dependencies are, and what to expect from the command you are executing.

Bazel is flexible. It can build C/C++, Objective-C, Swift, Java, Python, and a wide variety of other languages.

Bazel is also very modular. You can declare rules that can be easily shared between packages.

It’s the perfect way to share C++ code between platforms. At Baracoda we use Bazel instead of CMake to share C++ libraries between iOS and Android teams.

Finally, Bazel is also useful because you’ll need it to integrate Mediapipe in your application.

Why you should choose Bazel for iOS development

Bazel as a project solution is great and comes with many advantages, but like any solution, it won't work for everyone, so let's look at some ways you could use it. When starting a new project, you should consider using a solution in Bazel rather than one in Xcode (I won't go into detail here on the reasons why). However, it’s not always easy or desirable to migrate a mature Xcode project to Bazel for various reasons.

At Baracoda we are using Bazel in many of our projects. We learned to adapt, and we often use Bazel to generate frameworks that are used in Xcode projects. From our experience, it is more challenging to generate the correct frameworks for Xcode projects than to have the entire solution in Bazel. In this article, I will explain how to create distributable frameworks with Bazel, a framework that can also be used in other Xcode projects.

The generated framework is a wrapper over a simple C++ struct. It will contain a basic Objective-C wrapper over it, but it will not expose any C++ data types. The framework can be used in any Swift iOS application and it will build on the device or simulator. I will go step by step, from installing Bazel to generating frameworks.

To make the process easier, you can download the example project and check each step in detail. You can also execute all of the commands described in the following project: GitHub — Bodnar-Dan/bazel-poc

In the example project, I use Xcode 13.1, but there’s no version limitation per se. You just need to consider which version you need for your project. Keep in mind that some of the Swift frameworks don’t have the Module Stability enabled so you might need to build them with the Swift version of your app.

How to use Bazel to build an iOS framework

1. First step: Install Bazel

Bazel 5.0.0 (released in January 2022) is the first version that introduced support for iPhone Simulator on arm64 Macs (ios_sim_arm64 configuration). With all the versions below, there was no official way to generate a framework buildable for iPhone Simulator on an Apple Silicon Mac. This was challenging because Silicon computers are growing in popularity and there are many developers that own one.

If you are constrained to Bazel 4, the missing support for Silicon was highly discussed on different Github pages . In the end, we need to patch Bazel to add this new configuration. We created our own Bazel patch for version 4.1.0 and have been using it successfully. Please refer to these links: (Bazel 4.1.0 Patch + Bazel installation script). If you are using the patched version, all the commands below will need to run with temp/bin/bazel build.

If you are just installing Bazel, I suggest you install the latest version using Bazelisk (GitHub — bazelbuild/bazelisk: A user-friendly launcher for Bazel).

2. Setup Workspace

For us to be able to build anything for iOS, we’ll at least need the following dependencies for our project:

Swift Rules and Apple Rules define almost all the rules you’ll ever need to use Bazel for iOS development. But there’s one other dependency that you might want to keep track of: iOS Rules (https://github.com/bazel-ios/rules_ios). We are actually using this to export the Swift Framework below.

Feel free to use any IDE or text editor for Bazel configuration files. As a suggestion, I recommend CLion. It has a Bazel plugin that makes your life a lot easier.

You import and load dependencies in the WORKSPACE file from the project root folder. One interesting fact is that we can patch any dependency based on our needs when we load them.

1BAZEL_RULES_APPLE = "0.32.0"
2http_archive(
3    name = "build_bazel_rules_apple",
4    sha256 = "77e8bf6fda706f420a55874ae6ee4df0c9d95da6c7838228b26910fc82eea5a2",
5    url = "<https://github.com/bazelbuild/rules_apple/releases/download/{0}/rules_apple.{0}.tar.gz".format(BAZEL_RULES_APPLE),>
6)
7load(
8    "@build_bazel_rules_apple//apple:repositories.bzl",
9    "apple_rules_dependencies",
10)
11apple_rules_dependencies()
Copy to clipboard

3. Bazel configuration

A Bazel command accepts a lot of options. Imagine that Bazel needs to build several technologies and different architectures. We’ll have to pass arguments to define exactly what we want to build for each of them. To avoid having long and complicated commands, Bazel offers a configuration file (.bazelrc) on the project root, where we can define all this options for different needs.

We created a general configuration that fits our iOS need. The ios config defines the platform, we want bitcode enabled for all of the generated iOS frameworks and we define the C++ version (also used by Mediapipe, for example).

1build:ios --apple_platform_type=ios
2build:ios --apple_bitcode=embedded 
3build:ios --copt=-fembed-bitcode
4build:ios --cxxopt=-std=c++17 # enables c++ 17
Copy to clipboard

We then extended this to define the configuration for iPhone Simulator on x86_64 and arm64 Macs.

1build:ios_simulator --config=ios 
2build:ios_simulator --ios_multi_cpus=sim_arm64,x86_64
Copy to clipboard

We then do the same thing for the device.

1build:ios_device --config=ios
2build:ios_device --cpu=ios_arm64
Copy to clipboard

When executing a Bazel command, all we’ll need is to add one of the configurations above and that’s it. We also need to provide the package and the rule we want to execute:

bazel build --config=ios_simulator [PACKAGE:RULE]

4. Set up our package and rules, then generate the first framework

A package in a Bazel project is a folder that contains a file named BUILD (or BUILD.bazel). Creating a new folder that will contain our classes and the BUILD file is the starting point. The BUILD file it’s a configuration file that uses Starlark language (GitHub — bazelbuild/starlark: Starlark Language ).

Our framework will internally use a C struct (just for the sake of showcasing how it works), but we don’t really want to expose that to the app. Instead, we want to create a wrapper to go over it. The wrapper will only expose the struct values in Objective-C compatible types.

Since we use C data types to pass data to and from our C simple implementation, we can’t achieve that in Swift. We need an Objective-C++ class for our wrapper. We will go ahead and create another Swift layer on top of that later (this would be helpful for us to expose known data types, that may come from a Protobuf message, for example). In our case, we have a class called DataController . This is a really simple class that instantiates the C struct, nothing more. Keep in mind, the code in this class is just an example, so there's nothing fancy about it.

Let’s start by adding our first Bazel rule in the BUILD file. We need to create an obj_library rule that exposes the DataController.h interface and loads any dependency. We set the visibility to ios package because we don't really want other packages to use it.

1objc_library(
2    name = "DataCore",
3    srcs = [
4        "DataController.mm"
5    ],
6    hdrs = [
7        "DataController.h"
8    ],
9    module_name = "DataCore",
10    visibility = ["//:ios"],
11)
Copy to clipboard

The above rule creates an Objective-C library that can be further used in other Bazel rules. With the right configuration, we could use the output of this rule in an Xcode project, but it would be more complicated to distribute it (we need more than one architecture and we’ll need to expose the interface and the graph resources separately). What we care for is a *.framework or even better, an *.xcframework, but there are several other steps needed in order to achieve that. First, we’ll need to generate a *.framework for each architecture.

Apple Rules proposes to use ios_framework rule to generate a dynamic framework ready for distribution.

1ios_framework(
2    name = "DataCore",
3    bundle_id = "com.baracoda.data-core",
4    hdrs = [
5        "//ios/objc:Headers",
6    ],
7    families = [
8        "iphone",
9        "ipad",
10    ],
11    infoplists = ["Info.plist"],
12    minimum_os_version = "13.0",
13    visibility = ["//visibility:public"],
14    deps = [
15        "//ios/objc:DataCore",
16    ],
17)
Copy to clipboard

If we go ahead and run bazel build --config=ios_simulator ios:DataCore we'll end up having a real framework ready for distribution. Go to the `bazel-bin` folder at the root of the project and you’ll see the generated framework there.

However, there are two problems with this framework:

  1. The app that embeds it will build only on iPhone Simulator (because of the configuration we’re using), and
  2. It can only be used in an Objective C application (because there’s no Swift module defined).

To fix the first point, all we need is to execute the bazel command for the device configuration bazel build --config=ios_device ios:DataCore. We can now create an *.xcframework using the two frameworks.

If you need to use your Bazel framework in an Objective-C application, you can stop here. The next part is needed only if you need to use the Bazel-generated framework in a Swift application.

To fix the second point, we will need to go a bit deeper and modify the content of the generated framework. First, we’ll need to create a module.modulemap file that creates the Swift Module and exposes the Objective-C interface and the framework interface. We’ll add the module.modulemap file as a resource for now (we’ll need to handle that a bit differently soon) and the interface in the hdrs. See the project example for more details on their content.

1ios_framework(
2    name = "DataCore",
3    bundle_id = "com.baracoda.data-core",
4    hdrs = [
5        "//ios/objc:Headers",
6        "headers/DataCore.h",
7    ],
8    families = [
9        "iphone",
10        "ipad",
11    ],
12    resources = [
13        "modulemap/module.modulemap"
14    ],
15    infoplists = ["Info.plist"],
16    minimum_os_version = MIN_IOS_VERSION,
17    visibility = ["//visibility:public"],
18    deps = [
19        "//ios/objc:DataCore",
20    ],
21)
Copy to clipboard

We next need to run a small script to patch the generated framework, because the path of the module.modulemap is not correct.

We will call the script from a genrule rule, so instead of executing the DataCore rule, we'll execute the PatchedDataCore rule: bazel build --config=ios_simulator ios:PatchedDataCore and bazel build --config=ios_device ios:PatchedDataCore and create the *.xcframework out of them.

1genrule(
2    name = "PatchedDataCore",
3    srcs = [":DataCore"],
4    outs = [
5        "DataCore.framework",
6        "DataCore.framework.dSYM",
7    ],
8    output_to_bindir = 1,
9    tools = ["patch_ios_framework.sh"],
10    cmd = """
11        bash $(location patch_ios_framework.sh) "$(SRCS)" "$(OUTS)"
12    """
13)
Copy to clipboard

At this point, we have an entire usable framework in any Swift application and this should be enough for almost all cases. The only problem is that we expose the input and the output in an Objective-C data type. What if we want to expose Swift struct types instead of objects (potentially from a Protobuf message)?

5. Create a Swift framework

To expose Swift structs, we’ll need to create yet another layer over the Objective-C wrapper. We can easily change the visibility of PatchedDataCore to private in the BUILD folder because that will no longer be exposed. Instead, we created a Swift object that exposes the same methods, but instead of NSDictionary, the output is a Swift struct type.

We need to create a swift_library from Swift Rules to create our new Swift Module. This is private because the output of this rule is not really meant to be used outside Bazel's scope, but as you can see this has a dependency set to //ios/objc:DataCore and not the distributed framework //ios:DataCore.

1swift_library(
2    name = "DataProvider",
3    srcs = [
4        "DataProvider.swift",
5    ],
6    module_name = "DataProvider",
7    visibility = ["//:ios"],
8    deps = [
9        "//ios/objc:DataCore"
10    ]
11)
Copy to clipboard

If you take a look at the example project, in the Swift file we import DataCore. Basically, Bazel knows how to create this module internally and it will build successfully when `bazel build --config=ios_simulator ios/swift:DataProvider`. In our Swift class, we can make use of the DataController which is exposed by the objc_library.

In general, a Swift framework can define only one Swift module. Also, a swift-library rule creates one module. So keeping this in mind, when generating a Swift framework with ios_dynamic_framework or any other distribution rule, that rule can depend on one and only one swift-library.

Considering the above, our framework can not depend on //ios/objc:DataCore but only on //ios/swift:DataProvider. That’s because Bazel creates a Swift module for //ios/objc:DataCore, as seen above when we import DataCore in our Swift file. But we need DataCore to build our framework, so we add it in the transitive_deps which is not linked with our resulting framework.

So our rule can look like this. One thing to mention is that I’m using Rules iOS to generate this, that’s because Rules Apple rules do not have the transitive_deps option.

1apple_framework_packaging(
2    name = "DataProviderFramework",
3    framework_name = "DataProvider",
4    visibility = ["//visibility:public"],
5    transitive_deps = [
6        "//ios/objc:DataCore",
7    ],
8    deps = [
9        "//ios/swift:DataProvider",
10    ],
11)
Copy to clipboard

Calling bazel build --config=ios_simulator ios:DataProviderFramework will generate the final framework that can be used in a Swift application.

Keep in mind, you will need to import both frameworks in your app: DataCore and DataProvider. DataCore is a dynamic framework (we defined it like that with the rule we used), so you will need to Embed and Sign it, while DataProvider is a static one.

What else should you know about Bazel?

Bazel and its rules are evolving fast and the community around it is growing every day. Don’t hesitate to keep an eye on updates! Each new update on Bazel and on its rules is making the integration even easier.

About *.xcframework

Currently, while I am writing this article, there is no way to generate an *.xcframework with Bazel. However, let's keep our eyes open for:

Producing .xcframeworks · Issue #1249 · bazelbuild/rules_apple

We are currently using xcodebuild -create-xcframework to generate our *.xcframeworks. However, there’s a catch. This works flawlessly for a framework like DataCore, because that’s not a Swift framework. Also, it works ok for ios_static_framework rules from Apple Rules. But it doesn’t work for the ios_dynamic_framework rule or for Rules iOS apple_framework rule just yet, because the frameworks are generated with a Swift Module and not a Swift Interface. Basically, there is no way to Build Libraries for Distribution (BUILD_LIBRARY_FOR_DISTRIBUTION=1).

About framework optimization flags

You can lower the framework size on disk by adding some optimization flags. For example, bazel build --config=ios_simulator --copt=-O3 --cxxopt=-O3 ios:PatchedDataCore reduces the size to 10-times smaller.

No bazel-bin, bazel-out, etc. symlinks (aliases) on the project root

In some versions of Bazel you’ll need to explicitly specify the option: --use_top_level_targets_for_symlinks. You can either add it to all your build commands or add it in .bazelrc configuration.

Example Bazel project

You can check out my example Bazel project.

This article and the project example can be used as starting points for integrating Mediapipe into your project.