A Comprehensive guide to build a cross-platform application by Bazel

kaplad

Vladyslav Kaplun

Posted on November 17, 2022

A Comprehensive guide to build a cross-platform application by Bazel

Intro

New to Bazel? Or maybe you were about to use it for your cross-platform application? You’re in the right place.

You can find a decent amount of tutorials to use Bazel in your development. Most of them are either outdated or just don't work with the latest version. I was surprised that a project that size lacks a complete tutorial to build a cross-platform application. Let's fill that niche

What you'll learn

In this quick guide, we are going to use Bazel to build multiple applications which share a native library. We will be targeting 4 platforms: iOS, macOS, Android and Web.
In the end you should have a solid basement to build your first cross-platform application (or move an existing project to Bazel). I listed the project on Github - I encourage you to clone it and experiment.

Pre-requisite

This guide assumes you already have some basic knowledge of how Bazel works and what it is, how WORKPLACE and BUILD files are structured. I'll be focusing on BUILD files contents describing all the details, without a deep dive into platforms internals, JNI, Cocoa bridging or how Emscripten compilation works.

Before you begin

You'll need these tools installed on your system to compile & run this guide. My goal was to use the latest available version for each them, but I assume their older versions should also work fine

  • bazel 5.3.2
  • Android SDK
    • Android API 32
    • Android NDK 21. NDK v21 is currently the latest supported version
    • Android Build-Tools
    • Android Emulator
    • Android Platform-Tools
    • Android Studio. This is not a hard dependency. Though, Android Studio significantly decreases complexity of installing Android SDK and Android Emulator set up
  • Xcode 14

Project architecture

project architecture
As a shared dependency, we will create a small C++ library which will be used by every application. We will also create a bridge for each of the platforms so the native code could be imported. For the web app, we will use Emscripten to convert the native library to Javascript. Projects files were generated by Xcode and Android Studio and used without any modifications

Project file-tree:

├── WORKSPACE
├── common
│   ├── BUILD
│   ├── android_bridge
│   ├── cocoa_bridge
│   ├── library
│   └── web_bridge
├── project.android
│   ├── BUILD
├── project.ios
│   ├── BUILD
├── project.mac
│   ├── BUILD
└── project.web
    ├── BUILD
Enter fullscreen mode Exit fullscreen mode

Build with Bazel

Common library

Just like the first step every developer does, we will be displaying the greeting. The library will contain a single class with a method which returns the message

common/library/source.cpp
std::string Library::sayHello() {
    return "Hello, World!";
}
Enter fullscreen mode Exit fullscreen mode

The BUCK file just lists the sources and the name:

common/BUILD
cc_library(
    name = "library",
    srcs = [":library/source.cpp"],
    hdrs = [":library/header.hpp"],
)
Enter fullscreen mode Exit fullscreen mode

Cocoa bridge

Swift can not consume a native library directly, and needs a bridge, which is basically an Obj-C wrapper. Our wrapper class contains a single static method which returns NSString instance:

common/cocoa_bridge/NativeBridge.mm
@implementation NativeBridge

+ (NSString*) sayHello {
  const std::string hello = Library::sayHello();
  return [NSString stringWithCString: hello.c_str() 
                            encoding: [NSString defaultCStringEncoding]];
}

@end
Enter fullscreen mode Exit fullscreen mode

Bridge definition is similar to library's BUILD:

common/BUILD
objc_library(
    name = "cocoa_native_bridge",
    srcs = [":cocoa_bridge/NativeBridge.mm"],
    hdrs = [":cocoa_bridge/NativeBridge.h"],
    visibility = ["//visibility:public"],
    deps = [":library"],
)
Enter fullscreen mode Exit fullscreen mode

iOS

To display the message, I have created a label and imported it to the ViewController via IBOutlet. We just need to fill the label with our text:

project.ios/ViewController.swift
class ViewController: UIViewController {
  @IBOutlet weak var label: UILabel!

  override func viewDidLoad() {
    super.viewDidLoad()

    label.text = NativeBridge.sayHello()
  }
}
Enter fullscreen mode Exit fullscreen mode

By default, Bazel does not know how to build an iOS application, so we need to install rules which define how to build it:

WORKSPACE
git_repository(
    name = "build_bazel_rules_apple",
    remote = "https://github.com/bazelbuild/rules_apple.git",
    tag = "1.1.3",
)

git_repository(
    name = "build_bazel_rules_swift",
    remote = "https://github.com/bazelbuild/rules_swift.git",
    tag = "1.2.0",
)

load("@build_bazel_rules_swift//swift:repositories.bzl", "swift_rules_dependencies")

swift_rules_dependencies()
Enter fullscreen mode Exit fullscreen mode

Now, using the imported rules, we can define the iOS target:

