As mobile developers, we often rewrite the same logic in another language, maintaining two similar codebases for Android and iOS. Wouldn’t it be nice to write Kotlin once and compile it everywhere? Enter Kotlin Multiplatform, which enables us to write the same code for the JVM as well as LLVM!

The project is getting more and more traction in the community right now. It is also heavily supported by JetBrains and Google, which probably means that this is more than yet another hyped cross-platform framework.

Following the write once, compile everywhere pattern, Kotlin multiplatform code is regular Kotlin code compiled to JVM bytecode for Android and to LLVM bytecode for iOS. The concept of compiling a shared library to native code for both mobile platforms is not new. For instance, it is possible to write a shared library in C++ and integrate it in Android via JNI and iOS via Swift/C interoperability. However, Kotlin multiplatform takes this idea a step further and handles (most of) the difficult parts of the integration. And after all, Kotlin is a great language and we think its use should not be limited to the JVM!

For us mobile developers at inovex, several questions arise:

Is it ready for production yet? Can we use Kotlin multiplatform to build apps following clean architecture guidelines? How much code can be shared between platforms? In other words, how much development time (=money) can be scrapped by using Kotlin MP?

It is hard to fully answer these questions without releasing multiple apps into production and evaluating the results, i.e., feeling the pain of Kotlin multiplatform being still beta and in active development. Before we take that path, we could build a minimal example to evaluate whether Kotlin multiplatform is feasible for the next Android and iOS project at inovex.

Goal

Our primary goal is to build a minimal sample app which uses the MVVM pattern with the components persistence layer (i.e., database), repositories, view models and a HTTP-client to sync data. All components should be kept in the shared codebase, only the code for the UI should be platform-specific.

In terms of functionality, the app loads a list of todos (read: strings) from a public lorem-ipsum JSON-API and displays them in a list. The todos are persisted in the database.

The following part of this post covers the example in detail, but you can also skip to the end if you just want to know our opinion on Kotlin MP.

You can find the final code on GitHub: https://github.com/inovex/kotlin-multiplatform-sample

Scaffolding

For our first Hello world app we followed the official tutorial from JetBrains. The tutorial is pretty concise, the only hiccup was that Gradle source set names are case-sensitive and we first used iosMain instead of iOSMain.

