article banner

Implementing Multiplatform Kotlin library

This is a chapter from the book Advanced Kotlin. You can find it on LeanPub or Amazon.

In the previous part, we discussed the essential concepts of Kotlin multiplatform.

As a young and ambitious student, I started using the Anki flashcards application for learning. I still use it from time to time, but one of my biggest problems with it is the redundancy between the flashcards I create and my notes. To fix this, I started working on my AnkiMarkdown project. I invented a syntax to mark specific kinds of flashcards in the Markdown I use for making notes, then I implemented a program that understands this syntax. It has two modes of synchronization: update flashcards based on notes, or update notes based on flashcards.

Example of flashcards defined using Anki Markdown.

Flashcard of type cloze in Anki.

I initially implemented this program in Kotlin/JVM and ran it using the console, but then I started using the Obsidian program to manage my notes. I realized that I could make an Obsidian plugin for AnkiMarkdown synchronization that would need to be implemented in JavaScript, but it turned out to be really simple to turn my Kotlin/JVM module into a multiplatform module because Kotlin is a multiplatform language. I had already used the Ktor client for communication with Anki, and the only significant change was that I needed to move file management to multiplatform module platform source sets.

Complete example can be found on GitHub under the name MarcinMoskala/AnkiMarkdown.

Let's see it in practice. Imagine you’ve decided to make a library for parsing and serializing YAML. You’ve implemented all the parsing and serialization using Kotlin and provided the following class with two exposed methods as your library API:

class YamlParser { fun parse(text: String): YamlObject { /*...*/ } fun serialize(obj: YamlObject): String { /*...*/ } // ... } sealed interface YamlElement data class YamlObject( val properties: Map<String, YamlElement> ) : YamlElement data class YamlString(val value: String) : YamlElement // ...

Since you only use Kotlin and the Kotlin Standard Library, you can place this code in the common source set. For that, we need to set up a multiplatform module in our project, which entails defining the file where we will define our common and platform modules. It needs to have its own build.gradle(.kts) file with the Kotlin Multiplatform Gradle plugin (kotlin("multiplatform") using kotlin dsl syntax), then it needs to define the source sets configuration. This is where we specify which platforms we want to compile this module to, and we specify dependencies for each platform.

// build.gradle.kts plugins { kotlin("multiplatform") version "1.8.10" // ... java } kotlin { jvm { compilations.all { kotlinOptions.jvmTarget = "1.8" } withJava() testRuns["test"].executionTask.configure { useJUnitPlatform() } } js(IR) { browser() binaries.library() } sourceSets { val commonMain by getting { dependencies { // ... } } val commonTest by getting { dependencies { // ... } } val jvmMain by getting { dependencies { // ... } } val jvmTest by getting val jsMain by getting val jsTest by getting } }

Source sets’ names matter as they describe the corresponding platform. Note that tests for each platform are separate source sets with separate dependencies, therefore each source set needs an appropriately named folder that includes a "kotlin" subfolder for code and a "resources" folder for other resources.

We should place our common source set files inside the "commonMain" folder. If we do not have any expected declarations, we should now be able to generate a library in JVM bytecode or JavaScript from our shared module.

This is how we could use it from Java:

// Java
YamlParser yaml = new YamlParser();
System.out.println(yaml.parse("someProp: ABC"));
// YamlObject(properties={someProp=YamlString(value=ABC)})

If you build this code using Kotlin/JS for the browser, this is how you can use this class:

// JavaScript
const parser = new YamlParser();
console.log(parser.parse("someProp: ABC"))
// {properties: {someProp: "ABC"}}

It is more challenging when you build a NodeJS package because, in such packages, only exposed elements can be imported. To make your class functional for this target, you need to use the JsExport annotation for all elements that need to be visible from JavaScript.

@JsExport class YamlParser { fun parse(text: String): YamlObject { /*...*/ } fun serialize(obj: YamlObject): String { /*...*/ } // ... } @JsExport sealed interface YamlElement @JsExport data class YamlObject( val properties: Map<String, YamlElement> ) : YamlElement @JsExport data class YamlString(val value: String) : YamlElement // ...

This code can be used not only from JavaScript but also from TypeScript, which should see proper types for classes and interfaces.

// TypeScript
const parser: YamlParser = new YamlParser();
const obj: YamlObject = parser.parse(text);

Now, let's complicate this example a bit and assume that you’ve decided to introduce a class in your library for reading YAML from a file or a URL. Reading files and making network requests are both platform specific, but you’ve already found multiplatform libraries for that. Let's say that you’ve decided to use Okio to read a file and the Ktor client to fetch a file from a URL.

// Using Okio to read file class FileYamlReader { private val parser = YamlParser() fun read(filePath: String): YamlObject { val source = FileSystem.SYSTEM .source(filePath) .let(Okio::buffer) val fileContent = source.readUtf8() source.close() return parser.parse(fileContent) } } // Using Ktor client to read URL class NetworkYamlReader { private val parser = YamlParser() suspend fun read(url: String): YamlObject { val resp = client.get(url) { headers { append(HttpHeaders.Accept, "text/yaml") } }.bodyAsText() return parser.parse(resp) } }

The second problem is that to use network requests we needed to introduce a suspend function, but suspend functions cannot be used by languages other than Kotlin. To actually support using our class from other languages, like Java or JavaScript, we need to add classes that will adapt NetworkYamlReader for different platforms, like a blocking variant for JVM, or a variant that exposes promises on JS.

// jsMain module @JsExport @JsName("NetworkYamlReader") class NetworkYamlReaderJs { private val reader = NetworkYamlReader() private val scope = CoroutineScope(SupervisorJob()) fun read(url: String): Promise<YamlObject> = scope.promise { reader.read(url) } }

I hope you have a sense of the possibilities that multiplatform modules offer, as well as the challenges that arise from these possibilities. You will see more of them in the next example.

In the next part, you’ll see an example of a multiplatform Kotlin mobile application.