Practical Code Sharing with Kotlin Multiplatform Part (2/3)

by

In the first part of this series we gave a brief overview of Kotlin Multiplatform (KMP). In part 2 and 3 we will discuss best practices and patterns for code sharing between different environments and languages. In our projects we are developing a cross-platform framework targeting Java/JVM as well as Kotlin and JavaScript/Web runtime environments.

Part 2 will discuss common and JVM-related issues, whereas part 3 will focus on JavaScript/TypeScript traps and pitfalls.

Common code

A KMP project generally has a layout with a source folder for common code, and one for each target platform (plus tests of course, which we omit for brevity here). The common code folder contains as well expected templates as plain Kotlin classes which need no further differentiation.

In the last part of this series we already gave an example of implementing expected templates with actual classes.

Simple classes

When adding code to the library, what is the best place for it and how will you have to adapt it before usage? These are the questions we want to handle in the next sections. Let’s start with the some easy cases.

Enum classes

Enumerations usually do not cause any problems, you may place them directly in the common folder:

enum class Status(val name: String) {
  NEW("New"), OPEN("Open"), IN_PROGRESS("Work in progress"),
  DONE("Completed");

  override fun toString() = name
}

Data classes

Simple DTO classes just holding some state are also easy to implement as common classes, even if they contain some derived state methods:

data class CostCenter(val hasCostCenter: Boolean,
                      val referenceUnit: CostUnit) {
  val status : Status get() = if (hasCostCenter) {
    if (this.referenceUnit == DEPARTMENT1) {
      Status.OPEN
    } else {
      Status.NEW
    }
  } else {
    Status.IN_PROGRESS
  }
}

You have to bear in mind here, that in terms of JSON serialization the derived property “status” will be added to the JSON output, too. If you do not want this, you are better off by converting the property into a method, say “computeStatus()”.

Platform-specific code

Common functionality may include services whose implementation depends on the current platform. For example, a DownloadService to load resources from the web. The I/O code involved heavily depends on the target platform implementation.

expect class DownloadService {
    fun download(url: String) : ByteArray
}

This adds technical complexity that must be resolved separately for each target.

First try: Look for a Kotlin implementation

For many use cases like JSON-serialization or HTTP handling there already exist frameworks implementing the required functionality. For example, you might want to use the Ktor library for the JavaScript download service:

import io.ktor.client.*
import io.ktor.http.*

class DownloadService {
    private val client = HttpClient(Js)

    actual fun download(url: String): ByteArray {
       val response: HttpResponse = client.get(url) {
           contentType(ContentType.Application.Json)
       }

       return response.bodyAsBytes()
    }
}

Second try: Use platform-dependent libraries

If the first approach causes too much effort or is not feasible, you’ll have to choose an implementation for each platform individually. In the Java world you might, e.g, implement the given functionality using the popular Apache HTTP client:

import org.apache.hc.client5.http.impl.classic.*

actual class DownloadService {
    private var httpClient = HttpClientBuilder.create().build()

    actual fun download(url: String): ByteArray =
        httpClient.execute(HttpGet(url)) { response ->
            EntityUtils.toByteArray(response.entity)
        }
}

Common code “stubs”

Another category of classes that is hard to be shared are complex (legacy) classes. Often too many other classes relate to them and domain-specific or technical dependencies such as a complicated inheritance hierarchies prevent a clean cut.

In such cases we recommend to introduce common interfaces instead that each platform may implement differently. To give an example:

interface CommonOrder : CommonIdentifiable {
  val name: String
  val date: Date
  val invoice: CommonInvoice
  ...
}

Instead of directly implementing CommonOrder for the legacy class we recommend to provide an adapter class for the shared code framework:

class OrderAdapter(val order: LegacyOrder) : CommonOrder {
  override val name get() = order.orderName
  ...
}

Sharing code for “stubbed” classes

In order to share code operating on stubbed classes you should use Kotlin’s extension functions:

interface CommonOrder : CommonIdentifiable {
   ...
}

fun CommonOrder.getOrderType() = if(...) ...

or, to be more compatible with Java code, interface default methods:

interface CommonOrder : CommonIdentifiable {
   ...
   fun getLastUpdate(): Date? { ... }
}

Conclusions

Code sharing between different platforms by introducing a common framework is quite challenging, but may be worthwhile. Trransformation may be a lot of work especially when you have to move legacy code to the framework, but the benefits of shared code versus reimplementation outweigh the problems, at least for our projects.

Discover more from Spree-Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading

Cookie Consent Banner by Real Cookie Banner