Kotlin Multiplatform + Rx + MVVM

Hossein Abbasi
ProAndroidDev
Published in
11 min readJul 27, 2020

--

Image by wallpaperaccess.com

What is better for a developer than writing code once and run it on various platforms, natively? (but in real! and not like Java which runs anywhere(WORA) that a JVM only exists!)

Let me quote from Kotlin website to show you the power of Kotlin and what we’re gonna talk about in this article:

Working on all platforms is an explicit goal for Kotlin, but we see it as a premise to a much more important goal: sharing code between platforms. With support for JVM, Android, JavaScript, iOS, Linux, Windows, Mac and even embedded systems like STM32, Kotlin can handle any and all components of a modern application. And this brings the invaluable benefit of reuse for code and expertise, saving the effort for tasks more challenging than implementing everything twice or multiple times.

So, let’s start! 💪🏼

IDEs 🖥

  • Android Studio: v4.2
  • Xcode: v12.4

Note: It is possible to use IntelliJ IDEA IDE as well, but in that case, we would configure some other stuff like enabling Android Support in Plugin, gradle wrapper, etc. Yes, IntelliJ IDEA has its own benefits as well like predefined gradle files, but I’d prefer to work with the official IDEs.

Libraries 📚

  • Kotlin: v1.5.0
  • Reaktive(Kotlin multiplatform implementation of RXs): v1.1.22
  • Ktor(asynchronous Web framework for Kotlin): v1.5.4
  • Kodein(A multiplatform Dependency Injection library): 7.5.0

Goal 🎯

Step by step creating a working sample app, then at the end, a simple IMDb App that shows a list of movies fetches from TMDb API.

Source code:

Let’s start

1- Create an Empty Activity Android project.

2- Rename app module to android module; And do the same in settings.gradle.kts .

3- Create a shared folder(or any name you want) in the root of the project. This is the place we’re gonna put the shared business logic between platforms.

4- Add shared module to the settings.gradle.kt.

5- shared folder is gonna contain iosMain (actual), androidMain (actual) and commonMain (expect) source sets.

6- Note: Read more about actual and expect keywords here.

7- Create build.gradle.kts in shared module and put this:

We recommend that you disable automatic build import, but enable automatic reloading of script dependencies. That way you get early feedback while editing Gradle scripts and control over when the whole build setup gets synchronized with your IDE.

And yes, a few disadvantages on Android Studio(👉 not anymore on v4.0.0 and above 👈) like we’re gonna miss “Project Structure”:

9- Note: To targeting android, we must use android() and notjvm("android") .

10- The reason is when we use android(), we have to create AndroidManifest.xml (containing package name) in androidMain folder + THIS lines and scope in shared's build.gradle.kts file:

Otherwise, we can not build the project:

11- You can choose one of the following solutions to detect if it’s a simulator or actual Apple device:

They’re setting this flag since IntelliJ 15 but I don’t suggest this solution, cause it might be changed later.

12- If you want to support older devices, consider iosArm32 too; Which you can find supported devices with their OS here:

13- For now, I’m gonna put versions into gradle.properties in the root of the project. Later, I’ll separate modules with their own build.gradle files.

14- We’re gonna create a temporary function to just have a sample app for now and run it on iOS and Android. So, create shared/src/commonMain/kotlin/common.kt.

15- Create shared/src/androidMain/kotlin/common.kt and shared/src/iosMain/kotlin/common.ktaand create platfrom method with actual keyword and your desired method body.

