Building Multiplatform Project on Kotlin/Native

The Mobius 2018 conference held in Saint Petersburg earlier this year featured a talk by the guys from Revolut – Roman Yatsina and Ivan Vazhnov, called Multiplatform architecture with Kotlin for iOS and Android.

After watching the live talk, I wanted to try out how Kotlin/Native handles multiplatform code that can be used on both iOS and Android. I decided to rewrite the demo project from the talk a little bit so it could load the list of user’s public repositories from GitHub with all the branches to each repository.

Project structure

multiplatform
├─ android
├─ common
├─ ios
├─ platform-android
└─ platform-ios

Common module

common is the shared module that only contains Kotlin with no platform-specific dependencies. It can also contain interfaces and class/function declarations without implementations relying on a certain platform. Such declarations allow using the platform-dependent code in the common module.

In my project, this module encompasses the business logic of the app – data models, presenters, interactors, UIs for GitHub access with no implementations.

Some examples of the classes

UIs for GitHub access:

expect class ReposRepository {
     suspend fun getRepositories(): List<GithubRepo>
     suspend fun getBranches(repo: GithubRepo): List<GithubBranch>
}

Take a look at the expect keyword. It is a part of the expected and actual declarations. The common module can declare the expected declaration that has the actual realization in the platform modules. By the expect keyword we can also understand that the project uses coroutines which we’ll talk about later.

Interactor:

class ReposInteractor(
        private val repository: ReposRepository,
        private val context: CoroutineContext
) {

suspend fun getRepos(): List<GithubRepo> {
    return async(context) { repository.getRepositories() }
            .await()
            .map { repo ->
                 repo to async(context) {
                      repository.getBranches(repo)
                 }
            }
             .map { (repo, task) ->
                  repo.branches = task.await()
                  repo
            }
    }
}

The interactor contains the logic of asynchronous operations interactions. First, it loads the list of repositories with the help of getRepositories() and then, for each repository it loads the list of branches getBranches(repo). The async/await mechanism is used to build the chain of asynchronous calls.

ReposView interface for UI:

interface ReposView: BaseView {
    fun showRepoList(repoList: List<GithubRepo>)
    fun showLoading(loading: Boolean)
    fun showError(errorMessage: String)
}

The presenter

The logic of UI usage is specified equally for both the platforms.

class ReposPresenter(
        private val uiContext: CoroutineContext,
        private val interactor: ReposInteractor
) : BasePresenter<ReposView>() {

    override fun onViewAttached() {
        super.onViewAttached()
        refresh()
    }
    fun refresh() {
        launch(uiContext) {
            view?.showLoading(true)
            try {
                val repoList = interactor.getRepos()
                view?.showRepoList(repoList)
            } catch (e: Throwable) {
                 view?.showError(e.message ?: "Can't load repositories")
            }
            view?.showLoading(false)
       }
    }
}

What else could be included in the common module

Among all the rest, the JSON parsing logic could be included into the common module. Most of the projects contain this logic in a complicated form. Implementing it in the common module could guarantee similar treatment of the server incoming data for iOS and Android.

Unfortunately, in the kotlinx.serialization serialization library the support of Kotlin/Native is not yet implemented.

A possible solution could be writing your own or porting one of the simpler Java-based libraries for Kotlin. Without using reflections or any other third-party dependencies. However, this type of work goes beyond just a test project ?‍♂️

Platform modules

The platform-android and platform-ios platform modules contain both the platform-dependent implementation of UIs and classes declared in the common module, and any other platform-specific code. Those modules are also written with Kotlin.

Let’s look at the ReposRepository class implementation declared in the common module.

platform-android

actual class ReposRepository(
        private val baseUrl: String,
        private val userName: String
) {
    private val api: GithubApi by lazy {
         Retrofit.Builder()
                 .addConverterFactory(GsonConverterFactory.create())

.addCallAdapterFactory(CoroutineCallAdapterFactory())
              .baseUrl(baseUrl)
              .build()
              .create(GithubApi::class.java)
}

actual suspend fun getRepositories() =
       api.getRepositories(userName)
              .await()
              .map { apiRepo -> apiRepo.toGithubRepo() }

actual suspend fun getBranches(repo: GithubRepo) =
        api.getBranches(userName, repo.name)
              .await()
              .map { apiBranch -> apiBranch.toGithubBranch() }
}