project.ios/BUILD
ios_application(
    name = "bundle",
    app_icons = [":Assets.xcassets/AppIcon.appiconset/Contents.json"],
    bundle_id = "bazel.xplatform.awesome",
    families = [
        "iphone",
        "ipad",
    ],
    infoplists = [":Info.plist"],
    launch_storyboard = ":Base.lproj/LaunchScreen.storyboard",
    minimum_os_version = "13.0",
    version = ":version",
    deps = [":sources"],
)

apple_bundle_version(
    name = "version",
    build_version = "1.0",
    short_version_string = "1.0",
)

swift_library(
    name = "sources",
    srcs = [
        ":AppDelegate.swift",
        ":SceneDelegate.swift",
        ":ViewController.swift",
    ],
    copts = [
        "-import-objc-header",
        "common/cocoa_bridge/Native-Bridging-Header.h",
    ],
    data = [
        ":Base.lproj/Main.storyboard",
    ],
    module_name = "sources",
    deps = ["//common:cocoa_native_bridge"],
)
Enter fullscreen mode Exit fullscreen mode

ios_application does not allow us to include sources directly, so we need an additional target swift_library.

Open the terminal.app and execute this in the root directory:

bazel run //project.ios:bundle
Enter fullscreen mode Exit fullscreen mode

If everything goes as expected, you should see something like:

iOS application

macOS

The same steps should be applied inside the ViewController for macOS :

project.mac/ViewController.swift
class ViewController: NSViewController {
  @IBOutlet weak var label: NSTextField!

  override func viewDidLoad() {
    super.viewDidLoad()

    label.stringValue = NativeBridge.sayHello()
  }
}
Enter fullscreen mode Exit fullscreen mode

Xcode does not create an Info.plist for generated projects, which is a hard dependency for Bazel.
Let's create one:

project.mac/Info.plist
<?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>NSMainStoryboardFile</key>
    <string>Main</string>
</dict>
</plist>
Enter fullscreen mode Exit fullscreen mode

The target definition is very similar to the iOS target:

project.mac/BUILD
macos_application(
    name = "bundle",
    app_icons = [":Assets.xcassets/AppIcon.appiconset/Contents.json"],
    bundle_id = "bazel.xplatform.awesome",
    entitlements = ":bundle.entitlements",
    infoplists = [":Info.plist"],
    minimum_os_version = "12.0",
    version = ":version",
    deps = [":sources"],
)

apple_bundle_version(
    name = "version",
    build_version = "1.0",
    short_version_string = "1.0",
)

swift_library(
    name = "sources",
    srcs = [
        ":AppDelegate.swift",
        ":ViewController.swift",
    ],
    copts = [
        "-import-objc-header",
        "common/cocoa_bridge/Native-Bridging-Header.h",
    ],
    data = [
        ":Base.lproj/Main.storyboard",
    ],
    module_name = "sources",
    deps = ["//common:cocoa_native_bridge"],
)
Enter fullscreen mode Exit fullscreen mode

One more application is ready:

bazel run //project.mac:bundle
Enter fullscreen mode Exit fullscreen mode

A similar window should appear on your screen:

macOS application

JNI bridge

Just like Swift, Java can not import native code directly.
Our JNI bridge will be split into 2 targets:

  • native wrapper which exports native methods to JVM
  • Java class which "consumes" those methods

Native method export:

common/android_bridge/jni.cpp
extern "C" JNIEXPORT jstring JNICALL Java_library_NativeBridge_sayHello(JNIEnv* const env, const jclass clazz) {
  const std::string res = Library::sayHello();
  jstring result = env->NewStringUTF(res.c_str());
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Java class which imports the exported code:

common/android_bridge/java/library/NativeBridge.java
public class NativeBridge {
  public static native String sayHello();
}
Enter fullscreen mode Exit fullscreen mode

Time to show Bazel how to build an Android application:

WORKSPACE
git_repository(
    name = "build_bazel_rules_android",
    remote = "https://github.com/bazelbuild/rules_android.git",
    tag = "v0.1.1",
)

android_sdk_repository(
    name = "androidsdk",
    path = "<path to installed android sdk>",
)

android_ndk_repository(
    name = "androidndk",
    api_level = 21,
    path = "<path to installed android ndk>",
)
Enter fullscreen mode Exit fullscreen mode

The final target to export the bridge:

common/BUILD
android_library(
    name = "android_native_bridge",
    srcs = [":android_bridge/java/library/NativeBridge.java"],
    visibility = ["//visibility:public"],
    deps = [":android_native"],
)

cc_library(
    name = "android_native",
    srcs = [":android_bridge/jni.cpp"],
    linkopts = ["-ldl"],
    deps = [":library"],
    alwayslink = True,
)
Enter fullscreen mode Exit fullscreen mode

The fun part worth to mention:

linkopts = ["-ldl"],
alwayslink = True,
Enter fullscreen mode Exit fullscreen mode

These attributes are not listed in any Bazel tutorial I found! If you omit those, linker will simply drop the symbols, making the library empty. This was raised to Bazel community and eventually should be fixed in a new rule.

Android

Time to show the message from the Activity:

project.android/java/bazel/xplatform/awesome/MainActivity.java
public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("bundle");
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        TextView text = (TextView)findViewById(R.id.text_view);
        text.setText(NativeBridge.sayHello());        
    }
}
Enter fullscreen mode Exit fullscreen mode