sourceSets { commonMain { // ... } androidMain { // ... } iOSMain { // this ain't camelCase! :( // ... } } 1 2 3 4 5 6 7 8 9 10 11 12 13 sourceSets { commonMain { // ... } androidMain { // ... } iOSMain { // this ain't camelCase! :( // ... } }

As our primary IDE we use Android Studio 3.5 beta. iOS builds are tested with Xcode, of course. IDE support for Kotlin multiplatform in Android Studio is not complete at the moment and the builds take very long. However, Google is working on better IDE support, as we heard on the Conference for Kotliners this year. Let’s hope that build times can be improved, too.

Persistence

For a shared database, we use the SQLite wrapper SQLDelight. The framework uses a database scheme followed by queries for data access written in plain SQLite to generate all necessary classes like data access objects and such. The documentation on GitHub is a bit outdated. We recommend using the latest version of SQLDelight and refer to our sample code for the details.

CREATE TABLE todo( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed INTEGER ); selectAll: SELECT * FROM todo; insert: INSERT INTO todo(title, completed) VALUES (?, ?); deleteAll: DELETE FROM todo; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 CREATE TABLE todo( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL , completed INTEGER ); selectAll: SELECT * FROM todo; insert : INSERT INTO todo(title, completed) VALUES (?, ?); deleteAll: DELETE FROM todo;

In the Gradle file, the database is configured as follows:

sqldelight { Database { packageName = "com.inovex.cpsample.shared" } } 1 2 3 4 5 sqldelight { Database { packageName = "com.inovex.cpsample.shared" } }

The SQLDelight Gradle plugin will generate a Kotlin class called Database (if no other name is specified). To add an entry to the Todo table, all we need is a simple method call:

fun addTodo(title: String, completed: Boolean) { val completedNum = if (completed) 1L else 0L database.databaseQueries.insert(title, completedNum) } 1 2 3 4 fun addTodo ( title : String , completed : Boolean ) { val completedNum = if ( completed ) 1L else 0L database . databaseQueries . insert ( title , completedNum ) }

Note that booleans are not supported, thus we have to convert the flag to an SQLite INTEGER, which is handled as Long in Kotlin.

SELECT queries can also be monitored for changes, which helps us to emulate a simplified version of Android LiveData in our view model later. We keep track of registered observers using unique numerical IDs.

fun observeTodos(id: Int, onChangeCallback: (List<Todo>) -> Unit) { if (observers.containsKey(id)) { throw RuntimeException("Already observing with id $id") } else { val listener = object : Query.Listener { override fun queryResultsChanged() { onChangeCallback(database.databaseQueries.selectAll().executeAsList()) } } observers[id] = listener database.databaseQueries.selectAll().addListener(listener) } } fun stopObservingTodos(id: Int) { observers[id]?.let { database.databaseQueries.selectAll().removeListener(it) } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 fun observeTodos ( id : Int , onChangeCallback : ( List < Todo > ) -> Unit ) { if ( observers . containsKey ( id ) ) { throw RuntimeException ( "Already observing with id $id" ) } else { val listener = object : Query . Listener { override fun queryResultsChanged ( ) { onChangeCallback ( database . databaseQueries . selectAll ( ) . executeAsList ( ) ) } } observers [ id ] = listener database . databaseQueries . selectAll ( ) . addListener ( listener ) } } fun stopObservingTodos ( id : Int ) { observers [ id ] ? . let { database . databaseQueries . selectAll ( ) . removeListener ( it ) } }

To make SQLDelight actually work on both platforms, it needs a platform-specific database driver. We use the actual/expect pattern for this task. For iOS, this works out of the box, for Android it does not because we need the famous Android Context to initialize the driver.

// in comonMain source set expect fun getDriver(): SqlDriver? // implementation in iOSMain source set actual fun getDriver(): SqlDriver? { return NativeSqliteDriver(Database.Schema, "main.db") } // no implementation in androidMain source set actual fun getDriver(): SqlDriver? { return null } // in Android activity (can also be done in Application class): val driver = AndroidSqliteDriver(Database.Schema, this.applicationContext, "main.db") CommonDatabase.driver = driver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // in comonMain source set expect fun getDriver ( ) : SqlDriver ? // implementation in iOSMain source set actual fun getDriver ( ) : SqlDriver ? { return NativeSqliteDriver ( Database . Schema , "main.db" ) } // no implementation in androidMain source set actual fun getDriver ( ) : SqlDriver ? { return null } // in Android activity (can also be done in Application class): val driver = AndroidSqliteDriver ( Database . Schema , this . applicationContext , "main.db" ) CommonDatabase . driver = driver

HTTP client

As Android devs, we are happy to use OkHTTP and Retrofit to build API clients. With Kotlin multiplatform, we cannot use these Java libraries. Fortunately, the new Ktor framework provides an HTTP client ready to be used in Kotlin multiplatform projects. Integration using Gradle dependencies is described in the official documentation.

As a replacement for Gson, we use Kotlinx-Serialization to convert JSON strings to Kotlin classes and vice-versa.

With these libraries and the wonderful SQLDelight database interface our API client is pretty small:

internal class Api(private val repo: TodoRepository) { private val client = HttpClient() val baseUrl = "https://jsonplaceholder.typicode.com/" @Serializable data class ApiTodo(val title: String, val completed: Boolean) fun getTodos(): Job { val url = baseUrl + "todos/" return GlobalScope.launch(applicationDispatcher) { val result = client.call { url(url) }.response.readText() val apiTodos = Json.nonstrict.parse(ApiTodo.serializer().list, result) CommonDatabase.database.transaction { apiTodos.forEach { repo.addTodo(it.title, it.completed) } } } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 internal class Api ( private val repo : TodoRepository ) { private val client = HttpClient ( ) val baseUrl = "https://jsonplaceholder.typicode.com/" @ Serializable data class ApiTodo ( val title : String , val completed : Boolean ) fun getTodos ( ) : Job { val url = baseUrl + "todos/" return GlobalScope . launch ( applicationDispatcher ) { val result = client . call { url ( url ) } . response . readText ( ) val apiTodos = Json . nonstrict . parse ( ApiTodo . serializer ( ) . list , result ) CommonDatabase . database . transaction { apiTodos . forEach { repo . addTodo ( it . title , it . completed ) } } } } }

Did you notice the applicationDispatcher co-routine scope? Correct, we can use Kotlin co-routines on iOS and Android! Initialization is a bit of a bummer, though. On Android, it is as simple as

actual val mainDispatcher = Dispatchers.Main as CoroutineDispatcher actual val backgroundDispatcher = Dispatchers.Default 1 2 actual val mainDispatcher = Dispatchers . Main as CoroutineDispatcher actual val backgroundDispatcher = Dispatchers . Default

On iOS, we copied the code from this issue’s comment to use iOS’s dispatch_async on the main queue for our co-routine dispatcher. Kotlin Native—the LLVM compiler used in Kotlin multiplatform—does not yet support co-routines on background threads, using a background queue will cause crashes instead. There is an open issue in the Kotlinx repo and we hope there will be a fix soon, because we do not want to run network calls on the main thread in production!

actual val mainDispatcher = object : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { dispatch_async(dispatch_get_main_queue()) { block.run() } } } actual val backgroundDispatcher = mainDispatcher // :'( 1 2 3 4 5 6 7 8 9 actual val mainDispatcher = object : CoroutineDispatcher ( ) { override fun dispatch ( context : CoroutineContext , block : Runnable ) { dispatch_async ( dispatch_get_main_queue ( ) ) { block . run ( ) } } } actual val backgroundDispatcher = mainDispatcher // :'(

Repositories

The TodoRepository serves as a proxy class for the database. As a wrapper around the generated Database class we use a singleton CommonDatabase.

@ThreadLocal object CommonDatabase { var driver: SqlDriver? = getDriver() val database: Database by lazy { Database(driver!!) } private val observers: MutableMap<Int, Query.Listener> = mutableMapOf() fun addTodo(title: String, completed: Boolean) { val completedNum = if (completed) 1 else 0 database.databaseQueries.insert(title, completedNum.toLong()) } // [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @ ThreadLocal object CommonDatabase { var driver : SqlDriver ? = getDriver ( ) val database : Database by lazy { Database ( driver ! ! ) } private val observers : MutableMap < Int , Query . Listener > = mutableMapOf ( ) fun addTodo ( title : String , completed : Boolean ) { val completedNum = if ( completed ) 1 else 0 database . databaseQueries . insert ( title , completedNum . toLong ( ) ) } // [...] }

Because of Kotlin Native’s object freezing behavior, singletons do not work very well with multi-threaded access on iOS. To prevent crashes on iOS, we use the @ThreadLocal annotation, which creates one copy of the object on each thread. It works for this example, but handle with care!

When you get started with Kotlin multiplatform, prepare to run into such issues from time to time. Most things work without problems on Android because of the underlying JVM, but there might be some quirks when the same code is compiled to native LLVM bytecode.

View models

To complete our architecture stack, we introduce a view model for the list of todos.

class TodoViewModel(private val repo: TodoRepository = TodoRepository()) { private val id: Int = idCounter.getAndIncrement() private val api by lazy { Api(repo) } fun observeTodos(onChangeCallback: (List<Todo>) -> Unit) { repo.observeTodos(id) { GlobalScope.launch(mainDispatcher) { onChangeCallback(it) } } } fun clearTodos() { repo.deleteAll() } fun triggerSync() { api.getTodos() } fun onDestroy() { repo.stopObservingTodos(id) } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class TodoViewModel ( private val repo : TodoRepository = TodoRepository ( ) ) { private val id : Int = idCounter . getAndIncrement ( ) private val api by lazy { Api ( repo ) } fun observeTodos ( onChangeCallback : ( List < Todo > ) -> Unit ) { repo . observeTodos ( id ) { GlobalScope . launch ( mainDispatcher ) { onChangeCallback ( it ) } } } fun clearTodos ( ) { repo . deleteAll ( ) } fun triggerSync ( ) { api . getTodos ( ) } fun onDestroy ( ) { repo . stopObservingTodos ( id ) } }

The view model is our interface towards the platform-specific implementation. From Swift, it can be used as follows:

var todos: [String]? let viewModel = TodoViewModel(repo: TodoRepository()) override func viewDidLoad() { super.viewDidLoad() viewModel.observeTodos(onChangeCallback: { (list: [Todo]) -> KotlinUnit in self.todos = list.map { (t) -> String in return t.title } self.tableView.reloadData() return KotlinUnit() }) syncData(self) } 1 2 3 4 5 6 7 8 9 10 11 12 var todos : [ String ] ? let viewModel = TodoViewModel ( repo : TodoRepository ( ) ) override func viewDidLoad ( ) { super . viewDidLoad ( ) viewModel . observeTodos ( onChangeCallback : { ( list : [ Todo ] ) -> KotlinUnit in self . todos = list . map { ( t ) -> String in return t . title } self . tableView . reloadData ( ) return KotlinUnit ( ) } ) syncData ( self ) }

Since our callback is expected to return the Kotlin type Unit, we have to return an instance of KotlinUnit in Swift.

On Android, observing the data works similar:

viewModel.observeTodos { todos -> if (adapter == null) { adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, todos.map { it.title }) todoList.adapter = adapter } else { adapter!!.apply { clear() todos.forEach { insert(it.title, count) } notifyDataSetChanged() } } Toast.makeText(this, "Updated TODO list", Toast.LENGTH_SHORT).show() } override fun onDestroy() { viewModel.onDestroy() super.onDestroy() } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 viewModel . observeTodos { todos -> if ( adapter == null ) { adapter = ArrayAdapter < String > ( this , android . R . layout . simple_list_item_1 , todos . map { it . title } ) todoList . adapter = adapter } else { adapter ! ! . apply { clear ( ) todos . forEach { insert ( it . title , count ) } notifyDataSetChanged ( ) } } Toast . makeText ( this , "Updated TODO list" , Toast . LENGTH_SHORT ) . show ( ) } override fun onDestroy ( ) { viewModel . onDestroy ( ) super . onDestroy ( ) }

Please note that advanced architecture features like saved-state View Models and LiveData are not available. For smaller apps, we think it is feasible to implement by hand, for larger apps, we will have to wait for advanced libraries aimed at Kotlin multiplatform.

Tests

We thought about keeping the Unit tests in the shared codebase. The problem is that no JVM and JUnit framework can be used here. Thus we decided to put the Unit test for the TodoViewModel class into the Android JUnit test folder.

class TodoViewModelTest { private val mockedRepo = mockk<TodoRepository>(relaxed = true) private val viewModel = TodoViewModel(mockedRepo) private val mainThreadSurrogate = newSingleThreadContext("UI thread") @Before fun init() { Dispatchers.setMain(mainThreadSurrogate) } @Test fun testObserveTodos() { val latch = CountDownLatch(1) var resultList: List<Todo>? = null val mockedTodo = mockk<Todo>() every { mockedRepo.observeTodos(any(), any()) } answers { secondArg<(List<Todo>) -> Unit>().invoke(listOf(mockedTodo)) } viewModel.observeTodos { resultList = it latch.countDown() } latch.await(500L, TimeUnit.MILLISECONDS) assert(resultList != null) } // [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class TodoViewModelTest { private val mockedRepo = mockk < TodoRepository > ( relaxed = true ) private val viewModel = TodoViewModel ( mockedRepo ) private val mainThreadSurrogate = newSingleThreadContext ( "UI thread" ) @ Before fun init ( ) { Dispatchers . setMain ( mainThreadSurrogate ) } @ Test fun testObserveTodos ( ) { val latch = CountDownLatch ( 1 ) var resultList : List < Todo > ? = null val mockedTodo = mockk < Todo > ( ) every { mockedRepo . observeTodos ( any ( ) , any ( ) ) } answers { secondArg < ( List < Todo > ) -> Unit > ( ) . invoke ( listOf ( mockedTodo ) ) } viewModel . observeTodos { resultList = it latch . countDown ( ) } latch . await ( 500L , TimeUnit . MILLISECONDS ) assert ( resultList != null ) } // [...] }

The advantage for us Android developers is that we have all the tools available we know from regular Android apps, like Mockk for example. Mocking is also a lot easier on the JVM than on native platforms. A small disadvantage is that the common code is not unit-tested on iOS. Of course, we might naively assume that the behavior is the same as on the JVM, but experience in this first project taught us that this is not true. For instance, consider the object freezing issues mentioned above.

Verdict

Our minimal sample app enables us to answer the three questions from the beginning:

Production readiness: For small projects, Kotlin multiplatform feels manageable. For larger projects, we would suggest to wait until IDE support in Android Studio and/or Xcode has improved.

Stack traces for crashes are human readable on both the JVM and LLVM, so crash monitoring on production is possible.

A major showstopper at the moment is the lack of support for co-routines on a background thread—JetBrains is working on it, though.

We did not test Integration with CI/CD yet but we believe it can be achieved in the usual ways known from single-platform Android and iOS app projects. Building apps with clean architecture? Yes, it is possible to some extent. Check out the example code and judge for yourself. Code sharing between platforms? This was better than expected—55% of total LOC was in the shared code base.

Platform LOC (including blanks) Percentage Shared (including tests) 212 55% Android (without XML-resources) 83 22% iOS 91 23%

Based on the aforementioned results we strongly believe that Kotlin multiplatform is a technology worth pursuing. Of course, we are very interested in your opinion on this topic, so please drop a comment below.

Lastly, a friendly note to any sales person reading this: We know you will do the math, scrapping 33% off the budget on the next project because 50% of the code can be shared between Android and iOS. Please don’t be too optimistic—cross-platform code is harder to write than single-platform code, even in Kotlin 😊 But we still think that sharing code will save some time in the end and also make maintenance of the code base for both platforms less of an effort.