A Comprehensive guide to build a cross-platform application by Bazel
Vladyslav Kaplun
Posted on November 17, 2022
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 installingAndroid SDK
andAndroid Emulator
set up
-
-
Xcode
14
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
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!";
}
The BUCK
file just lists the sources and the name:
common/BUILD
cc_library(
name = "library",
srcs = [":library/source.cpp"],
hdrs = [":library/header.hpp"],
)
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
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"],
)
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()
}
}
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()
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"],
)
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
If everything goes as expected, you should see something like:
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()
}
}
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>
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"],
)
One more application is ready:
bazel run //project.mac:bundle
A similar window should appear on your screen:
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;
}
Java
class which imports the exported code:
common/android_bridge/java/library/NativeBridge.java
public class NativeBridge {
public static native String sayHello();
}
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>",
)
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,
)
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 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());
}
}
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",
],
)
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",
],
)
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
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);
}
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()
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"],
)
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>
The web
target converts C++
code to JavaScript
:
project.web/BUILD
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:
bazel build //project.web:bundle && bash project.web/copy_and_run_html.sh
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!
Posted on November 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.