We will need an extra rule to import maven dependencies:

WORKSPACE
git_repository(
    name = "rules_jvm_external",
    remote = "https://github.com/bazelbuild/rules_jvm_external.git",
    tag = "4.2",
)

load("@rules_jvm_external//:defs.bzl", "maven_install")

maven_install(
    artifacts = [
        "androidx.appcompat:appcompat:1.3.0",
        "com.google.android.material:material:1.4.0",
        "androidx.constraintlayout:constraintlayout:2.0.4",
    ],
    repositories = [
        "https://maven.google.com",
    ],
)
Enter fullscreen mode Exit fullscreen mode

The Android target list the sources and adds maven libraries as a dependency:

project.android/BUILD
android_binary(
    name = "bundle",
    srcs = [":java/bazel/xplatform/awesome/MainActivity.java"],
    custom_package = "bazel.xplatform.awesome",
    manifest = ":AndroidManifest.xml",
    manifest_values = {
        "minSdkVersion": "15",
        "targetSdkVersion": "32",
    },
    resource_files = glob(
        ["res/**/*"],
        ["**/.DS_Store"],
    ),
    deps = [
        "//common:android_native_bridge",
        "@maven//:androidx_appcompat_appcompat",
        "@maven//:androidx_constraintlayout_constraintlayout",
        "@maven//:com_google_android_material_material",
    ],
)
Enter fullscreen mode Exit fullscreen mode

To run the application, you'll need a launched emulator or a device connected to your Mac/PC. For simplicity, I have listed every available architecture:

bazel mobile-install //project.android:bundle --start_app --fat_apk_cpu=armeabi-v7a,arm64-v8a,x86,x86_64
Enter fullscreen mode Exit fullscreen mode

An emulator version:
Android application

Web bridge

We need to bind the library before it can be converted to JavaScript:

common/web_bridge/embind.cpp
class NativeBridge {
public:
  static std::string sayHello() {
    return Library::sayHello();
  }
};

EMSCRIPTEN_BINDINGS(xplatform_awesome) {
  emscripten::class_<NativeBridge>("NativeBridge")
      .class_function("sayHello", &NativeBridge::sayHello);
}
Enter fullscreen mode Exit fullscreen mode

This rule will download required Emscripten version:

git_repository(
    name = "emsdk",
    remote = "https://github.com/emscripten-core/emsdk.git",
    strip_prefix = "bazel",
    tag = "3.1.25",
)

load("@emsdk//:deps.bzl", "deps")

deps()

load("@emsdk//:emscripten_deps.bzl", "emscripten_deps")

emscripten_deps()
Enter fullscreen mode Exit fullscreen mode

Finally, the target is ready for the web build:

common/BUILD
cc_binary(
    name = "web_native_bridge",
    srcs = [":web_bridge/embind.cpp"],
    linkopts = ["--bind", "-sSINGLE_FILE"],
    visibility = ["//visibility:public"],
    deps = [":library"],
)
Enter fullscreen mode Exit fullscreen mode
Web

I have created an HTML page to load the wasm code:

project.web/index.html
<!doctype html>
<html>
<script>
  var Module = {
    onRuntimeInitialized: function () {
      const text = Module.NativeBridge.sayHello();
      document.getElementById('text_view').innerText = text;
    }
  };
</script>
<script src="web_native_bridge.js"></script>

<body>
  <p id="text_view" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">Loading WASM...
  </p>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

The web target converts C++ code to JavaScript:

project.web/BUILD
wasm_cc_binary(
    name = "bundle",
    cc_target = "//common:web_native_bridge",
)
Enter fullscreen mode Exit fullscreen mode

Creating and running a web container is out of bounds for this guide, so I've created a simple script which copies the html file and opens it in a browser:

bazel build //project.web:bundle && bash project.web/copy_and_run_html.sh
Enter fullscreen mode Exit fullscreen mode

web application

Results

Bazel is an amazing tool which can solve almost any assigned task. Using bazel rules we can onboard any possible build dependency.
With this guide, you have a solid example of how to include shared logic in your cross-platform application!

💖 💪 🙅 🚩
kaplad
Vladyslav Kaplun

Posted on November 17, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related