In the Android implementation, we use the Retrofit library with an adaptor converting the calls into a coroutine-compatible format. Note the actual keyword we’ve mentioned above.

platform-ios

actual open class ReposRepository {

    actual suspend fun getRepositories(): List<GithubRepo> {
        return suspendCoroutineOrReturn { continuation ->
            getRepositories(continuation)
            COROUTINE_SUSPENDED
        }
    }

    actual suspend fun getBranches(repo: GithubRepo): List<GithubBranch> {
         return suspendCoroutineOrReturn { continuation ->
              getBranches(repo, continuation)
              COROUTINE_SUSPENDED
         }
     }
     open fun getRepositories(callback: Continuation<List<GithubRepo>>) {
          throw NotImplementedError("iOS project should implement this")
     }

     open fun getBranches(repo: GithubRepo, callback: Continuation<List<GithubBranch>>) {
          throw NotImplementedError("iOS project should implement this")
     }
}

You can see the actual implementation of the ReposRepository class for iOS in the platform module does not contain the specific implementation of server interactions. Instead of this, the suspendCoroutineOrReturn code is called from the standard Kotlin library and allows us to interrupt the execution and get the continuation callback which has to be called upon the completion of the background process. This callback is then passed to the function which will be re-specified in the Xcode project where all the server interaction will be implemented (in Swift or Objective-C).  The COROUTINE_SUSPENDED value signifies the suspended state and the result will not be returned immediately.

iOS app

The following is an Xcode project that uses the platform-ios module as a generic Objective-C framework.

To assemble platform-ios into a framework, use the konan Gradle plugin. Its settings are in the platform-ios/build.gradle file:

apply plugin: 'konan'

