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. By JB Lorenzo.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Kotlin Multiplatform Project for Android and iOS: Getting Started
30 mins
- Getting Started
- Multiplatform
- Common Code
- Platform-Specific Code
- Integrating KMM Into an Existing Project
- Setting Up the Common Module
- Integrating Into Android
- Integrating Into iOS
- Fetching Data From the Network in Common Code
- Using expect in Common Modules
- Fetching Data in Android
- Fetching Data in iOS
- Saving Data in SharedPreferences and UserDefaults
- Saving Data in Android
- Saving Data in iOS
- Where to Go From Here?
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.