Last Updated | 31 July 2020 |
You will learn step-by-step how to create a multiplatform library which can be used from any other common code (for example, shared with Android and iOS). You will also learn how to write tests which will be executed on all platforms and use an efficient implementation provided by a specific platform.
Our goal is to build a small multiplatform library to demonstrate the ability to share the code between the platforms and its benefits. In order to have a small implementation to focus on the multiplatform machinery, we will write a library which converts raw data (strings and byte arrays) to the Base64 format which can be used on JVM, JS, and any available Kotlin/Native platform.
On JVM implementation will use java.util.Base64
which is known to be extremely efficient because JVM is aware of this particular class and compiles it in a special way.
On JS we will use the native Buffer API and on Kotlin/Native we will write our own implementation.
We will cover this functionality with common tests and then publish the final library to Maven.
We will be using IntelliJ IDEA Community Edition for this tutorial, though using Ultimate edition is possible as well.
Install the Kotlin Plugin 1.4.x or higher in the IDE. You can check the Kotlin version in Tools | Kotlin | Configure Plugin Updates.
Native part of this project is written using Mac OS X, but don't worry if you are using another platform, the platform affects only directory names in this particular tutorial.
Enter a project name and select Library under Multiplatform as the project template.
A multiplatform sample library is now created and imported into IntelliJ IDEA.
Go to any .kt
file and rename the package with the IntelliJ IDEA action Refactor | Rename action to org.jetbrains.base64
Let's just check everything is right with the project so far, the project structure should be:
└── src ├── commonMain │ └── kotlin ├── commonTest │ └── kotlin ├── jsMain │ └── kotlin ├── jsTest │ └── kotlin ├── jvmMain │ └── kotlin ├── jvmTest │ └── kotlin ├── macosMain │ └── kotlin └── macosTest └── kotlin
And the kotlin
folder should contain an org.jetbrains.base64
subfolder.
Now we need to define the classes and interfaces we want to implement. Create the file Base64.kt
in the commonMain/kotlin/jetbrains/base64
folder.
Core primitive will be the Base64Encoder
interface which knows how to convert bytes to bytes in Base64
format:
interface Base64Encoder { fun encode(src: ByteArray): ByteArray }
But the common code should somehow get an instance of this interface, for that purpose we define the factory object Base64Factory
:
expect object Base64Factory { fun createEncoder(): Base64Encoder }
Our factory is marked with the expect
keyword. expect
is a mechanism to define a requirement, which every platform should provide in order for the common part to work properly. So on each platform we should provide the actual
Base64Factory
which knows how to create the platform-specific encoder. Learn more about platform-specific declarations.
Now it is time to provide an actual
implementation of Base64Factory
for every platform.
We are starting with an implementation for the JVM. Let's create a file Base64.kt
in jvmMain/kotlin/jetbrains/base64
folder and provide a simple implementation, which delegates to java.util.Base64
:
actual object Base64Factory { actual fun createEncoder(): Base64Encoder = JvmBase64Encoder } object JvmBase64Encoder : Base64Encoder { override fun encode(src: ByteArray): ByteArray = Base64.getEncoder().encode(src) }
Pretty simple, isn't it? We have provided a platform-specific implementation, but used a straightforward delegation to an implementation someone else has written!
Our JS implementation will be very similar to the JVM one. We create a file Base64.kt
in jsMain/kotlin/jetbrains/base64
and provide an implementation which delegates to NodeJS Buffer
API:
actual object Base64Factory { actual fun createEncoder(): Base64Encoder = JsBase64Encoder } object JsBase64Encoder : Base64Encoder { override fun encode(src: ByteArray): ByteArray { val buffer = js("Buffer").from(src) val string = buffer.toString("base64") as String return ByteArray(string.length) { string[it].toByte() } } }
On the generic Native platform we don't have the luxury to use someone else's implementation, so we will have to write one ourselves. It's pretty straightforward and follows Base64 format description without any optimizations:
private val BASE64_ALPHABET: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" private val BASE64_MASK: Byte = 0x3f private val BASE64_PAD: Char = '=' private val BASE64_INVERSE_ALPHABET = IntArray(256) { BASE64_ALPHABET.indexOf(it.toChar()) } private fun Int.toBase64(): Char = BASE64_ALPHABET[this] actual object Base64Factory { actual fun createEncoder(): Base64Encoder = NativeBase64Encoder } object NativeBase64Encoder : Base64Encoder { override fun encode(src: ByteArray): ByteArray { fun ByteArray.getOrZero(index: Int): Int = if (index >= size) 0 else get(index).toInt() // 4n / 3 is expected Base64 payload val result = ArrayList<Byte>(4 * src.size / 3) var index = 0 while (index < src.size) { val symbolsLeft = src.size - index val padSize = if (symbolsLeft >= 3) 0 else (3 - symbolsLeft) * 8 / 6 val chunk = (src.getOrZero(index) shl 16) or (src.getOrZero(index + 1) shl 8) or src.getOrZero(index + 2) index += 3 for (i in 3 downTo padSize) { val char = (chunk shr (6 * i)) and BASE64_MASK.toInt() result.add(char.toBase64().toByte()) } // Fill the pad with '=' repeat(padSize) { result.add(BASE64_PAD.toByte()) } } return result.toByteArray() } }
Now we have implementations on all the platforms and it is time to for testing of our library.
To make the library complete we should write some tests, but we have three independent implementations and it is a waste of time to write duplicate tests for each one.
The good thing about common code is that it can be covered with common tests, which later are compiled and executed on every platform.
Let's create the class Base64Test
in commonTest/kotlin/jetbrains/base64
folder and write the basic tests for Base64.
But as you remember, our API converts byte arrays to byte arrays in a different format and it is not easy to test byte arrays. So before we start writing a test, let's add the method encodeToString
with a default implementation to our Base64Encoder
interface:
interface Base64Encoder { fun encode(src: ByteArray): ByteArray fun encodeToString(src: ByteArray): String { val encoded = encode(src) return buildString(encoded.size) { encoded.forEach { append(it.toChar()) } } } }
Note that the implementation on every platform can encode byte arrays to a string. If we want, we can provide a more efficient implementation for this method, for example, let's specialize it on the JVM:
object JvmBase64Encoder : Base64Encoder { override fun encode(src: ByteArray): ByteArray = Base64.getEncoder().encode(src) override fun encodeToString(src: ByteArray): String = Base64.getEncoder().encodeToString(src) }
Default implementations with optional more specialized overrides is another bonus of the multiplatform library. Now, when we have a string-based API, we can cover it with basic tests:
class Base64Test { @Test fun testEncodeToString() { checkEncodeToString("Kotlin is awesome", "S290bGluIGlzIGF3ZXNvbWU=") } @Test fun testPaddedStrings() { checkEncodeToString("", "") checkEncodeToString("1", "MQ==") checkEncodeToString("22", "MjI=") checkEncodeToString("333", "MzMz") checkEncodeToString("4444", "NDQ0NA==") } private fun checkEncodeToString(input: String, expectedOutput: String) { assertEquals(expectedOutput, Base64Factory.createEncoder().encodeToString(input.asciiToByteArray())) } private fun String.asciiToByteArray() = ByteArray(length) { get(it).toByte() } }
Use Gradle 6.0 and above to generate wrapper (gradle wrapper
) in the project root directory to generate gradlew
, gradlew.bat
, and gradle/wrapper/gradle-wrapper.jar
.
Execute ./gradlew check
and you will see that the tests are run three times, on JVM, on JS, and on Native!
If we want, we can add tests to a specific platform, then it will be executed only as part of these platform tests.
For example, we can add UTF-16 tests on JVM. Just follow the same steps as before, but create file in jvmTest/kotlin/jetbrains/base64
:
class Base64JvmTest { @Test fun testNonAsciiString() { val utf8String = "Gödel" val actual = Base64Factory.createEncoder().encodeToString(utf8String.toByteArray()) assertEquals("R8O2ZGVs", actual) } }
This test will be automatically executed on the JVM target in addition to the common part.
Our first multiplatform library is almost ready. The last step is to publish it, so other projects can then depend on our library.
Use the maven-publish
Gradle plugin.
Don't forget to specify the group and version of your library along with the plugin in build.gradle
:
apply plugin: 'maven-publish' group 'org.jetbrains.base64' version '1.0.0'
Now check it with the command ./gradlew publishToMavenLocal
and you should see a successful build.
That's it, our library is now successfully published and any Kotlin project can depend on it, whether it is another common library, JVM, JS, or Native application.
In this tutorial we have:
© 2010–2020 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/tutorials/mpp/multiplatform-library.html