konanArtifacts {
     framework('KMulti', targets: ['iphone_sim']) {
...

KMulti is a prefix for the framework. All the Kotlin classes from the common and platform-ios modules in the Xcode project will have this prefix.

After the following command,

./gradlew :platform-ios:compileKonanKMultiIphone_sim

the framework can be found under:

/kotlin_multiplatform/platform-ios/build/konan/bin/ios_x64

It has to be added to the Xcode project.

This is how a specific implementation of the ReposRepository class looks like. The interaction with a server is done by means of the Alamofire library.

class ReposRepository: KMultiReposRepository {
   ...
   override func getRepositories(callback: KMultiStdlibContinuation) {
      let url = baseUrl.appendingPathComponent("users/\(githubUser)/repos")
      Alamofire.request(url)
         .responseJSON { response in
            if let result = self.reposParser.parse(response: response) {
               callback.resume(value: result)
            } else {
              callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github repositories"))
            }
       }
   }
   override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) {
       let url = baseUrl.appendingPathComponent("repos/\(githubUser)/\(repo.name)/branches")
       Alamofire.request(url)
         .responseJSON { response in
         if let result = self.branchesParser.parse(response: response) {
            callback.resume(value: result)
         } else {
           callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github branches"))
           }
       }
   }
}

Android app

With an Android project it is all fairly simple. We use a conventional app with a dependency on the platform-android module.

dependencies {
      implementation project(':platform-android')

Essentially, it consists of one ReposActivity which implements the ReposView interface.

override fun showRepoList(repoList: List<GithubRepo>) {
     adapter.items = repoList
     adapter.notifyDataSetChanged()
}

override fun showLoading(loading: Boolean) {
    loadingProgress.visibility = if (loading) VISIBLE else GONE
}

override fun showError(errorMessage: String) {
    Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show()
}

Coroutines, apples, and magic

Speaking of coroutines and magic, in fact, at the moment coroutines are not yet supported by Kotlin/Native. The work in this direction is ongoing. So how on Earth do we use the async/await coroutines and functions in the common module? Let alone in the platform module for iOS.

As a matter of fact, the async and launch expect functions, as well as the  Deferred class, are specified in the common module. These signatures are copied from kotlinx.coroutines.

import kotlin.coroutines.experimental.Continuation
import kotlin.coroutines.experimental.CoroutineContext

expect fun <T> async(context: CoroutineContext, block: suspend () -> T): Deferred<T>

expect fun <T> launch(context: CoroutineContext, block: suspend () -> T)

expect suspend fun <T> withContext(context: CoroutineContext, block: suspend () -> T): T

expect class Deferred<out T> {
    suspend fun await(): T
}  

Android coroutines

In the platform-android platform module, declarations are mapped into their implementations from kotlinx.coroutines:

actual fun <T> async(context: CoroutineContext, block: suspend () -> T): Deferred<T> {
     return Deferred(async {
          kotlinx.coroutines.experimental.withContext(context, block = block)
     })
}

iOS coroutines

With iOS things are a little more complicated. As mentioned above, we pass the continuation(KMultiStdlibContinuation) callback to the functions that have to work asynchronously. Upon the completion of the work, the appropriate resume or resumeWithException method will be requested from the callback:

 override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) {
     let url = baseUrl.appendingPathComponent("repos/\(githubUser)/\(repo.name)/branches")
     Alamofire.request(url)
        .responseJSON { response in
          if let result = self.branchesParser.parse(response: response) {
             callback.resume(value: result)
           } else {
             callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github branches"))
           }
      }
}

In order for the result to return from the suspend function, we need to implement the ContinuationInterceptor interface. This interface is responsible for how the callback is being processed, specifically which thread the result (if any) will be returned in. For this, the interceptContinuation function is used.

abstract class ContinuationDispatcher :
        AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {

    override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> {
        return DispatchedContinuation(this, continuation)
    }

    abstract fun <T> dispatchResume(value: T, continuation: Continuation<T>): Boolean
    abstract fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean
}

internal class DispatchedContinuation<T>(
        private val dispatcher: ContinuationDispatcher,
        private val continuation: Continuation<T>
) : Continuation<T> {

    override val context: CoroutineContext = continuation.context

    override fun resume(value: T) {
        if (dispatcher.dispatchResume(value, continuation).not()) {
            continuation.resume(value)
        }
    }

    override fun resumeWithException(exception: Throwable) {
        if (dispatcher.dispatchResumeWithException(exception, continuation).not()) {
            continuation.resumeWithException(exception)
        }
    }
}

In ContinuationDispatcher there are abstract methods implementation of which will depend on the thread where the executions will be happening.

Implementation for UI threads

import platform.darwin.*

class MainQueueDispatcher : ContinuationDispatcher() {

    override fun <T> dispatchResume(value: T, continuation: Continuation<T>): Boolean {
        dispatch_async(dispatch_get_main_queue()) {
            continuation.resume(value)
        }
        return true
    }

    override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean {
        dispatch_async(dispatch_get_main_queue()) {
            continuation.resumeWithException(exception)
        }
        return true
    }
}

Implementation for background threads

import konan.worker.*

class DataObject<T>(val value: T, val continuation: Continuation<T>)
class ErrorObject<T>(val exception: Throwable, val continuation: Continuation<T>)

class AsyncDispatcher : ContinuationDispatcher() {

    val worker = startWorker()

    override fun <T> dispatchResume(value: T, continuation: Continuation<T>): Boolean {
        worker.schedule(TransferMode.UNCHECKED, {DataObject(value, continuation)}) {
            it.continuation.resume(it.value)
        }
        return true
    }

    override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean {
        worker.schedule(TransferMode.UNCHECKED, {ErrorObjeвыct(exception, continuation)}) {
            it.continuation.resumeWithException(it.exception)
        }
        return false
    }
}

Now we can use the asynchronous manager in the interactor:

let interactor = KMultiReposInteractor(
   repository: repository,
   context: KMultiAsyncDispatcher()
)

And the main thread manager in the presenter:

let presenter = KMultiReposPresenter(
   uiContext: KMultiMainQueueDispatcher(),
   interactor: interactor
)

Key takeaways

The pros:

  • The developers implemented a rather complicated (asynchronous) business logic and common module data depiction logic.
  • You can develop native apps using native libraries and instruments (Android Studio, Xcode). All of the native platforms’ capabilities are available through Kotlin/Native.
  • It goddamn works!

The cons:

  • All the Kotlin/Native solutions in the project are yet in the experimental status. Using features like this in the production code is not a good idea.
  • No support for coroutines for Kotlin/Native out of the box. Hopefully, this issue will be solved in the near future. This would allow developers to significantly speed up the process of multiplatform projects creation while also simplifying it.
  • An iOS project will only work on the arm64 devices (models starting from iPhone 5S).

References

The original article (in Russian).