Building Kotlin Multiplatform Apps
Featured in Kotlin Weekly #247
When I first started to learn Kotlin Multiplatform in 2018, I started by trying to build an app that displays a list of videos and plays them for both Android and iOS. I had given a presentation from my learnings at the Kotlin NYC meetup. During that time, it was challenging to set up network requests, serialization and video playback. I had to use multiple IDEs (XCode, Android Studio, CLion). But, Kotlin Multiplatform has come along far since 2018. In this article, I’ll explore how to build a Kotlin Multiplatform app in 2021 and compare it with my previous experience.
Why Kotlin Multiplatform?
I started to explore Kotlin Multiplatform because it allows you to share code between Android and iOS. We solve similar problems on both platforms such as getting data from the network, parsing it, handling analytics and implementing business logic. There are similar libraries and approaches to tackling these problems on both platforms. Solving these problems once is beneficial across Android and iOS.
Use Case
We’ll build an Android and iOS app that gets data from the network, caches it and displays it in the UI. It’s a very simple example of a Kotlin Multiplatform app.
Features
- Multiplatform API client
- Multiplatform Serilization
- Caching
In our project setup, we will have a shared module containing an API client and logic to cache data.
How do we set up our project?
Scaffolding
The Kotlin Multiplatform plugin creates the scaffolding for a project. It will create the following directory structure for a shared module. The structure contains a source set for platform specific logic for iOS (iOSMain) and Android (AndroidMain) and for common code (commonMain).
The build script gives you the ability to specify dependencies for each of your source sets.
sourceSets {
val commonMain by getting {
dependencies {
...
}
}
val iosMain by getting {
dependencies {
...
}
}
val androidMain by getting {
dependencies {
...
}
}
}
}
Shared Module
We want to create an API client in the shared module that fetches data from the network.
Ktor
Under the hood, Ktor uses different ways to make requests based on the platform. If it is JVM it will create an HTTP stream. On the other hand for iOS, NSURLSession
.
class ApiClient {
private val httpClient = HttpClient {
install(JsonFeature) {
val json = Json { ignoreUnknownKeys = true }
serializer = KotlinxSerializer(json)
}
}
}
Kotlinx Serialization
For our use case, we want to get a list of users. We’ll define a data transfer object for a user. It contains only two fields - the name and location of the user.
@Serializable
data class User(
val name: String,
val location: String,
)
The client will have a method to get a list of users for our use case. In order to do this, we’ll define the users endpoint and ask Ktor to make the request.
class ApiClient {
suspend fun getUsers(): List<User> {
return httpClient.get(API_ENDPOINT)
}
val API_ENDPOINT = “https://api.com/users”
}
Coroutines have Kotlin Multiplatform support. Therefore, getUsers
is a suspending method that returns a list of users.
A Look Back
Everything I’ve done so far was actually very challenging when KMM was initially introduced. Back in 2018, I used this library http-client-common to setup network requests. This logic used in the library for using NSURLSession and Http Stream on JVM carried over to Ktor. During that time, multiplatform support for serialization was still under development. A branch existed example-gen
that was difficult to set up. On iOS, I used the Codeable
protocol extension.
Setting Up Android App
How will we consume this client on Android? We could create a view model that launches a coroutine and communities with the client to get users.
class MyViewModel(val api: ApiClient) {
override fun onCreate() {
coroutineScope.launch {
val users = apiClient.getUsers()
...
}
}
}
Setting Up iOS App
class MyViewModel {
let apiClient: ApiClient
func loadUsers() {
apiClient.getUsers(completionHandler: { users, error in
...
}
})
}
}
On iOS, I have similarly defined a View Model that gets a list of users. The getUsers
method in this case has a completion handler. It’s not a suspending method rather you’re passing in a callback.
iOS Framework
For iOS, a framework is generated for your shared module that is consumed in the iOS app.
How is the User class mapped in the framework?
Kotlin
data class User(val name: String, val location: String)
Objective C
__attribute__((swift_name("User")))
@interface SharedUser : SharedBase
@property (readonly) NSString *name
__attribute__((swift_name("name")));
@property (readonly) NSString *location
__attribute__((swift_name("location")));
@end;
There are two classes in the framework - SharedBase and SharedUser. The class names were prefixed by the name of the shared module. It is possible to configure the name.
The SharedUser
interface has two read only values defined. For interoperability with Swift, the swift_name
method is used to specify a clear type name.
A Look Back
In my experience of using Kotlin Multiplatform, I found the push back from iOS developers was that it was an Objective-C framework. They wanted to consume a Swift framework to have access to modern languages features in Swift such as value types.
Thus far, we have built a simple Android and iOS that uses a shared client to get data. We accomplished this by using Ktor, Kotlin serialization library and coroutines.
Caching
For our use case, we want to cache the returned data in the database. We’ll use the SQL Delight library. It’s a Multiplatform library that provides a driver for Android and iOS.
In the shared module, we’ll add a sql file which will contain SQL queries and statements to create a User
table.
CREATE TABLE User (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
Location TEXT NOT NULL
);
insertUser:
INSERT INTO User(id, name, location)
VALUES(?, ?, ?);
selectUsers:
SELECT User.*
FROM User
These statements allow us to insert a user in the User
table and get a particular user. SQL Delight library will create a AppDatabase
object that we will use to perform these operations. In our gradle script, we could specify the name of the database.
sqldelight {
database("AppDatabase") {
packageName = "com.learn.kmmapplication.shared.cache"
}
}
We’ll create a wrapper around the generated database.
class AppCache() {
private val database = AppDatabase()
fun getUsers(): List<User> {
return database.selectUsers().executeAsList()
}
fun insertUser(user: User) {
database.insertUser(
id = user.id,
name = user.name,
location = user.location
)
}
We’ll use this database class in our client.
class ApiClient {
suspend fun getUsers(): List<User> {
val users = database.getUsers()
return if (users.isNotEmpty()) {
users
} else {
httpClient.get().also {
database.inserUsers(users)
}
}
}
}
KaMPKit
When I first started using Kotlin Multiplatform in 2018, it was a challenge to build something as simple as I’ve outlined. It’s great to see how far in a little time multiplatform support has come. Touchlab has a starter project that makes it easier to integrate it. It has a scaffolding with logging, cocoapods integration and common libraries configured for you. It’s very helpful to get you started.