TulipeMoutarde.be

About

Table of Contents

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:

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 welcome screen

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.

Tulsi Package pane Tulsi Shared Options Tulsi Configs Xcode running tulsi project

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_appleandrules_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:

  1. First add the package to the project.

Add Package to the tulsi project

  1. Add it to the config and add a target

Add Lib to the config

  1. Regenerate the XCode project and open it

Xcode targets

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:

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).

Running project with RxSwift

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.