Adopting a Cross Platform Strategy for Mobile Apps
Framework of choice: Kotlin Multiplatform
When our team decided to seriously pursue a cross-platform development strategy for mobile apps, we began by investigating a handful of various options, each with its share of strengths and weaknesses. As our team carefully weighed the benefits and shortcomings of building our strategy upon each of the frameworks, Kotlin Multiplatform ultimately emerged as the framework of choice. This didn’t mean that Kotlin would become the only framework our team would ever consider using moving forward, but rather our default, go-to framework of choice.
Why Kotlin?
Our team had a wide-range of reasons for landing on Kotlin Multiplatform, but some of the most compelling include:
- Kotlin Multiplatform is backed by JetBrains and Google via the Kotlin Foundation.
- There is significant investment in this technology area by JetBrains and the Kotlin Foundation.
- It’s built on Kotlin/Native, which is already quite robust, even though it’s still in beta.
- Our team develops apps, and therefore half of our developers were already familiar with Kotlin. The other half were familiar with Swift, which is very similar to Kotlin.
- There is first class IDE support for the technology in various JetBrains IDEs (mainly IDEA, App Code, and Android Studio).
- It allows for utilizing libraries built with various technologies as dependencies (more on this below).
Getting Started
There are a number of documentation pages, articles, and sample projects that dive into the structure and details of a Kotlin MPP (Multiplatform project). Rather than repeating others’ hard work on the subject, here are some resources that I found helpful when getting started:
- https://kotlinlang.org/docs/reference/multiplatform.html
- https://kotlinlang.org/docs/reference/building-mpp-with-gradle.html
- https://github.com/JetBrains/kotlinconf-app
Do keep in mind that Kotlin Multiplatform is still an experimental technology. It’s very early on in its days, and is subject to breaking changes going forward. There is no timeline from JetBrains in terms of a first stable release. That does introduce risk when utilizing it in production.
On our team, we generally develop new modules that can be consumed by one or more of our existing apps. This means we create Multiplatform libraries that are integrated by the app teams. Taking this into account, I use IDEA as my primary IDE for library development. With the latest Kotlin plugin, there a number of handy new-project templates that can be used to create projects. On our team, we use Mobile Shared Library, which does not create the app projects, only the library project. By default, a project will contain a JVM and iOS target. Based on your needs, you may need to “convert” the JVM target to Android, but there are a number of steps to get this working correctly, which I’ll cover later in another blog post.
For our first POC, JVM was adequate because we weren’t referencing the Android SDK APIs. This template will create all the source sets you need to get started, along with the basic Kotlin dependencies. It will also include some example code to illustrate the bare basics, which you can remove once you’re comfortable with it.
The Problem of Dependencies
Hopefully, before you have reached this point, you have had the chance to consider the scope and function of your library, and have some idea of the libraries that you might need to pull in to achieve your goal. One issue you will most likely encounter with Kotlin Multiplatform, is that the set of libraries that support it are very small at this point. There is support for HTTP (Ktor), JSON (kotlinx.serialization), a subset of coroutines (main thread only), and a few bits of functionality that you will find here and there. This list will obviously grow over time, but at this point it’s still in early development. As a result, you might see the need to import a library that is specific to one target, and another (or a port), that is specific to the other target. This is what we had to do in our case.
For our project, we wanted to leverage libphonenumber from Google. The primary language that the library is authored in is Java. This made adding it as a dependency for the JVM target very easy. There is also an official port of the library to C++ that is maintained by Google. Outside of that, there are several unofficial ports. There happens to be a port of the library to Objective-C. It also so happens that Kotlin/Native supports Objective-C dependencies by generating bindings to be used from Kotlin (which is referred to as cinterop). Unfortunately, the port is incomplete and mostly unmaintained. This made it a non-starter, and left us with the C++ port as the only practical option.
This created 2 problems for our team:
- The library, and all its dependencies (there are a few, including ICU) need to be cross-compiled to every supported iOS ABI.
- Kotlin/Native doesn’t support C++.
While solving the first problem was a fairly significant undertaking, it was feasible for our team to complete. And, we were able to find a workaround for the second issue base on this premise:
- Objective-C integrates with C++ using Objective-C++.
- From an API perspective (and with regard to the header files), it’s not relevant that the implementation is Objective-C++ (that’s an implementation detail).
- Kotlin/Native supports Objective-C dependencies (and therefore Objective-C++ dependencies, as long as there is no C++ in the header files).
As a result, we were able to create a shim interface in Objective-C(++) that we could link from our Kotlin MPP. You could do something similar with pure C, but since this was to be consumed by iOS apps, creating an Objective-C framework made the most sense. It wasn’t the most elegant solution, but it was better than writing our own version of libphonenumber by a mile (or several). Then, after validating that approach did indeed work, we began the task of cross-compiling. Fast forward an inordinate amount of time, and we had all of the required native libraries cross-compiled for iOS. However, I’ll spare you the details.
From this point we were able to create a Cocoa Touch Framework project from within Xcode. We imported the cross-compiled fat libraries, and then added the small amount of source that was needed to implement the shim. We set up the project to build a fat framework that could then be pulled into our Kotlin MPP. Suffice to say, there was much cheering and rejoicing at this moment.
A Brief Overview of Cinterop
Cinterop is (or was) not the most well documented piece of Kotlin functionality. There’s enough out there to get by, but it took our team a while to find everything we needed to get set up properly, including:
1. A directory named c_interop was created under the source root where we put the aforementioned fat framework. The actual name and location are arbitrary.
2. Under src/nativeInterop/cinterop, which is the default location for DEF files, a file named phonenumbers.def was created.
3. Within the DEF file, we placed the following code block:
Here we are telling Kotlin that we have an Objective-C framework, which headers it should look for, and to only process headers that are specific to the project (starting with the prefix “AW”). The header processing is recursive, so the platform headers don’t need to be parsed. We are also telling the compiler the framework name to link when linking the target binary.
4. The cinterop was then added as a dependency for our iOS targets by adding the following code block to the targets
block within the build.gradle:
We have to tell the compiler where to find the headers within the project source. Also, we have to tell the linker where to find the framework. Once this was all set up, we were able to access the framework APIs from Kotlin using the com.airwatch.phonenumbers package, and more rejoicing ensued.
“Expect” & “Actual” Keywords
The goal of a Kotlin MPP is obviously to have as much code in the common source set as possible. However, you will undoubtedly discover occasions where you will need a platform specific implementation. In this case, since we had 2 target specific implementations of libphonenumer, with different APIs, we needed a bridge within the common code to define a common interface. This was accomplished using the expect and actual keywords that are provided by Kotlin Multiplatform.
The easiest way to think about this concept is that expect declares an interface that has exactly 1 implementation per target, provided by actual. Separate names for the interface and the implementations aren’t needed. The common code simply constructs the interface and calls its methods, which in this case are a bridge to the underlying APIs.
Integrating the Module
Once we had implemented our Multiplatform library (and tested it, of course) it was time to decide how to pull it into the apps that will consume it. In doing so, we needed to consider 2 different workflows:
- A developer who does not work on the cross-platform library, and does not need to compile its source.
- A developer who does work on the library, and therefore needs to build it inline.
There were a few ways we could go about integrating the library, each with their own pros and cons. The simplest way was to keep the MPP separate, and allow developers to clone it and work on it as necessary. Meanwhile, the built artifacts could be dropped directly into the app’s source tree. This is a very straightforward approach, but not friendly for the developers who would be working on the library. It would create the need for lots of copying around of artifacts and what not. Also, it would be difficult to know which version of the library’s artifact would actually be included in the source tree. This approach would not be scalable.
In my opinion, the most flexible way to integrate the library was to add it as a submodule of each app. Depending on whether your project already has submodules or not, developers may not even need to init the submodule at all. On Android, it’s possible to use a local .properties file with a flag that indicates whether the library should be pulled in as a subproject, or pulled from an artifact repository (like Artifactory or JCenter). Because Kotlin Multiplatform has added CocoaPods support, a similar technique can be achieved on iOS. Two entries can be added to the Podfile, one pointing to the local Podspec, and one pointing to a Podspec hosted in git. The local entry will need to be commented out in source control. So, when an iOS developer wants to work on the library, they simply need to change which entry is commented out in the Podfile. This will allow for easy switching to local source on both platforms.
Next Steps
There are a wide range of considerations and issues with Kotlin Multiplatform that I did not cover in this first blog post. The fact is, the deeper down the rabbit hole you go, the more issues you will inevitably uncover. Stay on the lookout for follow up posts where I will talk more about additional features, issues, and how to get the most out of Kotlin Multiplatform.