Build iOS apps using the Kotlin Multiplatform framework
Kotlin Multiplatform allows sharing of a single business code base. It saves development time and reduces maintenance costs by creating a library for Android and iOS to reference the same business code. It means that all API connections and database storage are implemented in a single shared module. As a result, writing unit tests can be more efficient. It can open a new era of cross-platform mobile app development.
Overview
In this topic, we will use SwiftUI and Kotlin to create a simple iOS application to demonstrate the power of KMM. It is a location-based weather app that can display the current weather conditions in the city. Technically, it requests data from the Open Weather service, and once it obtains the current weather conditions, it will use it to retrieve weather images from the Unsplash service.
Architecture
The idea is to apply a clean architecture, and on a specific platform, we will apply the redux pattern. In this example, there are only 4 states that need to render: Idle, Loading, Loaded and Failed. In Android and iOS, we only need to call the use case and use shared domain models, and all response classes will be mapped to the domain model to be shared between platforms.
The tutorial app
I prepared an example SwiftUI project. It reads the JSON data from mock assets and parses it to the Weather class in Swift. The goal is to implement the HTTP connection and move the load JSON function in Swift to the shared module, by rewriting it in Kotlin.
Connect to platform-specific APIs
Kotlin provides a lot of libraries to use SQLdelight to cache data or connect to asynchronous remote APIs through Ktor, but to read local assets, we need to process them manually in the androidApp or iosApp module. This strategy, however, is inefficient. In this article, I will show how to use Kotlin in the shared module to solve platform-specific issues.
The actual declaration that corresponds to the expected declaration must be provided by platform source sets. For this example, iosApp module can get NSBundle with no additional dependencies, making it easy to access the main bundle and read the asset files. But on androidApp module, because there is no built-in Context provider, it is more complicated, so we have to provide it manually.
In order to build a class with Context in androidMain but without Context in iosMain, we declare expect classes without a constructor, which later allow each platform-specific to implementation its own constructor. In this example, the Kotlin version of AssetManager in androidMain module requires context in the constructor, so the expected class must remove the constructor, and it works like an abstract class that cannot be initialized in the commonMain module.
commonMain:
expect class AppContext {
fun createAssetManager(): AssetManager
}
expect class AssetManager {
fun loadAsset(name: String): String
}
iosMain:
The style of iOS implementation is similar to writing Objective C because Kotlin wraps all the native functions.
actual class AppContext {
actual fun createAssetManager(): AssetManager = AssetManager()
}
actual class AssetManager {
actual fun loadAsset(name: String): String {
val nsUrl = NSBundle.mainBundle.URLForResource(name, "")
val nsData = nsUrl?.let { NSData.create(it) }
val nsString = nsData?.let { NSString.create(it, NSUTF8StringEncoding) }
return nsString.toString()
}
}
androidMain:
Accessing app environment in Android requires passing the Context to the shared module.
actual class AppContext(private val context: Context) {
actual fun createAssetManager(): AssetManager = AssetManager(context)
}
actual class AssetManager(private val context: Context) {
actual fun loadAsset(name: String): String = context.assets
.open(name)
.bufferedReader()
.use { it.readText() }
}
I created an additional AppContext as an abstract context wrapper class, which will provide the Android context in the shared module. It will minimize the required context argument to provide from platform-specific every time a new class is introduced. This means that on iOS or Android, we only initialized the AppContext, and the rest will be handled under the shared module. It will be explained in the topic of dependency injection.
This achievement will unlock the implementation of WeatherServiceMock, which allow it to read local JSON data. Asset files are managed by platform-specific which means there are duplicated OpenWeather.json files in separated folders:
Android: androidApp/src/main/assets
iOS: andiosApp/iosApp/Mock
class WeatherServiceMock(private val assetManager: AssetManager,) : WeatherService {
override suspend fun getWeather(lat: Double, lon: Double): OpenWeatherResponse {
return withContext(Dispatchers.Default) {
val response = assetManager.loadAsset("OpenWeather.json")
json.decodeFromString(response)
}
}
private val json = Json { ignoreUnknownKeys = true }
}
Connect to remote APIs
In this tutorial, we will retrieve data from Open Weather API, to archive it, we need an HTTP client and a JSON parser. Ktor is a powerful framework that support multiple platforms, to make asynchronous requests we will use the Kotlin Coroutines and Kotlin Serialization to parse JSON response.
Now, let’s start adding the classpath settings to the root build.gradle.kts
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21")
classpath("org.jetbrains.kotlin:kotlin-serialization:1.5.21")
classpath("com.android.tools.build:gradle:7.0.1")
}
Then apply plugin.serialization in the shared module: shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
}
To be compatible with iOS, we must use the version of coroutines that includes native-mt postfix, which is also compatible with Android. If we remove the native-mt suffix, a runtime exception will be thrown when calling the suspend function in iOS.
val serializationVersion = "1.2.2"
val coroutinesVersion = "1.5.1-native-mt"
val ktorVersion = "1.6.2"
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("io.ktor:ktor-client-core:$ktorVersion")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-ios:$ktorVersion")
}
}
There are only two types of dispatchers that we need to concern about: Dispatchers.Main run on the main thread and Dispatchers.Default process the task on the background thread.
class WeatherServiceImpl(private val client: HttpClient) : WeatherService {
override suspend fun getWeather(lat: Double, lon: Double): OpenWeatherResponse {
return withContext(Dispatchers.Default) {
val response: String = client.get {
url("https://api.openweathermap.org/data/2.5/weather")
parameter("lat", lat)
parameter("lon", lon)
parameter("units", "metric")
parameter("APPID", Constant.OPEN_WEATHER_MAP_API_KEY)
}
json.decodeFromString(response)
}
}
private val json = Json { ignoreUnknownKeys = true }
}
In addition, Ktor provides a plug-in that can return a response class directly from the response by installing the JsonFeature, so we don’t have to manually convert it from a string to a response class.
Shared Model Class
In the Swift example without KMM, I declared a Weather structure, which is similar to the data class in Kotlin. They are all value reference types and support named with default arguments. Then we can partially initialize some values when constructing the object, for example: Weather(country: "VN")
struct Weather {
var city: String = ""
var country: String = ""
var temperature: Double = 0.0
var feelsLike: Double = 0.0
var condition: String = ""
}
Now, all the use cases are shared implemented by Kotlin, we will convert the Swift struct to the data class in Kotlin and move it in the shared module.
data class Weather(
val city: String = "",
val country: String = "",
val temperature: Double = 0.0,
val feelsLike: Double = 0.0,
val condition: String = "",
)
It will generate native code, which is Objective C, but it has the disadvantage that the default parameter information will be removed. This means that when bridging to Swift to build an object, we must pass all the parameters. Also, the doCopy function in Swift does not support default parameters.
Weather(
city: String,
country: String,
temperature: Double,
feelsLike: Double,
condition: String
)
There is a little trick that by creating a factory function we can initialize the default constructor in Swift, for example: Weather.Factory().default()
data class Weather(
val city: String = "",
val country: String = "",
val temperature: Double = 0.0,
val feelsLike: Double = 0.0,
val condition: String = "",
) {
companion object Factory {
fun default() = Weather()
}
}
Dependency Injection
For ease of understanding, I have implemented a manual provider here. Ideally, lazy is similar to a singleton scope provider. In this way, each specific platform can inject and used directly without knowing its dependencies.
class AppModule(context: AppContext) {
val assetManager: AssetManager = context.createAssetManager()
val client: HttpClient by lazy {
HttpClient()
}
val service: WeatherService by lazy {
if (Constant.IS_MOCK_ENABLED) {
WeatherServiceMock(assetManager)
} else {
WeatherServiceImpl(client)
}
}
val weatherRepository: WeatherRepository by lazy {
WeatherRepository(service)
}
val getCurrentWeatherUseCase: GetCurrentWeatherUseCase by lazy {
GetCurrentWeatherUseCase(weatherRepository)
}
}
Inject use case on Android module:
val module: AppModule = AppModule(AppContext(applicationContext))
val usecase: GetCurrentWeatherUseCase = module.getCurrentWeatherUseCase
Inject use case iOS module:
let module: AppModule = AppModule(context: AppContext())
let usecase: GetCurrentWeatherUseCase = module.getCurrentWeatherUseCase
It reduces the complexity of the platform module, and all the complex things will be handled under a single shared module. In addition, we can apply Koin, which is a lightweight dependency injection framework that supports Kotlin Multiplatform.
Connect to Kotlin Coroutines from Swift
In the Kotlin shared module, we declared a use case to get the current weather by location. This is a suspended coroutine function that will support asynchronous operation.
class GetCurrentWeatherUseCase(private val repository: WeatherRepository) {
suspend fun execute(lat: Double, lon: Double): Weather {
return repository.getWeather(lat, lon)
}
}
When generating native code, it will be converted into a callback handler. Even if we return a non-nullable object, the nullable information will be removed.
getCurrentWeatherUseCase.execute(
lat: Double,
lon: Double,
completionHandler: (Weather?, Error?) -> Void
)
It makes the code in Swift look ugly and remove the ability to calling chain functions. To solve this problem, we can wrap the callback in a function that returns a Future. It is the Combine framework provided by Apple, similar to ReactiveX.
func getWeatherByLocation(lat: Double, lon: Double) -> Future<Weather, Error> {
return Future<Weather, Error>() { [weak self] promise in
self?.getCurrentWeatherUseCase.execute(lat: lat, lon: lon) { result, error in
if let error = error {
promise(.failure(error))
} else {
promise(.success(result ?? Weather.Factory().default()))
}
}
}
}
So we can create a chain request that will retrieve the current weather conditions from the Open Weather service and then query the current weather image from the Unsplash service. Finally, it is mapped to a pair result object.
getWeatherByLocation(lat: lat, lon: lon)
.flatMap { weather in
getImageByKeyword(keyword: weather.condition)
.map { image in (weather, image) }
}
Unit Test
To apply asynchronous unit testing that allows blocking the current thread while waiting for response data. It is required to create additional utils in each platform-specific.
commonTest:
expect val testCoroutineContext: CoroutineContext
expect fun runBlockingTest(block: suspend CoroutineScope.() -> Unit)
iosTest:
actual val testCoroutineContext: CoroutineContext =
newSingleThreadContext("testRunner")
actual fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) =
runBlocking(testCoroutineContext) { this.block() }
androidTest:
actual val testCoroutineContext: CoroutineContext =
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
actual fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) =
runBlocking(testCoroutineContext) { this.block() }
In this tutorial, we will write a simple unit test for GetCurrentWeatherUseCaseTest
. By creating a mock service with a lateinit callback, that will allow providing the expected value in each test.
class MockService : WeatherService {
lateinit var mockGetWeather: (coordinates: Pair<Double, Double>) -> OpenWeatherResponse
override suspend fun getWeather(lat: Double, lon: Double): OpenWeatherResponse {
return mockGetWeather(lat to lon)
}
}
In this test, we overwrite the callback, which will be able to provide a response based on the input coordinates. The use case implementation will get the response from the mock service and then transform it into a domain model. Here we test whether the input parameters processing and the result are correctly mapped.
class GetCurrentWeatherUseCaseTest {
private lateinit var mock: Mock
private lateinit var service: MockService
private lateinit var repository: WeatherRepository
private lateinit var useCase: GetCurrentWeatherUseCase
@BeforeTest
fun before() {
val module = TestModule()
mock = module.mock
service = module.service
repository = module.weatherRepository
useCase = module.getCurrentWeatherUseCase
}
@Test
fun remoteSuccess_getCurrentWeather_shouldReturnWeather() {
service.mockGetWeather = { coordinates ->
mock.createOpenWeatherResponse(coordinates)
}
runBlockingTest {
// Given
val response = mock.createOpenWeatherResponse(Mock.SAIGON_COORDINATES)
// When
val (lat, lon) = Mock.SAIGON_COORDINATES
val result = useCase.execute(lat, lon)
// Then
assertEquals(response.toWeatherModel(), result)
}
}
}
When we run a unit test, there are two test environments. Sometimes passing the test in the Android environment does not mean that it can also pass in iOS. In this example, if we put service.mockGetWeather
in the runBlockingTest
, we will face an exception kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen
, which I think the object is frozen and cannot be modified in an iOS thread.
To leverage the use of Unit Tests, I will create a simple Github Action that will be trigger every time there is a commit on the master branch or when there is a merge request to the master branch. In my example, I only need to run the JVM tests on ubuntu because it is cheaper. To make it more serious, we can add a build task and run it in macOS environment, to make sure there is nothing broken when making a change commit.
name: KMM CI
on:
push:
branches: [ master, ci ]
pull_request:
branches: [ master ]
jobs:
Test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test with Gradle
run: ./gradlew clean test
- name: Publish Unit Test Results
uses: EnricoMi/publish-unit-test-result-action@v1
if: always()
with:
files: shared/build/test-results/**/*.xml
Conclusion
There are some disadvantages while doing iOS development that renaming a function or variable has to do twice in Xcode and Android Studio. Debugging the app is not easy, we can run a debug app from both Android and iOS, while trigger it in Android Studio we cannot set breakpoints and inspect value in Swift and vice versa.
While Flutter introduces cross-platform solutions that focus on Material design with minimal iOS design features, I believe if we want to target iOS first, KMM is the best solution fit here.
Originally published at https://pixelcarrot.com on August 29, 2021.