Kotlin Multiplatform Project for Android and iOS: Getting Started
In this tutorial, you’ll learn how to use Kotlin Multiplatform and build an app for Android and iOS with the same business logic code.
Version
- Kotlin 1.4, Android 4.1, Android Studio 4.1

Hallo! Gluten Tag. :]
Are you a bit tired of writing two versions of similar code for Android and iOS?
While these two platforms are different, the business logic behind your app is probably similar: download files, read from and write to a database, send messages to a remote host, and retrieve and display fancy kitten pictures.
These similarities are why Kotlin Multiplatform Mobile (KMM) exists. Historically, it’s known as Kotlin Multiplatform Project, or MPP. Thanks to Kotlin/JVM, Kotlin/JS and Kotlin/Native, you can compile/transpile a single project for many platforms.
In this tutorial, you’ll learn how to build and update an app for Android and iOS while only having to write the business logic once in Kotlin. More specifically, you’ll learn how to:
- Integrate KMM into an existing project.
- Set up the common module.
- Fetch data from a network.
- Save data.
Ready to get started? Keep reading!
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.
Open the starter project in Android Studio 4.1 or later.
Use a recent version of Kotlin — 1.4.20 or above. For iOS, you need Xcode 11.3 or later.
Looking at the code, you’ll find the starter project provides the interface and some logic for the MultiGrain app: A simple app that navigates a list of grains and their descriptions, it saves your favorite grains and has platform specific code in the UI.
Build and run the starter code on Android Studio and you’ll see the screen below.
Once you click the Grains button, you’ll see the screen below. It has a hardcoded list of grains.
Of course, you’ll want to update the list from time to time, and it’s better if it can be done remotely. It’s also great if you can save the users’ favorite grains locally. Since you have both Android and iOS apps, you can save some headache by sharing some code for fetching the data and saving your preferences.
But before you get into the implementation, you’ll learn some theory first.
Multiplatform
Kotlin Multiplatform supports more than just Android and iOS. It also supports JavaScript, Windows and other native targets. You’ll focus on the mobile aspect, or Kotlin Multiplatform Mobile (KMM).
Common Code
KMM allows shared code in Android and iOS. On Android, it does this by using a shared module with Gradle. On iOS, you can import the shared module as a framework that you can access from Swift code.
If you think it’s easy to understand how Android uses common code, you’re right. It’s a simple include
in settings.gradle. However, as mentioned earlier, for iOS it’s different, as the code is compiled to native code. You’ll go through the steps of creating this framework later.
Not all code can be common; for example, calling native libraries for key-value storage requires writing different code for iOS and Android. To do this, there can be a common interface acting as common code. Then the platform specifies the interface that will be used in each platform separately.
Platform-Specific Code
Sometimes you need to call methods that are specific to a platform. It isn’t possible to use platform-specific code inside the common module, but Kotlin has a mechanism it uses to achieve this result: expect
/actual
.
First, declare a class, method or function in the common module using the expect
keyword, and leave the body empty, as you often do when creating interfaces or abstract classes. Then, write the platform-specific implementations for the class, method or function in all the other modules using the actual
keyword in the signature.
Each platform can share a common interface while also possessing its own implementation.
Now that you’ve covered the theory, what now? Keep reading to find out.
Integrating KMM Into an Existing Project
Open the starter project in Android Studio. The folder structure should look like the following.
Now you’ll begin integrating it into an existing project.
Setting Up the Common Module
Before continuing, ensure you have the KMM plugin in Android Studio. To check this, go to Preferences > Plugins in Android Studio. Then look up Kotlin Multiplatform Mobile. Install it if it isn’t already there.
Next, to add the common module, go to File > New > New Module.
Then select KMM Shared Module as shown below:
Check the Generate packForXcode Gradle task. This will be used later when you set up iOS.
Lastly, click on Finish.
To see if everything still works, build and run your project. It shouldn’t change anything visually, and it should still compile.
Integrating Into Android
First, add the following line in androidApp/build.gradle inside the dependencies
structure:
implementation project(":shared")
This will allow you to use the common module in Android. Later, you’ll also add dependencies and platform-specific code.
Integrating Into iOS
Before you start integrating the common module in iOS, select iosApp as the configuration. Then build and run the project.
It should open iOS Simulator and show the screen below.
To use the common module in iOS, open iosApp/iosApp.xcodeproj in Xcode. Next, add a Run Script in Build Phases of the Xcode project by pressing the + button.
Enter the code below in the expanded Run Script area:
cd "$SRCROOT/.."
./gradlew :shared:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION}
After the build script is entered, it should look like:
Then build the project in Xcode to create the common code as a framework. After this, select general under the MultiGrain target. In Frameworks, Libraries, and Embedded Content, click the + button.
On Choose frameworks and libraries to add, click the dropdown Add Other and select Add Files.
Afterward, navigate two folders up to the root starter folder, as shown below, add shared/build/xcode-frameworks/shared.framework and click Open.
After adding the framework, it should look like the image below.
At this point, the project won’t be able to find the framework, so you have to add the file into the Framework Search Paths. To do this, go to the target’s Build Settings. Then find Framework Search Paths. Set the value of this field to:
$(SRCROOT)/../shared/build/xcode-frameworks
The change is shown in the picture below.
Build and run. There won’t be a visual change, but now you can start using the common module.
Close Xcode for now, as the next section is about fetching data from the network.
Fetching Data From the Network in Common Code
To fetch data, you need a way to use networking from common code. Ktor is a multiplatform library that allows performing networking on common code. In addition, to parse/encapsulate the data, you can use a serialization library. Kotlin serialization is a multiplatform library that will allow this from common code.
To start, add the dependencies. Go back to Android Studio, open shared/build.gradle.kts and add the following lines of code below the import
but above plugins
:
val ktorVersion = "1.5.0"
val coroutineVersion = "1.4.2"
You’ve defined the library versions to be used. Now, inside plugins
at the bottom add:
kotlin("plugin.serialization")
Next, replace the code inside of sourceSets
which is inside of kotlin
with:
// 1
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-common")
implementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
implementation(
"org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
implementation("io.ktor:ktor-client-core:$ktorVersion")
}
}
// 2
val androidMain by getting {
dependencies {
implementation("androidx.core:core-ktx:1.2.0")
implementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion")
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
}
// 3
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-ios:$ktorVersion")
}
}
Here’s what you’re doing in the code above:
- Adding the dependencies to the common module.
- Declaring the dependencies to the Android module.
- Filling the dependencies into the iOS module.
Next, you need to add the data models. Create a file named Grain.kt under shared/src/commonMain/kotlin/com.raywenderlich.android.multigrain.shared. Add the following lines inside this file:
package com.raywenderlich.android.multigrain.shared
import kotlinx.serialization.Serializable
@Serializable
data class Grain(
val id: Int,
val name: String,
val url: String?
)
This defines the data model for each grain entry.
Create another file inside the same folder, but this time, name it GrainList.kt. Update the file with the following:
package com.raywenderlich.android.multigrain.shared
import kotlinx.serialization.Serializable
@Serializable
data class GrainList(
val entries: List<Grain>
)
This defines an array of grains for parsing later.
Since now you have the data models, you can start writing the class for fetching data.
In the same folder/package, create another file called GrainApi.kt. Replace the contents of the file with:
package com.raywenderlich.android.multigrain.shared
// 1
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
// 2
class GrainApi() {
// 3
// 3
private val apiUrl =
"https://gist.githubusercontent.com/jblorenzo/" +
"f8b2777c217e6a77694d74e44ed6b66b/raw/" +
"0dc3e572a44b7fef0d611da32c74b187b189664a/gistfile1.txt"
// 4
fun getGrainList(
success: (List<Grain>) -> Unit, failure: (Throwable?) -> Unit) {
// 5
GlobalScope.launch(ApplicationDispatcher) {
try {
val url = apiUrl
// 6
val json = HttpClient().get<String>(url)
// 7
Json.decodeFromString(GrainList.serializer(), json)
.entries
.also(success)
} catch (ex: Exception) {
failure(ex)
}
}
}
// 8
fun getImage(
url: String, success: (Image?) -> Unit, failure: (Throwable?) -> Unit) {
GlobalScope.launch(ApplicationDispatcher) {
try {
// 9
HttpClient().get<ByteArray>(url)
.toNativeImage()
.also(success)
} catch (ex: Exception) {
failure(ex)
}
}
}
}
Here’s the breakdown of what the code above does:
- Defines the imports for this class.
- Creates the
GrainApi
class. - Declares the URL for the API.
- Calls
getGrainList()
for fetching the JSON data. - Since
Ktor
needs to be called on a coroutine, this launches a lambda using a dispatcher,ApplicationDispatcher
. This needs to be defined before you can build the app without errors. - Gets the JSON string from the URL.
- Deserializes the string into a
GrainList
class. - Starts another method,
getImage()
, to fetch the image from common code.
Now that you’ve added the dependencies and data model and written a class for fetching the data, it’s time to learn about using Expect
in common modules.
Using expect in Common Modules
At this point, you have a few things that aren’t yet defined — namely ApplicationDispatcher
, Image
and toNativeImage()
.
Create the file Dispatcher.kt in the same package. Then fill it in with the following:
package com.raywenderlich.android.multigrain.shared
import kotlinx.coroutines.CoroutineDispatcher
internal expect val ApplicationDispatcher: CoroutineDispatcher
Here you state the expectation that there will be platform-specific implementations of this ApplicationDispatcher
value.
Create another file in the same package and name it NativeImage.kt. Replace the contents of this file with:
package com.raywenderlich.android.multigrain.shared
expect class Image
expect fun ByteArray.toNativeImage(): Image?
Here you declare an expected Image
class and a method, toNativeImage()
, which operates on ByteArray
and returns an optional Image
.
At this point, you still can’t build the app since the expect
declaration is missing the actual
counterparts.
Fetching Data in Android
To add the actual
declarations, go to shared/src/androidMain/kotlin/com.raywenderlich.android.multigrain.shared and create a file named Dispatcher.kt. Insert the following lines into the file:
package com.raywenderlich.android.multigrain.shared
import kotlinx.coroutines.*
internal actual val ApplicationDispatcher: CoroutineDispatcher = Dispatchers.Default
You defined the expected ApplicationDispatcher
variable. Next, create a file, NativeImage.kt, in the same package or path:
package com.raywenderlich.android.multigrain.shared
import android.graphics.Bitmap
import android.graphics.BitmapFactory
// 1
actual typealias Image = Bitmap
// 2
actual fun ByteArray.toNativeImage(): Image? =
BitmapFactory.decodeByteArray(this, 0, this.size)
The code above:
- Defines the
Image
class as an alias ofBitmap
. - Declares the actual implementation of the extension function,
toNativeImage()
, which creates aBitmap
from the array.
After adding a lot of files, you can now build and run your app even though parts of the code are still underlined in red. There are still no visual changes, but check that your project is compiling successfully.
Now you’ll wire GrainApi
to the Android code.
Open MultiGrainActivity.kt inside androidApp. At the top of the class add:
private lateinit var api: GrainApi
This declares an api
variable. Next, in onCreate
replace the statement:
grainAdapter = GrainListAdapter()
with the two statements below:
api = GrainApi()
grainAdapter = GrainListAdapter(api)
Now you’ve initialized the api
variable
and passed it to the constructor of GrainAdapter
. This will cause an error until you update GrainAdapter
, which you’ll do after one more change here. Add the following inside the body of loadList
:
api.getGrainList(
success = { launch(Main) { grainAdapter.updateData(it) } },
failure = ::handleError
)
The code above adds a call to getGrainList
inside loadList()
. There are some error messages at the moment, ignore them for now. These changes also require some changes on GrainListAdapter.kt. Open this file and replace:
typealias Entry = String
with:
typealias Entry = com.raywenderlich.android.multigrain.shared.Grain
You’ve changed the typealias
of Entry
to refer to the class you wrote in the common code called Grain
. Now, add a parameter to the constructor of the class so that it looks like:
class GrainListAdapter(private val api: GrainApi) :
RecyclerView.Adapter() {
This changed the constructor of this adapter to include api
. Locate the hardcoded grain
list and replace it with:
private val grainList: ArrayList<Entry> = arrayListOf()
The list is now an empty array so that the data can be provided via a call to the api instead. Lastly, locate bind
and replace the body with:
// 1
textView.text = item.name
item.url?.let { imageUrl ->
// 2
api.getImage(imageUrl, { image ->
imageView.setImageBitmap(image)
}, {
// 3
handleError(it)
})
}
The code above:
- Sets the text to the item name.
- Gets the image and sets it as a
Bitmap
. - Handles the error if it occurs.
After a lot of code without visual changes, build and run the app to see that the Android app is now fetching data from the internet and loading the images as well. After clicking on the Grains button, it should appear as shown below:
With that done, now it’s time to fetch data in iOS.
Fetching Data in iOS
Just as you did in Android, you need to define the actual implementation of the expected classes.
In shared/src/iosMain/kotlin/com.raywenderlich.android.multigrain.shared, create a file called Dispatcher.kt, and insert the following lines into the file:
import kotlin.coroutines.*
import kotlinx.coroutines.*
import platform.darwin.*
// 1
internal actual val ApplicationDispatcher: CoroutineDispatcher =
NsQueueDispatcher(dispatch_get_main_queue())
// 2
internal class NsQueueDispatcher(
private val dispatchQueue: dispatch_queue_t
) : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
dispatch_async(dispatchQueue) {
block.run()
}
}
}
Again, you defined the expected ApplicationDispatcher
variable, but this time in iOS. It’s a bit more complicated than Android. Since iOS doesn’t support coroutines, any calls to dispatch to a coroutine will be dispatched to the main queue in iOS. This is for simplicity.
The next task is to create a file, NativeImage.kt, in the same package or path:
import kotlinx.cinterop.*
import platform.Foundation.NSData
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage
actual typealias Image = UIImage
@ExperimentalUnsignedTypes
actual fun ByteArray.toNativeImage(): Image? =
memScoped {
toCValues()
.ptr
.let { NSData.dataWithBytes(it, size.toULong()) }
.let { UIImage.imageWithData(it) }
}
The code above does the following:
- Defines the
Image
class as an alias ofUIImage
. - Declares the actual implementation of the extension function,
toNativeImage()
, which creates aUIImage
from the bytes.
Build and run your app. There aren’t any visual changes, but make sure your project is working.
Déjà vu? Now you’ll wire the GrainApi
to the iOS code.
Open MultiGrainsViewController.swift inside iosApp. You can do this in Android Studio, so there’s no need to open Xcode. Add the import statement below the other import statements at the top of the file:
import shared
This imports the common module called shared
— recall that you added this framework earlier. Next, add the following lines inside MultiGrainsViewController
at the top of the class:
//swiftlint:disable implicitly_unwrapped_optional
var api: GrainApi!
//swiftlint:enable implicitly_unwrapped_optional
Now you’ve declared the api
variable and disabled the linter because you’ll be using forced unwrapping.
var grainList: [Grain] = []
This replaces the hardcoded grainList
with an empty array so it can be populated via the network call to the api.
Next, in viewDidLoad
, right below the call to super.viewDidLoad()
, add:
api = GrainApi()
The statement above initializes the api
variable. The last step is to replace the contents of loadList
with:
api.getGrainList(success: { grains in
self.grainList = grains
self.tableView.reloadData()
}, failure: { error in
print(error?.description() ?? "")
})
Now you’ve added a call to get the grain list inside loadList
, which updates the local grainList variable after a successful fetch. Then it reloads the table. On a failure, it shows an alert.
These also require some changes on MultiGrainsViewController+UITableView.swift. Open this file and in tableView
, replace the line:
cell.textLabel?.text = entry
with:
// 1
cell.textLabel?.text = entry.name
// 2
cell.imageView?.image = nil
// 3
api.getImage(url: entry.url ?? "", success: { image in
DispatchQueue.main.async {
cell.imageView?.image = image
cell.setNeedsLayout()
}
}, failure: { error in
// 4
print(error?.description() ?? "")
})
return cell
The code above does the following:
- In
tableView(_: cellForRowAt:)
, it sets the text to the item name. - Sets the image to
nil
. - Gets the image and sets it as an
image
of the cell’simageView
. - Handles the error if it occurs.
At this point, build and run the app to see that the iOS app is now fetching data from the internet and loading the images. After clicking on the Grains button, it should appear like this:
That was a lot of work already. You deserve a snack :] Go grab a bowl and put some muesli or oats or cereal in it, and add your liquid of choice.
Saving Data in SharedPreferences and UserDefaults
You’ve completed fetching data, but you’ll also want to use platform-specific methods, like key-value storage, in KMM. Since you still cannot save the user’s preferred grains, you can start by adding code to get and set favorites.
Open GrainApi.kt. Modify the constructor to look like:
class GrainApi(private val context: Controller) {
Now the constructor takes a Controller
instance, which you’ll define afterwards. Then, add the following to methods inside GrainApi
.
// 1
fun isFavorite(id: Int): Boolean {
return context.getBool("grain_$id")
}
// 2
fun setFavorite(id: Int, value: Boolean) {
context.setBool("grain_$id", value)
}
Here’s the gist of what the code above does:
- Defines
isFavorite()
, which will get a Boolean from the key-value store. - Declares
setFavorite()
to set a Boolean on the key-value store.
Since you modified the constructor with an undefined class, you should define it. Create a file called KeyValueStore.kt under shared/src/commonMain/kotlin/com.raywenderlich.android.multigrain.shared with the following inside:
package com.raywenderlich.android.multigrain.shared
// 1
expect class Controller
// 2
expect fun Controller.getBool(key: String): Boolean
expect fun Controller.setBool(key: String, value: Boolean)
This code declares an expected Controller
class together with two methods for setting and getting a Boolean.
Saving Data in Android
To save data in Android, create a file called KeyValueStore.kt under shared/src/androidMain/kotlin/com.raywenderlich.android.multigrain.shared called KeyValueStore.kt and insert the following:
package com.raywenderlich.android.multigrain.shared
import android.app.Activity
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
// 1
actual typealias Controller = Activity
// 2
actual fun Controller.getBool(key: String): Boolean {
val prefs: SharedPreferences = this.getSharedPreferences("", MODE_PRIVATE)
return prefs.getBoolean(key, false)
}
// 3
actual fun Controller.setBool(key: String, value: Boolean) {
val prefs: SharedPreferences = this.getSharedPreferences("", MODE_PRIVATE)
val editor = prefs.edit()
editor.putBoolean(key, value)
editor.apply()
}
Here’s a quick overview of what this code does:
- Aliases
Controller
asActivity
. - Writes
getBool()
, which reads a Boolean fromSharedPreferences
. - Declares
setBool()
to set a Boolean onSharedPreferences
.
Here you won’t see any difference when you run the app. To add a visual indicator for favorites, open MultiGrainActivity.kt in androidApp:
override fun onCreate(savedInstanceState: Bundle?) {
...
api = GrainApi(this)
...
}
...
private fun setupRecyclerView() {
...
toggleFavorite(item.id)
...
}
private fun toggleFavorite(id: Int) {
val isFavorite = api.isFavorite(id)
api.setFavorite(id, !isFavorite)
}
You updated GrainApi
, since now it takes an Activity
. And you also updated toggleFavorite()
to include the item ID. Moreover, you defined the contents of toggleFavorite()
; it toggles favorite
for this specific ID.
Now open GrainListAdapter.kt, and update the bind method by adding these lines at the top:
// 1
val isFavorite = api.isFavorite(item.id)
// 2
textView.setCompoundDrawablesWithIntrinsicBounds(
null,
null,
if (isFavorite) ContextCompat.getDrawable(
view.context, android.R.drawable.star_big_on)
else null,
null
)
This gives you the favorite status of the item. Then you set the corresponding drawable.
Build and run the app now. You should be able to toggle favorites by clicking on the grains. Click on a few and you’ll see your preferences persist. You can even restart the app to check.
Saving Data in iOS
Of course, this isn’t complete without the iOS part. Create a file called KeyValueStore.kt under shared/src/iosMain/kotlin/com.raywenderlich.android.multigrain.shared. Then insert these lines:
// 1
actual typealias Controller = UIViewController
// 2
actual fun Controller.getBool(key: String): Boolean {
return NSUserDefaults.standardUserDefaults.boolForKey(key)
}
// 3
actual fun Controller.setBool(key: String, value: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(value, key)
}
Similar to the code in Android, the code above:
- Aliases
Controller
asUIViewController.
- Writes
getBool()
, which reads a Boolean fromNSUserDefaults.
- Declares
setBool()
to set a Boolean onNSUserDefaults
.
To show this, open MultiGrainsViewController+UITableView.swift in iosApp. Then modify it like below:
extension MultiGrainsViewController: UITableViewDelegate {
// 1
func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
let entry = grainList[indexPath.row]
let current = api.isFavorite(id: entry.id)
api.setFavorite(id: entry.id, value: !current)
tableView.reloadRows(at: [indexPath], with: .automatic)
}
}
extension MultiGrainsViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
// 2
cell.accessoryType = api.isFavorite(id: entry.id) ?
.checkmark : .none
return cell
}
}
Here’s a quick overview of what this code does:
- Updates
tableView(_, didSelectRowAt:)
to toggle the Boolean for the item when the cell is selected. - In
tableView(_, cellForRowAt:)
, sets theaccessoryType
to show a check if the item is a favorite.
Another small update is in MultiGrainsViewController.swift:
override func viewDidLoad() {
super.viewDidLoad()
api = GrainApi(context: self)
...
}
Now the API takes in a UIViewController
.
Finally, build and run your iosApp
in Android Studio. As with Android, you can now toggle your preferences and check if the data is persisted after a restart of the app.
If you see a something similar to the image above, it’s time to celebrate! You’ve done a good job. Grab a wheat beer if you like, or a granola bar. You’ve consumed a lot of calories while reading, and of course you need some reward. Enjoy!
Where to Go From Here?
Download the completed project by clicking the Download Materials button at the top or bottom of the tutorial.
To learn more about KMM, check out the Kotlin documentation. It also includes the release notes for the KMM plugin. Additionally, you’ll find notes for what’s new in Kotlin for KMM.
If you’re still hungry for more information, enjoy this detailed read on integrating KMM in an existing app.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
Comments