Build mobile apps with Bazel. Part 2: iOS
Written on July 20 2018.
This blog post was written a long time ago and may not reflect my current opinion or might be technically out of date. Read with a grain of salt.
tl;dr; How to build iOS apps with external dependencies with Bazel and Tulsi.
After building an Android app and its dependencies with Bazel in the first post of the series, we continue our journey by adding an iOS app to our monorepo. Read the first post if you want to have more context about what we are trying to achieve and why we use a monorepo and Bazel to build mobile apps.
In this second post, we will see how we can express internal and external dependencies, how to organise the monorepo directories and how to run the app with XCode.
Directory structure
We will mirror the code organization we had for the android app:
- AppA-iOS
- Lib-iOS
Those two directories sit right next to their android counterpart.
Current situation overview
Bazel has built-in support for Apple
platforms.
Almost all iOS developers use XCode to build their apps. The tricky part is that
XCode is not extensible and very rigid in the way it manages a project. All the
configuration is stored in an xcodeproj
file. Multiple projects can be
combined in an xcworkspace
. The chance of Apple supporting Bazel is close to
zero and that won't change any time soon. That is why
Tulsi is born. It will generate xcodeproj
files
based on the Bazel description of a build.
A popular tool in the iOS world is CocoaPods:
CocoaPods is a dependency manager for Swift and Objective-C Cocoa projects. It has over 48 thousand libraries and is used in over 3 million apps. CocoaPods can help you scale your projects elegantly.
Almost all the libraries targeting iOS are available as a Pod. Converting a
Podfile
to a BUILD
file
feels a bit clunky but is usually trivial if the dependency list is short.
If the pod is written in Objective-C, it is possible to directly declare the pod dependency with PodToBUILD without any manual process.
AppA-iOS
The official documentation has a tutorial to create an iOS project. It is maybe a bit outdated so it might be better to directly read the git repo of the apple rules.
We first import the Bazel rules in our WORKSPACE
file:
git_repository(
name = "build_bazel_rules_apple",
remote = "https://github.com/bazelbuild/rules_apple.git",
tag = "0.6.0",
)
load(
"@build_bazel_rules_apple//apple:repositories.bzl",
"apple_rules_dependencies",
)
apple_rules_dependencies()
Nothing really fancy here, we add the Apple rules repo and load them. We are now ready to create the app:
$ mkdir AppA-iOS
$ touch AppA-iOS/BUILD
AppA-iOS/BUILD
:
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
swift_library(
name = "Sources",
srcs = glob(["src/**"]),
resources = [
"Resources/Main.storyboard",
],
)
ios_application(
name = "AppA-iOS",
bundle_id = "be.tulipemoutarde.appa",
families = ["iphone"],
infoplists = [":Info.plist"],
visibility = ["//visibility:public"],
deps = [":Sources"],
)
An interesting pattern here is that the ios_application
rules does not
reference the source code. Instead, all our code is bundled into a swift library
on which the app depends.
We create a plist.info
, AppDelegate.swift
and Main.storyboard
. They don't
do much but we want to have a buildable app as soon as possible:
AppA-iOS/src/AppDelegate.swift
:
import UIKit
@UIApplicationMain
class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions: [UIApplicationLaunchOptionsKey : Any]?) -> Bool {
print("App has started")
return true
}
}
AppA-iOS/Info.plist
(nothing interesting in here, don't write this by hand,
get it from an existing project):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
AppA-iOS/Resources/Main.storyboard
(nothing interesting in here, don't write
this by hand, use XCode to generate/edit the Storyboard):
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="11542" systemVersion="16D32" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11524"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Hello from Bazel" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Z9g-BR-NKm">
<rect key="frame" x="143" y="323" width="89" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="Z9g-BR-NKm" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="b9H-lm-x4b"/>
<constraint firstItem="Z9g-BR-NKm" firstAttribute="centerY" secondItem="8bC-Xf-vdC" secondAttribute="centerY" id="uTp-aY-7lJ"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
To recap, this is what our directory structure looks like:
$ tree -L 7 AppA-iOS/
AppA-iOS/
├── BUILD
├── Info.plist
├── Resources
│ └── Main.storyboard
└── src
└── AppDelegate.swift
We can now check that all the dots are connected together:
$ bazel build //AppA-iOS
If all goes well, you should have the bazel-bin/AppA-iOS/AppA-iOS.ipa
file.
This is the app bundle. You must sign it before you can send it to a device.
This is where the experience starts to become cumbersome: we will generate an
XCode project to launch the app.
Using XCode with Tulsi
Time to install Tulsi, a tool made by the bazel team to generate XCode projects from Bazel definitions. There are no pre-built binaries, you must compile it yourself. It's pretty straightforward and it should build cleanly without any modifications.
Tulsi will generate a .tulsiproj
directory containing all your configuration.
I placed the tulsiproj file next to the WORKSPACE
file and let it in git. Add
/tulsigen-*
and /tulsi-*
to the root .gitignore
(like the /bazel-*
symlinks).
It seems that having a single tulsiproj file for all your monorepo is the way to go. You can add all your bazel packages and config into it. I haven't used it in a real project so this might be wrong. Only experience will tell.
Beware that XCode might not behave like you are used to. The BUILD
file is the
single source of truth for building. Do not trust what you see in XCode.
Compilation flags should be set in Bazel, not XCode. Adding a file in XCode will
not include it in your build. For example, if you add a file to the sources,
you must regenerate the xcodeproj if you want to see it in XCode.
xcodeproj are just a transient container to use Apple tools on our code. I've
decided not to version them (*.xcodeproj/
in .gitignore
). I don't know yet
if it is the best thing to do, again only experience will tell.
Let's recap our directory layout:
{{<highlight shell "hl_lines=6-7 12">}} [EDITED FOR CLARITY] $ tree -L 7 . . ├── App-A-Android ├── AppA-iOS │ ├── AppA.xcodeproj │ │ ├── ... Xcode proj junk │ ├── BUILD │ ├── Info.plist │ ├── Resources │ └── src ├── BazelApps.tulsiproj │ ├── Configs │ │ └── AppA.tulsigen │ ├── fstephany.tulsiconf-user │ └── project.tulsiconf ├── Lib-Android ├── README.md ├── WORKSPACE {{}}
Internal Swift library
Pure Swift code can run on both macOS and Linux. Bazel can handle both and will
happily build Swift code even when it is not tied to iOS/macOS. The bazel team
is reworking to unify Swift rules under a new repo:
rules_swift
. It is a work in progress and unfortunately, we can't use
rules_appleand
rules_swift` in the
same dependency tree yet (as of
0.6.0 and
0.2.0
respectively).
So we'll us the swift_library
of the rules_apple
instead of the one in
rules_swift
but that might change in a near future.
We create a Lib-Swift
directory and create the BUILD
file:
load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
swift_library(
name = "Lib_Swift",
srcs = glob(["src/**"]),
visibility = ["//visibility:public"],
module_name = "AppLib",
)
And here we hit a annoyance: the name of the artifact does not match the name of
the directory. This is because swift target names are limited to [a-zA-Z0-9_]
.
The module_name
param is important as it will be the name of the generated
Swift module (i.e., the one you will import).
We add dummy swift code to our library:
Lib-Swift/src/StringProvider.swift
:
public class StringProvider {
let baseString: String
public init(baseString: String) {
self.baseString = baseString
}
public func provideString() -> String {
return "\(baseString) was the base string"
}
}
And check that it builds:
$ bazel build //Lib-Swift:Lib_Swift
Time to use this new library in our main app:
AppA-iOS/BUILD
:
{{<highlight python "hl_lines=10">}}
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
swift_library( name = "Sources", srcs = glob(["src/**"]), resources = [ "Resources/Main.storyboard", ], deps = ["//Lib-Swift:Lib_Swift"] )
ios_application( name = "AppA-iOS", bundle_id = "be.tulipemoutarde.appa", families = ["iphone"], infoplists = [":Info.plist"], visibility = ["//visibility:public"], deps = [":Sources"], ) {{}}
AppA-iOS/src/AppDelegate.swift
:
{{<highlight swift "hl_lines=2 12-13">}}
import UIKit
import AppLib
@UIApplicationMain class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions: [UIApplicationLaunchOptionsKey : Any]?) -> Bool {
print("App has started")
let provider = StringProvider(baseString: "Hello")
print("\(provider.provideString())")
return true
}
} {{}}
We can build the app again to check that everything is OK:
$ bazel build //AppA-iOS
When opening the XCode project, it will build and run fine but won't show the
source code of the AppLib
module. We need to regenerate the xcodeproj file:
- First add the package to the project.
- Add it to the config and add a target
- Regenerate the XCode project and open it
Xcode now knows everything about the code and we can navigate easily in the app and lib code.
External CocoaPod library
Many iOS apps load external libraries for common tasks. Carthage and CocoaPods are the most common tools to handle those dependencies. As an example, we will import the RxSwift and RxCocoa libraries into our project.
Manual Pod conversion
The official doc for converting CocoaPods depedencies to Bazel is a bit disappointing. The process is tedious:
- manually examine the Podfile and Podspecs;
- download each Podspec (beware of version numbers!) and create a BUILD for each
- Modify the main BUILD file to express the dependency.
Don't worry if you find these instructions confusing, we'll see how this translates to RxSwift/RxCocoa.
If you don't want to deal with a manual process and work with Objective-C pods, skip to the PodToBUILD section.
RxSwift
We see in the RxSwift Podspec that it does not have any external depencies. This will make our task easier.
We will start by adding the RxSwift code as an http archive in our WORKSPACE
:
new_http_archive(
name = "Rx",
url = "https://github.com/ReactiveX/RxSwift/archive/4.2.0.tar.gz",
strip_prefix = "RxSwift-4.2.0",
sha256 = "d8474e9733075e7164732b25284c263d0b16e9c9a18393de932bd8ddded73360",
build_file = "Pods/RxSwift/BUILD")
The archive path was retrieved from the release page of RxSwift on Github. The SHA signature is not mandatory but it's a first verification step to ensure that you are grabbing the right archive. To be sure to have reproducible builds, you might want to store the archive file on a location you control.
An obvious downside of hardcoding the version is that we lose the ability to update the external dependencies automatically with the build tool. Some will see this vendoring (i.e., the act of copying an external dependency into the source tree. Completely bypassing the original location of this dependency) as an advantage, some as an evil thing. Choose your side but be pragmatic about it.
The strip_prefix
argument is handy because the downloaded archive has a top
level directory RxSwift-4.2.0
containing everything. As our BUILD file
contains relative paths to the archive content, strip_prefix
will save us a
few key strokes.
Finally, build_file
contains the path containing the BUILD file we'll use to
build stuff from this archive.
Pods/RxSwift/BUILD
:
load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
swift_library(
name = "RxSwift",
module_name = "RxSwift",
srcs = glob(
["Platform/**/*.swift", "RxSwift/**/*.swift"],
exclude= ["RxSwift/Platform/**/*.swift"]
),
visibility = ["//visibility:public"],
)
The srcs
directories are a copy-paste from the RxSwift podspec
`.
As the Rx
entity is an external one, we must refer to it with a @
(we saw
that in the first
post).
$ bazel build @Rx//:RxSwift
Add this dependency to the main app:
AppA-iOS/BUILD
:
{{<highlight python "hl_lines=12">}}
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
swift_library( name = "Sources", srcs = glob(["src/**"]), resources = [ "Resources/Main.storyboard", ], deps = [ "//Lib-Swift:Lib_Swift", "@Rx//:RxSwift" ] )
ios_application( name = "AppA-iOS", bundle_id = "be.tulipemoutarde.appa", families = ["iphone"], infoplists = [":Info.plist"], visibility = ["//visibility:public"], deps = [":Sources"], ) {{}}
AppA-iOS/src/AppDelegate.swift
:
{{<highlight python "hl_lines=3 8 17-23">}}
import UIKit
import AppLib
import RxSwift
@UIApplicationMain class AppDelegate: NSObject, UIApplicationDelegate {
let disposeBag = DisposeBag()
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions: [UIApplicationLaunchOptionsKey : Any]?) -> Bool {
print("App has started")
let provider = StringProvider(baseString: "Hello")
print("\(provider.provideString())")
Observable
.just(1)
.subscribe(
onNext: { print("Emitted \($0)")},
onCompleted: { print("Stream has completed") }
)
.disposed(by: disposeBag)
return true
}
} {{}}
And that's about it, RxSwift is now working in the project. Unfortunately, it
does not seem possible to see its source code in XCode. When adding the
Pods/RxSwift/BUILD
as a package in tulsi, it as a package in Tulsi, it cannot
find the associated source code. Which is quite understandable as this BUILD
file should be interpreted within the context of the new_http_archive
. I
haven't found an elegant solution yet. If you have an idea, ping
me.
But the app builds fine (even in XCode).
RxCocoa
RxCocoa depends on RxSwift but is hosted in the same git repo. Its
podspec is
not really an issue and is easily mapped into Pods/RxSwift/BUILD
:
{{<highlight python "hl_lines=15-26">}} load("@build_bazel_rules_apple//apple:swift.bzl", "swift_library")
Based on RxSwift.podspec
swift_library( name = "RxSwift", module_name = "RxSwift", srcs = glob( ["Platform//*.swift", "RxSwift//.swift"], exclude= ["RxSwift/Platform/**/.swift"] ), visibility = ["//visibility:public"], )
Based on RxCocoa.podspec
swift_library( name = "RxCocoa", module_name = "RxCocoa", srcs = glob( ["Platform//*.swift", "RxCocoa//.{swift,h,m}"], exclude= ["RxCocoa/Platform/**/.swift"] ), deps = [":RxSwift"], visibility = ["//visibility:public"], ) {{}}
Building RxCocoa is no different:
$ bazel build @Rx//:RxCocoa
Declare Pod dependency with PodToBUILD
Manually converting a Pod is not always necessary as PodToBUILD will take care of it. It is written by Pinterest and supports Objective-C Pods.
Future
The rules_swift
is slowly
replacing the rules_apple
for
building Swift code. The new rules should work on Linux and will probably the
way to go in the future. In the meantime, rules_apple
works pretty well.
Conclusion
Adding an iOS app and library to the monorepo was not really hard. Dealing with
external libraries still requires some hand written config to map the CocoaPod
(or Swift Package) definitions. Fortunately, most of them are trivial. When Pods
are in Objective-C, PodToBUILD makes
it easy to integrate them in the BUILD
file.
As before, you can check the source code on Github.
So far we have two completely distinct worlds where iOS and Android live next to each other but don't share any code. This will be the topic of the next post in the series.
As usual, ping me on twitter if you have any feedback, suggestion or question.
Update: Many thanks to @rmalik and @jin_ for reaching out to let me know about PodToBUILD.