Last Updated | 4 October 2018 |
In this tutorial, we will build a small library available from the worlds of JVM, JS, and Native. You will learn step-by-step how to create a multiplatform library which can be used from any other common code (e.g., one shared with Android and iOS), and how to write tests which will be executed on all platforms and use an efficient implementation provided by the concrete 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 K/N platform. On JVM implementation will be using 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 be using 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 resulting library to Maven.
We will be using IntelliJ IDEA Community Edition for this tutorial, though using Ultimate edition is possible as well. The Kotlin plugin 1.3.x or higher should be installed in the IDE. This can be verified via the Language & Frameworks | Kotlin Updates section in the Settings (or Preferences) of the IDE. 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.
We will be using IntelliJ IDEA Community Edition for the example. You need to make sure you have the latest version of the Kotlin plugin installed, 1.3.x or newer. We select File | New | Project, select Kotlin | Kotlin (Multiplatform Library) and configure the project in the way we want.
A multiplatform sample library is now created and imported into IntelliJ IDEA. Let's 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. You can read more about platform specific declarations here.
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. I won't explain the implementation details here, but 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 move to 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. All the bits for testing are already generated by the project Wizard.
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()) } } } }
Notice 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() } }
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. To make the publishing mechanism work, you should enable the experimental Gradle feature in settings.gradle
:
enableFeaturePreview('GRADLE_METADATA')
Now the classic maven-publish
Gradle plugin can be used. 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–2019 JetBrains s.r.o.
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/tutorials/multiplatform-library.html