A Comprehensive guide to build a cross-platform application by Bazel
Published: 2022-11-17 09:09:47

### 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](https://github.com/kaplad/bazel.xplatform) 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`](https://docs.oracle.com/javase/8/docs/technotes/guides/jni/), [`Cocoa bridging`](https://developer.apple.com/documentation/swift/importing-objective-c-into-swift) or how [`Emscripten`](https://www.google.com/search?client=safari&rls=en&q=emscripten&ie=UTF-8&oe=UTF-8) 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](https://github.com/bazelbuild/bazel/issues/12889) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/czc9kgoawh94ltomcf77.jpg)
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
```
<!-- Next







 
-->
### 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`
```c++
std::string Library::sayHello() {
    return "Hello, World!";
}
```

The `BUCK` file just lists the sources and the name:
##### `common/BUILD`
```python
cc_library(
    name = "library",
    srcs = [":library/source.cpp"],
    hdrs = [":library/header.hpp"],
)
```
 

<!-- Next







 
-->
#### 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`
```obj-c
@implementation NativeBridge
 
+ (NSString*) sayHello {
  const std::string hello = Library::sayHello();
  return [NSString stringWithCString: hello.c_str() 
                            encoding: [NSString defaultCStringEncoding]];
}

@end
```
Bridge definition is similar to library's `BUILD`:
##### `common/BUILD`
```python
objc_library(
    name = "cocoa_native_bridge",
    srcs = [":cocoa_bridge/NativeBridge.mm"],
    hdrs = [":cocoa_bridge/NativeBridge.h"],
    visibility = ["//visibility:public"],
    deps = [":library"],
)
```

<!-- Next







 
-->

#### 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`
```swift
class ViewController: UIViewController {
  @IBOutlet weak var label: UILabel!
  
  override func viewDidLoad() {
    super.viewDidLoad()

    label.text = NativeBridge.sayHello()
  }
}
```

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`
```python
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()
```
Now, using the imported rules, we can define the `iOS` target: 
##### `project.ios/BUILD`
```python
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"],
)
```
`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:
```bash
bazel run //project.ios:bundle
```
If everything goes as expected, you should see something like:

![iOS application](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0imid25curzrp1ss7zkl.png)
<!-- Next







 
-->
#### macOS

The same steps should be applied inside the `ViewController` for `macOS` :
##### `project.mac/ViewController.swift`
```swift
class ViewController: NSViewController {
  @IBOutlet weak var label: NSTextField!

  override func viewDidLoad() {
    super.viewDidLoad()

    label.stringValue = NativeBridge.sayHello()
  }
}
```

`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
<?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>
```
The target definition is very similar to the `iOS` target:
##### `project.mac/BUILD`
```python
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"],
)
```
One more application is ready:
```bash
bazel run //project.mac:bundle
```
A similar window should appear on your screen:

![macOS application](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gyhn4420ahi4l5acflvc.png)
<!-- Next







 
-->
#### 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`
```c++
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;
}
```
`Java` class which imports the exported code:
##### `common/android_bridge/java/library/NativeBridge.java `
```java
public class NativeBridge {
  public static native String sayHello();
}
```

Time to show `Bazel` how to build an `Android` application:
##### `WORKSPACE`
```python
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>",
)
```

The final target to export the bridge:
##### `common/BUILD`
```python
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,
)
```
The fun part worth to mention:
```
linkopts = ["-ldl"],
alwayslink = True,
```
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](https://github.com/bazelbuild/bazel/issues/10837) to `Bazel` community and eventually should be fixed in [a new rule](https://github.com/bazelbuild/rules_android_ndk).


<!-- Next







 
-->

#### Android

Time to show the message  from the `Activity`: 
##### `project.android/java/bazel/xplatform/awesome/MainActivity.java`
```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());        
    }
}
```

We will need an extra rule to import `maven` dependencies:
##### `WORKSPACE`
```python
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",
    ],
)
```

The Android target list the sources and adds `maven` libraries as a dependency:  
##### `project.android/BUILD`
```python
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",
    ],
)
```
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:
```bash
bazel mobile-install //project.android:bundle --start_app --fat_apk_cpu=armeabi-v7a,arm64-v8a,x86,x86_64
```

An `emulator` version:
![Android application](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l3l1u8tpauyzxca2ctfy.png)
<!-- Next







 
-->
#### Web bridge

We need to bind the library before it can be converted to `JavaScript`:
##### `common/web_bridge/embind.cpp`
```c++
class NativeBridge {
public:
  static std::string sayHello() {
    return Library::sayHello();
  }
};

EMSCRIPTEN_BINDINGS(xplatform_awesome) {
  emscripten::class_<NativeBridge>("NativeBridge")
      .class_function("sayHello", &NativeBridge::sayHello);
}
```

This rule will download required `Emscripten` version:
```python
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()
```

Finally, the target is ready for the `web` build:
##### `common/BUILD`
```python
cc_binary(
    name = "web_native_bridge",
    srcs = [":web_bridge/embind.cpp"],
    linkopts = ["--bind", "-sSINGLE_FILE"],
    visibility = ["//visibility:public"],
    deps = [":library"],
)
```

<!-- Next







 
-->
##### Web
I have created an HTML page to load the wasm code:
##### `project.web/index.html`
```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>
```

The `web` target converts `C++` code to `JavaScript`:
##### `project.web/BUILD`
```python
wasm_cc_binary(
    name = "bundle",
    cc_target = "//common:web_native_bridge",
)
```

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:
```bash
bazel build //project.web:bundle && bash project.web/copy_and_run_html.sh
```

![web application](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x2dvdjflq4fhvddd43z9.png)

### 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!

<!-- Next
examples:
https://tulipemoutarde.be/posts/bazel-for-mobile-apps-part-1/
https://proandroiddev.com/part-1-creating-bazel-android-apps-from-scratch-getting-started-84485295cfce
https://bazel.build/start/ios-app
-->