Home Android & Kotlin Tutorials

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.

5/5 1 Rating

Version

  • Kotlin 1.4, Android 4.1, Android Studio 4.1
Update note: JB Lorenzo updated this tutorial for the alpha version of Kotlin Multiplatform. Dario Coletto wrote the original.

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.

Note: This tutorial assumes you are familiar with the basics of Android and Android Studio. If you are new to Android development, check out our Android Tutorial for Beginners series first.

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.

Starter grain entry screen running on Android

Once you click the Grains button, you’ll see the screen below. It has a hardcoded list of grains.

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.

Overview of shared module structure. https://kotlinlang.org/docs/mobile/discover-kmm-project.html

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.

Note: For more on this topic, you can read about understanding the KMM project structure on the Kotlin website.

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.

Illustration of the expect/actual mechanism

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.

The project structure of the starter project.

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:

Create new Module dialog with KMM Shared Module selected.

Check the Generate packForXcode Gradle task. This will be used later when you set up iOS.

Filling in the package name and shared as the module name.

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.

Starter grain entry screen running on Android

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.
Dropping down the configuration and selecting run as iOS app.
It should open iOS Simulator and show the screen below.

Starter project in iOS

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.

Adding a run script under the build phases tab of xcode with the plus button.

Enter the code below in the expanded Run Script area:

cd "$SRCROOT/.."
./gradlew :shared:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION}
Note: Make sure that after the cd line, the ./gradlew command is one line. There should be no line breaks.

After the build script is entered, it should look like:
Added the build script text into the Run Script area.

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.

Click the add new framework button under the general tab in xcode.

On Choose frameworks and libraries to add, click the dropdown Add Other and select Add Files.

Select add files under add other under frameworks.

Afterward, navigate two folders up to the root starter folder, as shown below, add shared/build/xcode-frameworks/shared.framework and click Open.

Adding the shared framework folder.

After adding the framework, it should look like the image below.

The way the general tab looks after the framework is added, it is showing under the Frameworks, Libraries, and Embedded Content section.

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.
Adding the framework path.
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:

  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.

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:

  1. Defines the Image class as an alias of UIImage.
  2. Declares the actual implementation of the extension function, toNativeImage(), which creates a UIImage from the bytes
  3. .

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
  let alert = UIAlertController(
    title: nil, message: error?.description(), preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
  self.present(alert, animated: true, completion: nil)
})

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
  let alert = UIAlertController(
    title: nil, message: error?.description(), preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
  self.present(alert, animated: true, completion: nil)
})
return cell

The code above does the following:

  1. In tableView(_: cellForRowAt:), it sets the text to the item name.
  2. Sets the image to nil.
  3. Gets the image and sets it as an image of the cell's imageView.
  4. 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:

iOS app with fetched data

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:

  1. Defines isFavorite(), which will get a Boolean from the key-value store.
  2. 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:

  1. Aliases Controller as Activity.
  2. Writes getBool(), which reads a Boolean from SharedPreferences.
  3. Declares setBool() to set a Boolean on SharedPreferences.

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.

Android Grain List with Favorites

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:

  1. Aliases Controller as UIViewController.
  2. Writes getBool(), which reads a Boolean from NSUserDefaults.
  3. Declares setBool() to set a Boolean on NSUserDefaults.

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:

  1. Updates tableView(_, didSelectRowAt:) to toggle the Boolean for the item on click event.
  2. In tableView(_, cellForRowAt:), sets the accessoryType 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.

iOS App with Favorites

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!

Average Rating

5/5

Add a rating for this content

1 rating

More like this

Contributors

Comments