16- Note: in android module’s build.gradle.kts you can use plugins { id("kotlin-multiplatfrom") or plugins { id("kotlin-android"). In case of the first choice, you’d need to put dependencies scope into kotlin { sourceSets {scope and add android() as well.

But apart from this, in the case of choosing the first option, you’d have more than one “multiplatform” plugin which might be a cause for possible issues.

17- Use HelloWorldMessage() in the Android module and run the Android app:

15- Create an iOS project: Create a new Xcode project -> iOS target -> Single View App and then fill the form and choose the root of the previous project. Remember to choose Storyboard for the user interface, instead of SwiftUI.

16- Note: Check gradle-wrapper.properties and sure Gradle version is more than 4.7 (which I suggest to set it on class six (6.X.X)). Why? Run ./gradlew --version on different gradle versions.

In this case, using val kotlinVersion: String by extra doesn’t work in settings.gradle.kts. So, we need to set the Kotlin version using requested.version which use the version of the plugin if one was specified, otherwise null. So:

17- Run packForXcode task to setup iOS Framework in the Xcode project model easily, whether with right Gradle panel in AS, Android Studio terminal, or Mac terminal.

You probably(👉 I didn’t get this error after I update Xcode, Android Studio and Kotlin version 👈) get this warning:

Which addressed here, here, etc.

18- Now, we need to add the shared framework to the Xcode project to be able to use common resources on iOS as well.

Go to Xcode, double click on the root of the project name(the blue icon: ios) to open target settings.

Under General tab, Under Frameworks, Libraries, and Embedded Content, press + -> Add Other… -> Add Files… -> address shared/build/xcode-frameworks/shared.framework folder.

19- Now we need to tell to the Xcode where to look for the framework.

Under the Build Settings tab, search for “Framework Search Paths”. Now add this relative path to both Debug and Release: “$(SRCROOT)/../shared/build/xcode-frameworks”.

20- Now we need to tell to the Xcode build our shared framework before each run.

Under the Build Phases -> + -> New Run Script Phase -> paste this snippet code in Shell content:

And drag this task on top of the other tasks.

21- Use HelloWorldMessage() in the ViewController.swift.

Now run the iOS app:

Let’s start to build the TMDb App

22- Note: I’ve checked lots of articles and open-source projects (as I listed some of them as a reference).

For now, I’m gonna follow with some of Fandy Gotama’s snippet codes (which I do not agree with all of them) but, the idea is to have a working application; Especially, as I’m not an iOS developer(yet😄). Later on, I’m gonna improve and change the project and update the article.

23- Add the necessary APIs to the project. For example for Reactive:

Root’s build.gradle file:

allprojects {
repositories {
mavenCentral()
...
maven("https://dl.bintray.com/badoo/maven")
google()
jcenter()
}
}

And in the shared’s build.gradle.kt:

val reactiveVersion: String by extra...sourceSets {
val commonMain by getting {
dependencies {
...
implementation("com.badoo.reaktive:reaktive:$reactiveVersion")
}
}
...
}
}

24- About kotlinx.serialization, take it into account after Kotlin v1.4-M2:

Specifying dependencies only once

From now on, instead of specifying dependencies on different variants of the same library in shared and platform-specific source sets where it is used, you should specify a dependency only once in the shared source set.

Don’t use kotlinx library artifact names with suffixes specifying the platform, such as -common, -native, or similar, as they are NOT supported anymore. Instead, use the library base artifact name, which in the example above is kotlinx-coroutines-core. However, the change doesn’t currently affect the stdlib and kotlin.test libraries (stdlib-common and test-common); they will be addressed later.

If you need a dependency only for a specific platform, you can still use platform-specific variants of standard and kotlinx libraries with such suffixes as -jvm or-js, for example kotlinx-coroutines-core-jvm.

25- Regards enabling experimental features, please take a look here.

26- For Ktor version, take the latest version from the releases section, and not the download section of the Github: ❌👇

27- Remember if you tried the newer unstable Kotlin version, then regretted and set it back to a stable version(1.3.X), be sure that you uninstall Kotlin Plugin(with unstable version) and restart the IDE(to get the stable version automatically). Otherwise, you get this error:

28- Note: If you use kotlin() instead of id(), you must change dash(-) with a dot(.) and remove kotlin keyword if it exists. For example:

29- Objective-C supports “lightweight generics” defined on classes, with a relatively limited feature set. Swift can import generics defined on classes to help provide additional type information to the compiler.

Generic feature support for Objc and Swift differ from Kotlin, so the translation will inevitably lose some information, but the features supported retain meaningful information.

Generics are currently not enabled by default. To have the framework header written with generics, add an experimental flag to the compiler config:

iOSTarget("ios") {
binaries {
framework {
baseName = "shared"
//freeCompilerArgs += "-Xobjc-generics" //It is enabled by default since Kotlin 1.4
}
}
}

For Kotlin/Native interoperability with Swift/Objective-C, check out here.

30- To have the logging feature in Ktor, check out this.

31- [Out of Date-Check #44]For determining build type in the shared module, there are a few tricks and libraries but, I preferred to pass the value(API_KEY) from android module to the shared module.

A minor advantage of this solution is, we avoid rebuilding the shared module when the API URL and token get changed.[Out of Date-Check #44]

32- Note: For the architecture, it’s not 100% MVVM that all we know; Cause we have to take care of our object lifecycle by ourselves. There is moko-mvvm but, I didn’t want to rely on a third-party component for the architecture. Maybe later when I try to improve the architecture(as I mentioned on ‘under development’ section), I realize that that component is the best solution so far.

33- If you use opt-in requirements for features in the experimental state, carefully handle the API graduation to avoid breaking the client code.

To use experimental opt-in annotation, we need to add -Xopt-in=kotlin.RequiresOptIn to the compiler argument. I’ve passed as the second parameter to sections where we defined jvmTarget = “1.8” but, didn’t work for me. So, I’ve used it like below in the shared’s build.gradle, inside sourceSet scope:

34- About Ktor client, we used the built-in install method to implement the JSON feature and use kotlin serialization.

35- Note: we use suspend function from coroutines because it's required by Ktor.

36- I’m not gonna explain implemented architecture. If you have any doubts or questions regards Clean Architecture, you could take a look into this article of mine.

37- [Out of Date] As I earlier mentioned, Ktor uses coroutines to do the asynchronous task. So, we need to transform suspend fun into the observable stream. One solution is using coroutines-interop but, due to its temporary limitations and current bugs, do not use this for now:

implementation 'com.badoo.reaktive:coroutines-interop:<latest-version>'

For more information, please read this. [Out of Date]

38- About ViewModel, just keep it in your mind that Inputs represents any interaction/input from the view and Outputs represents changes from the ViewModel that the view has to display. To understand it better, please take a look into this.

39- To work with Inputs and Outputs in the ViewModel on all platforms, we created ViewModelBinding.

40- For image loading in iOS, I’m using Nuke library.

To add an external library to your project in XCode, first, connect your IDE to your GitHub account from Preferences -> Account, then to add a library from Github, follow File -> Swift Package -> Add Package Dependency.

41- About nil keyword in Swift:

Swift’s nil isn’t the same as nil in Objective-C. In Objective-C, nil is a pointer to a nonexistent object. In Swift, nil isn’t a pointer—it’s the absence of a value of a certain type. Optionals of any type can be set to nil, not just object types.

In another word, it’s null in Java/Kotlin/etc.

42- In iOS project, we don’t pass MoviesUIMapper(To map mapped API response(domain model), to UI model) to the ViewModel. We use the mapped model(from API response to domain model) via MoviesMapper directly, which we passed to MoviesApiImpl.

Update 07/2020:

43- According to Arkadii Ivanov’s comment, I’ve changed the definition of ViewModelBinding to extends DisposableScope:

For more information, please take a look into this.

Update 08/2020:

44- After using Kodein as a DI/Service Locator library, I’m setting API_KEY in shared module -> DI class. A benefit of this, we’d not set this key multiple times in different platforms.

A few points we need to take into account:

  • We have to set jvmTarget to 1.8 in android and shared modules.
  • We use kotlin.native.concurrent.ThreadLocal on the DI class to prevent “Unexpected mutability issue” on Koltin-Native.
  • Changes to apply Kodein.

45- I really didn’t like the result of the OMDb API 😅. So, I’ve migrated the web service to use the TMDb API instead.

It’s really easy to achieve. You could check the changes here.

Update 11/2020:

46- Migrate to Kotlin 1.4.0. To see changes, please click here. Special thanks to Arkadii Ivanov!

Update 04/2021:

47- Migrate to Kotlin 1.4.30. To see changes, please click here.

48- Done!

Result 📺

Under Development 🏭

  • 🔘 Improve architecture.
  • 🔘 Add search box.
  • 🔘 Use Sqldelight to show how to use the database in Multiplatform apps.
  • 🔘 Improve the Clean Architecture approach and separate modules with the power of gradle.
  • 🔘 Categorize dependencies.
  • 🔘 Add tests.
  • ✅ Migrate from OMDb API to TMDb API
  • ✅ Use a DI framework or a Service Locator
  • ✅ Migrate to Kotlin 1.4.X

--

--