We have finally arrived at one of my favorite topics. In this section, we'll explore how multiplatform works, why it's different from cross-platform technologies, and its cost implications.
The multiplatform approach
As we mentioned previously, cross-platform technologies generally try to take on the "burden" of dealing with platform-specifics; thus, their main goal is to help facilitate application development without having to deal with platform-specific decisions. This has two implications – interoperability with the native platform is not the primary scope of these technologies and (partly because of this) the framework needs to do most of the heavy lifting when it comes to making platform-specific decisions.
To overcome these issues, another approach is needed. Kotlin Multiplatform (KMP), a multiplatform solution, introduces a paradigm shift. It recognizes both the need for flexible platform-specific decision making and keeping up to date with different platforms, where these two things go hand-in-hand.
Its aim is not to provide a wrapper layer over the native platforms, but to be a handy tool in the native development palette, which can help with sharing non-platform-specific code such as the business logic.
You may be wondering why understanding the ideology of a framework would be important for you. There are a couple of reasons, as follows:
- You become more aligned with a framework, and you'll know when something goes against the framework's design.
- You'll be able to manage your expectations regarding the framework's future direction better.
The main objectives of KMP are as follows:
- Keeping the native part of development as close to the regular native development process as possible.
- Ensuring that native developers do not find it difficult when they're writing the shared code.
- Facilitating interoperability between native and shared code; interacting with shared code should be as close to native-like as possible.
Now, let's take a deeper look into how KMP can empower you to write platform-agnostic code and share that between different platforms.
How KMP works
KMP allows you to write code in Kotlin in a platform-agnostic way and share that code between different platforms, all while leveraging the native programming benefits.
The Kotlin ecosystem contains three main compilers – Kotlin/JVM, Kotlin/JS, and Kotlin/Native (we will cover them in more depth in Chapter 2, Exploring the Three Compilers of Kotlin Multiplatform):
Figure 1.7 – The architecture of KMP
Note
Most developers know Kotlin through the lens of Kotlin/JVM. This is because of Kotlin's reliable interoperability with Java. It has a wide and quickly growing adoption rate in the Android community, but server-side development with Kotlin has also been becoming more and more popular in recent years.
In essence, Kotlin's interoperability power depends on how well these three compilers work with the respective platforms. For Android, we can consider that the interoperability cost with KMP is zero since Kotlin/JVM is part of the Android developer ecosystem. As for iOS (and potentially the web), the costs depend on how well the Kotlin/Native (and Kotlin/JS) compiler works. We will look at this in more detail in the next chapter.
Shared code must be platform-agnostic, which means the code shouldn't contain any JVM, JavaScript, iOS, or any other platform-specific references. For example, working with Date
and Time
is platform-specific and has different dependencies on iOS than on JVM (or Android). Don't worry – a lot of these use cases are already covered in libraries that have been developed by either the Kotlin community or the JetBrains team.
Now, let's learn how to leverage KMP's capabilities to write platform-agnostic code that will use the proper platform-specific dependencies on the different target platforms, in case you bump into any uncovered use case from the community.
Platform abstractions (expect-actual)
This mechanism is one of the cores of the whole KMP technology. In a lot of cases, when you're writing shared code, you need a way to define how certain functionality should be implemented on the specific native platforms.
Note
Going forward, we will use the terms shared code and common code interchangeably, both of which refer to code written in a platform-agnostic way and that could be seamlessly compiled with one of the Kotlin compilers to the chosen targets.
As you'll see, this could mean being platform-agnostic across Kotlin/Native and Kotlin/JVM only, depending on what platforms you target.
With KMP, you can write expected declarations using the expect
keyword in your shared code, which will have an actual
implementation for every platform that you specify. Let's look at an example of how to share code between Android and iOS with this mechanism.
Let's say you have an application where users can upload certain files to the cloud and you'd like to share this part of your networking layer. Since file handling is something platform-specific, you'll need to create some abstractions for this (or potentially check if it's already covered in a library).
First, you would declare the expected functionality in your shared code; in our case, we'll need any file to be converted into a byte representation of the file so that we can send it to the backend:
expect class File {
fun toByteArray(): ByteArray
}
Don't worry much about the syntax; the important part is the expect
/actual
mechanism.
To make KMP able to substitute the expected implementations with the actual implementations on the different platforms, we need to provide those as well:
// JVM
actual class File(private val file: java.io.File) {
actual fun toByteArray() = file.readBytes()
}
As you can see, for JVM/Android, we are just wrapping the java.io.File
platform-specific implementation. There is a better way to do this using type aliases, which we'll cover in Chapter 5, Writing Shared Code.
For iOS/React Native, the implementation could look like this:
// iOS/Native
actual class File(private val fileHandle:
platform.Foundation.NSFileHandle) {
actual fun toByteArray() =
with(fileHandle.readDataToEndOfFile()) {
memScoped {
ByteArray(length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), bytes, length)
}
}
}
}
}
As you can see, in the native implementation, you can use the Foundation Kit; there is also a way to include CocoaPods dependencies, which we will also cover in Chapter 5, Writing Shared Code.
At this point, KMP will compile the shared code for two different targets (Kotlin/JVM and Kotlin/Native) with the two different compilers and replace all the expected declarations with their actual
implementations on the specific platform.
I can't emphasize enough how important this mechanism is for multiplatform; this is what enables the bridge between different platforms and provides the scalability for the whole platform so that outside contributors can easily build upon the current solutions.
Next, I'm going to touch on a little tool that we're going to cover in more depth in Chapter 2,Exploring the Three Compilers of Kotlin Multiplatform, which helps out tremendously with actual implementations – the commonizer.
This tool automates the process of the expect/actual declaration and generates the expect/actual declarations for us. However, this tool was designed specifically for cases where targets (such as macOS and different iOS architectures) have very similar dependencies (such as the POSIX library on OS X and Linux).
Now that we have a bit of an understanding of the KMP framework and how it enables developers to share code, let's see what it can be used for and how it could help out in a regular development process.
The different use cases for KMP
The KMP framework is unopinionated about what you use it for. Its main goal is to help you share code between multiple target platforms with as good interoperability as possible.
This means that the possible combination of potential use cases is close to infinite. You can play around with the amount of code you plan on sharing and the targets you'd like to share the code between. You can also scale it later on in the process because you can add other target platforms and migrate more and more code to your common part as your project develops.
You can go from having 1% shared code to sharing your UI layer – the only blocking thing will be your sense of what needs to stay platform-specific.
With this in mind, let's check out some of the most common use cases.
Kotlin Multiplatform Mobile (KMM)
You may have heard about Kotlin Multiplatform Mobile (KMM) and perhaps you've been wondering what the difference is between KMP and KMM; allow me to shed a bit of light on this topic.
Technology-wise, KMM is a specific use case, whereas KMP is used for sharing code between mobile targets – Android and iOS.
KMM was introduced when JetBrains realized that this concept is, at the time of writing, one of the main use cases for developers choosing KMP to share code. Hence, a dedicated KMM team was formed and special tooling was introduced to help support this cause:
Figure 1.8 – Kotlin Multiplatform Mobile in the Kotlin Multiplatform technology
In KMM, your code-sharing capabilities will largely depend on two of the Kotlin compilers: Kotlin/JVM and Kotlin/Native. To grasp the limits of what's capable when working with these compilers, we dedicate Chapter 2, Exploring the Three Compilers of Kotlin Multiplatform to this so that you can know what to expect and how to get the most out of both the Kotlin/JVM and Kotlin/Native compilers.
As we've already mentioned, you can start with any level of code sharing, but here are some examples:
- A Small Part of the Code Base: Kevin Galligan would say to choose one of the parts that's not so fun to work on, such as analytics.
- Networking Layer or Persistence Layer: This is still a relatively small amount of the code base and it can reduce some of the synchronization costs.
- The Entire Data Layer: Managing offline support and syncing logic consistently on two different platforms can be a burden, so it can be worth doing this for certain apps.
- View/Presentation Layer: This can be done, but things get a bit more platform-specific here. This is also where the line between cross-platform and multiplatform starts to get a bit blurry.
You can start going from only a small part of the code base and then bring more and more layers and/or features as you gain more confidence working with KMP.
Another nice benefit of KMM is that it doesn't change the native development cycle radically. Instead, it builds upon it, with KMP being more of an additional tool in the existing palette.
Going forward, this use case is going to be the main focus of this book, but we will briefly explore other potential use cases so that you can get a better picture of what code-sharing possibilities you have with KMP.
Code sharing between frontend applications
You can do this gradually as well, going from a KMM app to sharing logic between all the different frontend platforms you plan on supporting.
Since your current shared code is already based on working with the Kotlin/JVM and Kotlin/Native compilers, adding support for all the different desktop targets such as macOS, Windows, and Linux is relatively easy and largely depends on how well you manage the non-shared part of your code.
A slightly bigger step is to bring the Kotlin/JS compiler into play and share code with your web app through a JS target.
The complexity of this depends on the interoperability power of Kotlin/JS and how well you can work with it.
Code sharing between backend and frontend applications
Another interesting use case of KMP is sharing code between your backend and frontend applications.
In most real-world projects, there is a limited amount of implementation overlap between backend and frontend apps, so this is why it doesn't get much focus from cross-platform solutions.
Nevertheless, there is always a piece of the backend that would be awesome to share. I've had the chance to experience minor modifications that broke the frontend apps, and also remember doing Git history research to understand why there are differences in the way frontend platforms use the backend APIs.
Yes, you can minimize these human errors with carefully designed processes, but enforcing the process itself can be another challenge.
I think that sharing DTOs, API keys, and other useful information, such as base URLs, can speed up development, especially in the long term. Just think about a continuous integration (CI) pipeline, where if a backend modification breaks the builds on the apps, it's immediately visible to the backend team.
I think that the combination of use cases is huge, and as a developer, I would start getting more and more into this world that KMP offers. The whole approach offers a new perspective on how we think about developing apps and introduces a new potential team composition:
- Platform Experts: Developers with native Android, iOS, web, or other platform expertise
- Shared Code Experts: The ones who maintain the shared logic and know the ins and outs of KMP
JetBrains had already started experimenting with this setup while developing their Space product and as KMP expertise spreads, I suspect we will see even more people follow.
Now, let's close this chapter by talking about the cost implications of a multiplatform approach.
KMM cost implications
At this point, you hopefully understand the differences between cross-platform, native, and multiplatform. The latter is in-between a native and cross-platform solution, where you remain with your native platform development cycle but enhance it with code sharing capabilities where it makes sense to.
So, how would you calculate the costs for a multiplatform project? It should have native costs for your non-shared code and cross-platform costs for shared code, except that you don't face roadblocks with KMP, with interoperability being much better than it is with cross-platform solutions.
In the case of a real roadblock, you can just decide on not sharing that part of the code so that your interop costs will be diminishing relative to those of cross-platform solutions.
Based on this reasoning, a possible calculation of KMP costs could look like this:
Cost of development (n) = FC * [n * (1 - α) + α]
Here, n is the number of platforms, FC is the feature complexity, and α ∈ [0,1] represents the amount of shared code (1: all the code is shared, 0: no code is shared).
Note that in this case, we don't include any synchronization costs. This is because KMP, when done right, should eliminate the situations where synchronization costs could occur; thus, the non-shared amount of code should be a representation of the platform-specific code that's not worth sharing.
Of course, since KMP is a relatively newborn platform, the aforementioned ideal scenario probably won't manifest for every use case, though it is approachable. To grasp what this cost calculation means, check out the following chart:
Figure 1.9 – Cost of KMP development versus other options as a function of feature complexity
As you can see, as the costs of development increase, cross-platform solutions can be a good choice for short-term, quick projects. But in the long term, KMP is going to be the winner.
Important Note
I'm going to emphasize again that this is a simplistic estimation of costs and that the preceding chart is a representation of a fabricated (though possible) scenario of project development.
Because estimating real-world projects with generic calculation logic and from the perspective of the different technologies is a hugely complex task, this should be enough reasoning as to why this simplistic approach was taken.
Nevertheless, I'm confident that this simplistic approach can provide a good overview of the costs of the different technologies.
Please note that an important aspect is missing from this chart – having an even better view that shows another dimension of the quality would be required to have complete reasoning on the technologies.
We won't dive deeper into this topic, but I'd reason about my technology choices in the following manner:
- Are quality and nativeness paramount for my project? If the answer is yes, go as native as possible.
- If both quality and costs are important and you're looking for the highest quality/cost ratio, then go with multiplatform. Note that KMP is applicable for the first scenario as well since it offers gradual code sharing; hence, if you only find out that sharing something affects your quality during the process, you can revert and go fully native for that feature. The upside is that you'll cut a lot of the costs.
- Cross-platform is the most cost-efficient option, but it is likely to require compromises.