We started using Kotlin/Native and Kotlin multiplatform at PlanGrid as our first cross-platform initiative at the start of 2019. We’ve learned a lot, and now we’re ready to share our experiences and findings using Kotlin multiplatform!
Kotlin by JetBrains is now the preferred language for writing Android apps, and we use it for our Android apps at Autodesk. It has familiar modern language features like null safety and extension functions, and there are first-party libraries to support high-quality coding, like coroutines for asynchronous programming.
Our Android team has been writing the PlanGrid Android app in Kotlin for years. Kotlin is also somewhat similar to Swift. The PlanGrid iOS team has been writing Swift for awhile, so we could all kind of understand Kotlin when reading it for the first time. And after some Kotlin Koans, the iOS team was writing Kotlin worthy of ✅s from our veteran Kotlin Android peers.
In PlanGrid’s software ecosystem, we have Android, iOS, Windows, and web clients talking to the same servers. More specifically, the native mobile and desktop clients support almost the same set of features and offline functionality. They all implement the same infrastructure for downloading, parsing, persisting, and uploading construction data, which all has to move through an offline-focused sync system. All of that logic is supposed to be the same on all native clients. To ensure that, we want to share that core sync infrastructure and logic across platforms with a common library.
Kotlin/Native is now one of many cross-platform solutions out there. PSPDFKit uses a non-Kotlin approach, for example. But, there were a few important details that drove us to Kotlin:
- Language level support for multiplatform libraries (including the gradle plugin that makes it all possible)
- Built-in Obj-C/Swift interop in the Kotlin/Native project
- A rare combination of no JNI to deal with for Java clients and no JVM for native clients 🎉
Kotlin/Native and Kotlin Multiplatform
Kotlin/Native is an LLVM backend for the Kotlin compiler, runtime implementation, and native code generation facility using the LLVM toolchain.
Kotlin/Native is the set of tools that lets us compile Kotlin code down to a binary that works on native (i.e. non-JVM/JS) platforms. Kotlin Multiplatform (referred to as MPP for “multiplatform project”) is the language-level model for building Kotlin for multiple platforms.
We write our Kotlin code in a multiplatform Kotlin project shared across our mobile teams. Using the gradle build system in tandem with the Kotlin multiplatform gradle plugin, we set up gradle tasks in our project that compile, test, and publish to multiple targets– one for each architecture/platform we support (Android, iOS, and Windows).
We create the following artifacts upon building our project for all platforms:
Once they’re published inside PlanGrid’s network, we use them in the corresponding native client code bases, just like any other 3rd party library in our respective projects.
Kotlin/Native is a new project. Things break and get fixed rapidly, which is familiar if you were an early adopter to Swift:
- Sometimes there are compiler bugs (means you’re using all the good features, right?)
- Each time a new version is published, all of the 3rd party libraries that support multiplatform have to be re-published (expect improvements here in Kotlin 1.4)
- Sometimes you bump into the new compiler’s edges
The upside though is that the Kotlin/Native folks are super responsive to issues, especially if you provide small reproducible sample cases! 🤓 There’s also the kotlinlang slack, which has been super useful for getting help from both the community and JetBrains as well.
One of the biggest struggles working with multiplatform Kotlin right now is getting familiar with the Gradle infrastructure that makes it work. Gradle is a great tool, but if you’re new to it (i.e. you have a background in iOS or Windows development or have otherwise avoided writing gradle tasks), it can be very painful to jump in without a tutorial. There are plenty of great resources out there, though, when facing difficulties:
- Official Gradle tutorials
- Kotlin references about building multi-platform projects
- And of course, the great ProAndroidDev blog.
What to share?
At PlanGrid, our goal is to share code that allows us to share sync logic on all platforms. To get there, we decided to keep our library super focused and avoid tackling more platform-specific issues, like shared UI and networking.
Typically to get one model (i.e. a
User object instance) to exist on a client, an engineer would implement the same code on platform A in language X, and another client engineer would do the same on platform B in language Y. Except sometimes, they would implement it differently, which would lead to differences in product specification, possible differences in capabilities on clients, and eventually, confused customers.
To avoid all of that and to also make implementing new models/features faster, we’d like to share all of the code necessary to download, parse, persist, and upload these model types on all native clients.
We don’t share actual networking code, as in making requests to servers. It remains the client apps’ responsibility to pass along the request composed in the shared code to whatever networking library they use on the different platforms. We decided to avoid a shared networking implementation to help us get started on implementing the shared sync logic faster. Networking can be highly platform-specific and comes with many sub-problems in the context of a mobile app:
- Knowledge of the current API environment (e.g staging vs production)
- The logged in user’s credentials (storage and handling)
- Platform-specific networking layers
These are problems that our applications have already solved, and we were happy to defer working on a shared implementation to a later date.
We don’t want to share UI code. ViewModels? Maybe. Rendering is often platform specific, and each platform has its own UI frameworks. Sharing UI code would require a level of integration that we’re not ready to take on this early into our Kotlin multiplatform adventure.
Database Schema and Models
At PlanGrid, our apps are focused on syncing large amounts of construction data. Sharing data/models schema and business logic around that data provided the most natural fit for an initial attempt at sharing code between our native mobile clients. All of that logic and schema is replicated in their native languages for each mobile platform, but most of it is platform-agnostic. We use Square’s SQLDelight library for the shared sqlite database layer.
As always, there are some challenges, especially starting off.
With every native client engineer working on the same shared code, we started breaking things for each other all the time. We maintain our shared library in a separate repository, so you might not realize you broke an API that another platform was using until an engineer from that team went to upgrade the library later. We expect this to get better as our process stabilizes and the library matures. In the meantime, we’ve implemented a few processes to help improve this:
- PRs are labeled as either breaking, backward compatible, or internal-only, so that you can easily look back at recent breaking PRs to see what changed.
- We have compile checks that build each platform’s app with the latest version of the library, so that we can get ahead of upgrading the library before an unsuspecting coworkers attempts the library upgrade to integrate their own changes.
- Mark Kotlin APIs as
internalas often as possible, so that APIs can start from a position where they can be changed without breaking its clients.
Kotlin/Native, Concurrency, and Coroutines
Early on, we knew we wanted to use Kotlin’s coroutines feature for doing async work. We found that it’s a great way to manage concurrency. And due to the “structured concurrency” you get with Kotlin’s coroutines, our async tests rarely flake compared to similar tests in the Swift/iOS ecosystem. However, the kotlinx.coroutines library doesn’t have full multi-threaded coroutine support for Kotlin/Native, so we wrote and open-sourced coroutineworker to allow us to use coroutines in the shared library.
On top of that, Kotlin/Native’s concurrency model is quite different. The tl;dr is that objects are either mutable and belong to a single thread, or they are “frozen” (immutable) and can be shared across threads. We could write a whole blog post just about this topic, but we’ll defer to the many that are already out there, like this one by Kevin at Touchlab.
We started by moving one feature’s data and business logic into our shared Kotlin library. Despite some bumps, the experience has been positive enough that we plan to move all of our schemas, sync logic, and much of our business logic into our shared Kotlin library. We’re excited for the future too. JetBrains already has a preview available for multi-threaded coroutines support for Kotlin/Native, and Kotlin 1.4 promises to bring lots of polish to Kotlin multiplatform. We cannot wait to see how the ecosystem evolves in 2020! 📈