Choosing a Multiplatform Stack
By Nuno Baldaia
Choosing a Multiplatform Stack
Developing two products — Todoist and Twist — for multiple platforms — Android, Apple platforms, Web, Windows, etc. — surfaces challenges in coordination and consistency. At Doist, we have been relying on common specifications to achieve consistency. Despite this, coordination is still hard and time-consuming, and for each specification update, no matter how big or small, we have to coordinate work for all platforms. Moreover, we generally lack shared tests to ensure implementations conform to their shared specification.
A multiplatform solution can solve both issues by concentrating specifications, tests, and implementation within a single software package. However, we care deeply about the great integrated experience that native platforms provide users, so end-to-end cross-platform solutions that bypass the native ecosystem such as React Native, Flutter, etc., are usually out of the equation for us. With these reservations in mind, what entices us most is sharing common business logic.
We are aware of challenges [ 1 ], but we have been testing for a while. A year ago, I revisited some of the multiplatform technologies with potential to fit our needs. In this post, I will share the process and the results.
Assessment criteria and tested technologies
Looking for the best option to share business logic, the focus was on performance and interoperability as the two major assessment metrics. There are other important aspects, but failing at any of these would be a serious blocker for us — we deeply care about fast and responsive interfaces, and a smooth and pleasant developing experience is no less of a concern.
I tested the following technologies:
- JavaScript, via iOS 14.3’s JavaScriptCore : common denominator for most host platforms and the underlying language for our Web clients.
- Go, v1.15: simple and easy to learn language that provides (limited) mobile support.
- Rust, v1.50: fast, safe, and modern language with a rich feature set.
- Kotlin Multiplatform, v1.4.21: modern language with very active development in becoming a multiplatform solution. We use it for Android, and could potentially adapt existing components and leverage existing internal knowledge and experience.
I left out C++ as the language could represent a critical first adoption barrier due to its complexity. On its replacement, I used Rust.
Testing environment
Being part of the Apple team, I focused this exploration on the Apple platforms only. I used a small iOS app, in which I integrated simple libraries built on the mentioned technologies to assess their performance and interoperability with Swift. The app plots the time spent on the different tasks.
Those tasks cover multiple aspects that challenge both performance and interoperability from different perspectives. The following sections describe each of those tests, analyzing the results and reaching some conclusions.
Hello test
This is the first typical “Hello, World” test. Each library exposes an API that accepts a name
string parameter and returns the “Hello, <name>!”
string as a result.
JavaScript implementation:
function hello(name) {
return "Hello, " + name + "!";
}
Despite how basic the test is, it already gives us important insights regarding performance and interoperability.
As we can see, as the app calls the API 10k times for a list of randomly generated names, there are already some noticeable performance differences. Those differences are especially impacted by the interoperability between each library and the host iOS app. Particularly, on how the data (in this case, strings) is sent through the boundary.
I didn’t investigate why Kotlin is significantly less performant than directly calling the native Swift API.
But I can explain why Rust, when invoked via UniFFI , is slower than when invoked via raw FFI . UniFFI is a convenient library that provides safer and easier interoperability between Rust and other languages. In this case, Swift via an Objective-C interface. Behind the scenes, UniFFI is using FFI (i.e., a C interface), but to make it safer and simpler, it relies on Lifting, Lowering and Serialization mechanisms to send data across the boundary. This improves interoperability at the cost of performance degradation. As we can see in the previous graph, using raw FFI has a significant performance boost compared with UniFFI. However, interoperability is critically compromised! Dealing with the low-level C interface is not usable when dealing with slightly more complex data structures, such as arrays. Also, it is unsafe! Having two very safe and modern languages (Swift and Rust) communicating through such an unsafe layer (C) is, in my opinion, unreasonable.
So far, I can’t also explain why Go and JavaScript are that slower, but I believe that’s also related to sending data back and forth, i.e., interoperability issues. But we will investigate that in more detail in the following tests.
Primes test
To verify the assumption that most of the performance issues are related to sending a high volume of data across the boundary, I added another test that returns the sum of all prime numbers up to the provided number. So, it only deals with native types (integers) for all the technologies (straightforward for crossing the Rust FFI layer, for example) but has a significant computational effort. I.e., near to zero performance cost in crossing the boundary.
Go implementation:
func isPrime(number int) bool {
if number < 2 {
return false
}
for i := 2; i < number; i++ {
if number % i == 0 {
return false
}
}
return true
}
func SumOfPrimes(until int) int {
sum := 0
for number := 2; number <= until; number++ {
if isPrime(number) {
sum += number
}
}
return sum
}
Note: the algorithm is intentionally unoptimized, and it has the same implementation for all technologies.
Surprisingly, Swift’s performance is terrible! Swift’s issue is related with the for loop that, even when optimized using stride(from:to:by:) , has a poor runtime performance.
If you are curious, here’s the Swift implementation using stride(from:to:by:
:
func isPrime(number: Int) -> Bool {
if number < 2 {
return false
}
for i in stride(from: 2, to: number, by: 1) {
if number % i == 0 {
return false
}
}
return true
}
func sumOfPrimes(until: Int) -> Int {
var sum = 0
for number in stride(from: 2, through: until, by: 1) {
if isPrime(number: number) {
sum += number
}
}
return sum
}
Analyzing all the other technologies clarifies the initial assumption. As the boundary is simple, and this test is not sending data back and forth, performance is more or less the same for both Kotlin and Rust (both UniFFI or FFI). The same happens regarding JavaScript, for which the algorithm itself does not perform that poorly, and there is no performance toll on crossing the boundary. I can’t explain why Go’s performance is worse, though. Like Swift, it’s probably not optimized for heavy computational tasks?
Send items back test
To test the performance toll on crossing the boundary, I set up another basic test. It’s an API that accepts an array of simple data structures and returns it back without modifying it or doing anything else. The idea is to check the time spent on sending data back and forth only.
Rust implementation:
struct RustItem {
name: String
}
fn rust_send_back(items: Vec<RustItem>) -> Vec<RustItem> {
items
}
Note: I did not even test exposing this API using raw FFI, as sending arrays in both directions is a nightmare. I experimented with it, but complexity and safety compromises were too severe to consider this path. So, to use Rust in a practical way, we’d have to live with UniFFI or a similar high-level boundary layer.
At this point, we disqualified Go as an option, as only a subset of Go types are currently supported , in which arrays, for example, are not included. Interoperability is still very compromised.
And the results are clarifying. Swift and Kotlin almost don’t spend any time on this task as they are just sending back the same arrays, and both use native interfaces. But there’s a significant performance cost on sending the arrays through Rust via the UniFFI layer, which is even worse than in the JavaScript case.
Focusing on Kotlin’s interoperability with Swift (via an Objective-C interface) I tested sending Kotlin an array of a class defined in Swift that conforms to a Kotlin-defined interface. This way, we can decouple business logic in Kotlin that can be attached to existing codebases without requiring significant changes on the host platforms. In this case, the only requirement is to make Swift data structures conform to the Kotlin-defined interface.
Kotlin implementation:
interface KotlinItemInterface {
val name: String
}
fun sendBackItemInterfaces(items: List<KotlinItemInterface>): List<KotlinItemInterface> {
return items
}
Swift usage:
// Swift-defined data structure
class SwiftItemClass {
let name: String
init(name: String) {
self.name = name
}
}
// Conforming to the Kotlin interface
extension SwiftItemClass: SwiftItemInterface {}
// Sending Swift data structures to Kotlin
_ = kotlinImplementation.sendBackItemInterfaces(items: swiftItemClasses)
As we can see from the previous plot, such interoperability has no significant performance degradation.
Map test
This test is a direct map function. I.e., items are mapped without any transformation.
Kotlin’s implementation using interfaces:
fun mapItemInterfaces(items: List<KotlinItemInterface>): List<KotlinItemInterface> {
return items.map { it }
}
Unsurprisingly, Rust via the UniFFI layer and JavaScript have significant performance degradation due to the boundary-crossing overhead.
So, we focused on Swift and Kotlin comparisons from now on, and on Kotlin’s interoperability possibilities, for example, to provide functions to perform a task in the host platform.
Kotlin implementation:
fun mapFunction(): (KotlinItemInterface) -> KotlinItemInterface {
return {
item: KotlinItemInterface -> item
}
}
Swift usage:
func mapWithKotlinFunction(items: [KotlinItemInterface]) -> [KotlinItemInterface] {
return items.map(KotlinImplementation().mapFunction())
}
Even though we are facing a slight performance degradation, it is good to know that we have multiple interoperability options using Kotlin Multiplatform via the Objective-C interface. Overall, Kotlin Multiplatform has been very promising on both performance and interoperability, the criterion we originally defined.
Sorting test
This test is about returning the array of item objects sorted by their name. The results become interesting when using language-specific data structures, i.e., Swift is sorting Swift-defined structs, and Kotlin is sorting Kotlin-defined structs.
Swift implementation:
func sortSwiftItemStructs(items: [SwiftItemStruct]) -> [SwiftItemStruct] {
return items.sorted { $0.name < $1.name }
}
Kotlin implementation:
fun sortKotlinItems(items: List<KotlinItem>): List<KotlinItem> {
return items.sortedBy { it.name }
}
I can’t explain why Kotlin’s performance is significantly better than Swift’s, but I assume that Kotlin has faster sorting algorithms than Swift.
However, a more interesting approach regarding interoperability is to use interfaces.
Again, there’s a slight trade-off between performance and interoperability.
Kotlin implementation:
fun sortKotlinItemInterfaces(items: List<KotlinItemInterface>): List<KotlinItemInterface> {
return items.sortedBy { it.name }
}
Swift usage:
_ = kotlinImplementation.sortKotlinItemInterfaces(items: swiftItemClasses)
Note: we can’t send Swift structs because the boundary interface is in Objective-C. However, we are looking forward for direct interoperability with Swift for an even more powerful and seamless interoperability in the future.
Conclusions
JavaScript is the native language for the Web and has almost ubiquitous support across platforms. However, its performance and interoperability characteristics are lacking. It shows a considerable performance degradation when crossing the boundary and on some processing tasks (sorting, for example). Interoperability is unsafe, and it requires significant boilerplate glue code (at least from the Swift side).
Go shows promise, but for our use cases, interoperability with Swift was very incomplete.I tried it most out of curiosity, as Rust seems to be a more suitable choice for the specific use case of sharing business logic.
Rust could be a great fit. However, its interoperability with host platforms is problematic — FFI for Swift, JNI for Android, and WebAssembly for the Web. Simply looking at the Swift case, we’d have to make two modern and safe languages (Swift and Rust) communicate via an ancient and unsafe interface (C). There are alternatives, such as UniFFI, which provide safer interoperability (even though not as flexible and advanced as Kotlin’s) but at a significant performance cost.
Kotlin Multiplatform was the most promising technology in the tests and, in my opinion, the only technology worth exploring. It has good performance (for some tests, the generated binary is even faster than the native Swift implementations) and great interoperability. It’s still in Alpha, but JetBrains seems very active in developing it.
Subsequent explorations
Since this exploration took place, we have continued exploring Kotlin Multiplatform. We have faced some interoperability challenges, especially on the Web, and I realized that some of my initial assumptions were wrong. For example, we found issues integrating the original architecture we designed for mobile clients in the Web platform. There, our architecture is primarily functional, so providing a list of data structures conforming to Kotlin-defined interfaces was not a good match. Still, we were able to quickly find another architectural solution that suited all host platforms .