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.

4.7 (6) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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:

  1. Adding the dependencies to the common module.
  2. Declaring the dependencies to the Android module.
  3. 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:

  1. Defines the imports for this class.
  2. Creates the GrainApi class.
  3. Declares the URL for the API.
  4. Calls getGrainList() for fetching the JSON data.
  5. 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.
  6. Gets the JSON string from the URL.
  7. Deserializes the string into a GrainList class.
  8. 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:

  1. Defines the Image class as an alias of Bitmap.
  2. Declares the actual implementation of the extension function, toNativeImage(), which creates a Bitmap 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:

  1. Sets the text to the item name.
  2. Gets the image and sets it as a Bitmap.
  3. 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:

Android app with fetched data

With that done, now it’s time to fetch data in iOS.