EXP10
EXP10
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.
Here ,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:
Save data.
Download the starter project by clicking the Download Materials button at the top or bottom of the
tutorial.
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.
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.
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.
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.
Next, to add the common module, go to File > New > New Module.
Check the Generate packForXcode Gradle task. This will be used later when you set up iOS.
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.
cd "$SRCROOT/.."
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:
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
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.
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:
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
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
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
dependencies {
implementation("io.ktor:ktor-client-ios:$ktorVersion")
package com.raywenderlich.android.multigrain.shared
import kotlinx.serialization.Serializable
@Serializable
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
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
"https://gist.githubusercontent.com/jblorenzo/" +
"f8b2777c217e6a77694d74e44ed6b66b/raw/" +
"0dc3e572a44b7fef0d611da32c74b187b189664a/gistfile1.txt"
// 4
fun getGrainList(
// 5
GlobalScope.launch(ApplicationDispatcher) {
try {
// 6
// 7
Json.decodeFromString(GrainList.serializer(), json)
.entries
.also(success)
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)
failure(ex)
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.
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
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
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.
package com.raywenderlich.android.multigrain.shared
import kotlinx.coroutines.*
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
// 2
BitmapFactory.decodeByteArray(this, 0, this.size)
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.
grainAdapter = GrainListAdapter()
api = GrainApi()
grainAdapter = GrainListAdapter(api)
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(
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:
with:
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:
RecyclerView.Adapter() {
This changed the constructor of this adapter to include api. Locate the hardcoded grain list and
replace it with:
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
// 2
imageView.setImageBitmap(image)
}, {
// 3
handleError(it)
})
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.
import kotlin.coroutines.*
import kotlinx.coroutines.*
import platform.darwin.*
// 1
NsQueueDispatcher(dispatch_get_main_queue())
// 2
) : CoroutineDispatcher() {
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
@ExperimentalUnsignedTypes
memScoped {
toCValues()
.ptr
.let { UIImage.imageWithData(it) }
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
//swiftlint:enable implicitly_unwrapped_optional
Now you’ve declared the api variable and disabled the linter because you’ll be using forced
unwrapping.
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.
cell.textLabel?.text = entry
with:
// 1
cell.textLabel?.text = entry.name
// 2
cell.imageView?.image = nil
// 3
DispatchQueue.main.async {
cell.imageView?.image = image
cell.setNeedsLayout()
}, failure: { error in
// 4
print(error?.description() ?? "")
})
return cell
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.
Now the constructor takes a Controller instance, which you’ll define afterwards. Then, add the
following to methods inside GrainApi.
// 1
return context.getBool("grain_$id")
}
// 2
context.setBool("grain_$id", value)
1. Defines isFavorite(), which will get a Boolean from 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.multig
rain.shared with the following inside:
package com.raywenderlich.android.multigrain.shared
// 1
// 2
This code declares an expected Controller class together with two methods for setting and getting a
Boolean.
package com.raywenderlich.android.multigrain.shared
import android.app.Activity
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
// 1
// 3
editor.putBoolean(key, value)
editor.apply()
Here you won’t see any difference when you run the app. To add a visual indicator for favorites,
open MultiGrainActivity.kt in androidApp:
...
api = GrainApi(this)
...
...
...
toggleFavorite(item.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
// 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.s
hared. Then insert these lines:
// 1
// 2
return NSUserDefaults.standardUserDefaults.boolForKey(key)
}
// 3
NSUserDefaults.standardUserDefaults.setBool(value, key)
// 1
...
// 2
.checkmark : .none
return cell
}
}
1. Updates tableView(_, didSelectRowAt:) to toggle the Boolean for the item when the cell is
selected.
super.viewDidLoad()
...
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.
Thus the implementation of the Mini Project with Android and Kotlin Multiplatform is completed
Successfully