Blog Infos
Author
Published
Topics
,
Published
Topics
,

Image by Devraj Bajgain from Pixabay

 

Back in 2020 I built a simple app to save my notes and protect them with a password. The project is called Notes and it is explained HERE.

Initially, I developed the app natively for Android. However, I later realized that building an iOS version of the app presented an excellent learning opportunity.

Rather than duplicating my efforts, I opted to use Kotlin Multiplatform as it allowed me to leverage my existing expertise and easily share code between the two platforms.

Firstly, Let me share the project goals:

  • My main objective is to share business logic and presentation logic, but not UI.
  • Additionally, I’m interested in experimenting with SwiftUI to see how it compares to Jetpack Compose
The journey

To migrate your app effectively, it’s important to do it incrementally instead of all at once. I find it helpful to break down objectives into milestones. For this project, I identified 7 milestones and ensured that the Android app worked as before after each one.

Those milestones are:

  1. All the business models are multiplatform
  2. Repository contracts and domain logic are multiplatform
  3. All the UI models are multiplatform
  4. The presentation logic is multiplatform
  5. The iOS UI is complete
  6. The data layer is multiplatform
  7. The Security layer can be shared

The app followed the principles of clean architecture, as shown in the diagram below.

The arrows represent the dependency directions, with the UI depending on the presentation layer, which in turn depends on the domain layer and so on.

It’s worth noting that a specific implementation is considered a “plugin” for the domain layer. For example, my project’s datastore implementation was based on Room, but it could be reimplemented using another library while still adhering to the same high-level contract. More details on this will be provided later.

Milestone 1: All the business models are multiplatform

The initial step of the migration involved moving the business models to the commonMain folder of a new common multiplatform module. In my case, the models were already platform-agnostic, making this step straightforward.

After moving the models, I made the original Android/JVM module dependent on the new module.

If you are not yet in this situation you should refactor your code to make it platform-agnostic. Your business models should have no reason to depend on any platform-specific dependency.

Another piece of advice is to avoid mixing business models with business logic. If you have any business logic in your models you should move it out.

Milestone 2: Repository contracts and domain logic are multiplatform

With the business models successfully migrated to the multiplatform module, the next step is to transfer the contracts on which the business logic depends.

In the original implementation, I didn’t fully adhere to the clean architecture principles, and one of my violations was that I lacked an interface or contract for my NotesRepository. As a result, my viewModels depended on the actual implementation of the repository, which would complicate the migration process.

Creating the contract for NotesRepository and abstracting the implementation

To address the issue, I began by addressing the lack of a contract or interface for the repository. I created a new interface based on the repository contract and bound the implementation to it using Dagger.

This was enough to reverse the dependency direction. As a result, the ViewModels only know about the contract, not the actual implementation.

To better understand the change, consider the before-and-after comparison below:

Before:

After:

Ensure that all dependencies are multiplatform

To move the contract we had another problem to address first. I was using RxJava which is a JVM specific dependency that cannot be migrated as it is.

After considering several options, I decided to migrate the entire app to Coroutines.

Coroutines and Flows are multiplatform and offer similar functionality to RxJava and LiveData for managing concurrency and reactive programming.

To determine if a file is ready for migration to the multiplatform module, you can check its imports. If you see any Android or Java-specific dependencies, then the file is not yet ready.

Move the contract to the multiplatform module

After successfully migrating the whole app, including the ViewModels, to use Coroutines and Flows, I was able to move the NotesRepository contract to the multiplatform module.

The final interface, which only contains imports from multiplatform libraries or code that we already migrated, is shown below:

package com.msignoretto.note.newnote
import com.msignoretto.note.shared.domain.Note
import kotlinx.coroutines.flow.SharedFlow
interface NotesRepository {
val stream: SharedFlow<List<Note>>
suspend fun insert(title: String, content: String)
suspend fun update(uid: String, title: String, content: String)
suspend fun delete(uid: String)
suspend fun lock(uid: String, title: String, content: String, password: String)
suspend fun unlock(uid: String, password: String): Note
suspend fun biometricUnlock(uid: String): Note
suspend fun getNote(uid: String): Note?
suspend fun fetchAll(): List<Note>
}

With the domain layer migrated, the architecture appeared as shown below.

Milestone 3: All the UI models are multiplatform

With the Domain layer successfully migrated to KMP, it was time to tackle the presentation layer, starting with the UI models.

In my case, I followed an MVI-like architecture, where each screen had a corresponding viewModel and UIState representing the entire screen state.

A common issue you might face is that your UI models are not platform agnostic, for example, you might have it annotated with @Parcelize and extends Parcelable to be able to store and restore them from Bundle s.

Let’s take one of my models as an example:

@Parcelize
data class UINote(
val uid: String,
val title: String,
val description: String,
val isSecure: Boolean,
) : Parcelable
view raw UINote.kt hosted with ❤ by GitHub

One way to address this issue is to add @Parcelize and Parcelable to KMP as expect and actual declarations, and implement them with typealiases for Android and an empty interface for iOS as shown below:

Common:

@OptIn(ExperimentalMultiplatform::class)
@OptionalExpectation
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class Parcelize()
expect interface Parcelable
view raw Parcelable.kt hosted with ❤ by GitHub

Android:

typealias AndroidParcelable = android.os.Parcelable
actual interface Parcelable : AndroidParcelable
actual typealias Parcelize = kotlinx.parcelize.Parcelize
view raw Parcelable.kt hosted with ❤ by GitHub

iOS:

actual interface Parcelable
view raw Parcelable.kt hosted with ❤ by GitHub

Alternatively, we can introduce another layer between the UI model and the platform-specific representation, such as ParcelableUINote. This class can be used to store and restore data in and from a Bundle. A mapper will then be used to map it back and forth to the platform-agnostic UINote.

While the second approach keeps the UI models platform-agnostic and avoids no-op implementations on iOS, it requires more boilerplate code and is more verbose than the first approach.

Milestone 4: The presentation logic is multiplatform

My viewModels were based on Android Jetpack ViewModel s, and, as we saw earlier we cannot use those in multiplatform code.

To address this, we have two options:

  1. Switch to a “vanilla” viewModel and implement the pattern from scratch.
  2. Create a ViewModel interface in multiplatform and implement it in Android as a Jetpack ViewModel.

Given the advantages that Jetpack ViewModels offer in terms of configuration changes and state restoration, I decided to go with option 2 and avoid reinventing the wheel. To do this, I created a ViewModel as an expect class in multiplatform, like so:

expect open class ViewModel {
public val viewModelScope: CoroutineScope
}
view raw ViewModel.kt hosted with ❤ by GitHub

Then, I created an actual implementation for Android that looks like this:

import androidx.lifecycle.viewModelScope as androidViewModelScope
typealias AndroidViewModel = androidx.lifecycle.ViewModel
actual open class ViewModel : AndroidViewModel() {
actual val viewModelScope: CoroutineScope = androidViewModelScope
}
view raw ViewModel.kt hosted with ❤ by GitHub

For iOS, the actual class looks like this:

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
actual open class ViewModel {
actual val viewModelScope: CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
}
view raw ViewModel.kt hosted with ❤ by GitHub

With this decoupling of platform-specific dependencies, all the viewModels are now ready to be moved to the multiplatform module.

The overall situation at this point looked like the following:

Milestone 5: The iOS UI is complete

This step was one of the most time-consuming. I had to re-implement all of my Compose widgets using SwiftUI. However, the conversion process was not overly difficult since Compose and SwiftUI share the same concept of declarative UI, and their respective modifiers are quite similar.

It’s worth noting that I completed this step before ChatGPT was released. These days, you could even use ChatGPT to automatically convert your UI widgets from one framework to the other and fine-tune the result.

At the end of this journey, the architecture looked like this:

Milestone 6: The data layer is multiplatform

Moving the data layer was a bit tricky, I had to move the NotesRepositoryImpl to multiplatform.

The NotesRepositoryImpl class depends on several libraries, including Room, which is not available on iOS. To address this, I opted to use SQLDelight, an open-source library from Square that is multiplatform and widely used in the Kotlin Multiplatform community.

To ensure a smooth migration, I created a new implementation of the class, rather than moving it in one shot.

This approach provided me with the flexibility to swap the implementation with a proxy and migrate from the existing Room database to the SQLDelight one keeping the data on both databases while giving me the option to rollback if something would have gone sideways.

Database migration

As we discussed earlier, we need to migrate the dependencies related to the database layer from Room to SQLDelight.

To illustrate the difference between the two implementations, let’s take a look at the original Room based insert function:

override suspend fun insert(title: String, content: String) {
val uid = UUID.randomUUID().toString()
noteDao.insert(
NoteEntity(
uid = uid,
title = title,
content = content.toByteArray(),
isSecured = false,
password = byteArrayOf()
)
)
val notes = fetchNotes()
_stream.emit(notes)
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

The Art of KMP: what you need to know as a multiplatform developer

There’s a lot of Android learning roadmaps on the internet, which cover some topics every Android developer should be familiar with.
Watch Video

The Art of KMP: what you need to know as a multiplatform developer

Lena Stepanova
Mobile Developer

The Art of KMP: what you need to know as a multiplatform developer

Lena Stepanova
Mobile Developer

The Art of KMP: what you need to know as a multiplatform developer

Lena Stepanova
Mobile Developer

Jobs

And the SQLDelight-based implementation:

override suspend fun insert(title: String, content: String) {
noteDao.notesQueries.transaction {
val maxPosition = noteDao.notesQueries.maxPosition().executeAsOneOrNull()?.MAX
val uid = UUID.randomUUID()
noteDao.notesQueries.insert(
uid = uid,
title = title,
content = content.safeEncodeToByteArray(),
isSecured = false.toLong(),
position = maxPosition?.plus(1) ?: 0,
passwordHash = byteArrayOf(1) // workaround for issue https://github.com/touchlab/SQLiter/issues/42
)
}
val notes = fetchNotes()
_stream.emit(notes)
}

As you can see, the SQLDelight-based implementation uses different APIs to run queries. For instance, instead of directly invoking the insert function of the NoteDao interface, we use the notesQueries property. and we used the executeAsOneOrNull function to retrieve the result of a query in a null-safe manner.

Overall, migrating to SQLDelight required some effort, but it allowed us to have a shared database layer that worked seamlessly across platforms.

Milestone 7: The Security layer can be shared

Security operations such as encryption and hashing are platform-specific and each platform has its own ways of handling them. In the project, I was using a library called Sekurity, which I created to wrap Android and JVM encryption functions.

If you look at some of the dependencies in NotesRepositoryImpl

import com.msignoretto.sekurity.KeyStore
import com.msignoretto.sekurity.SecurityKey
import com.msignoretto.sekurity.StringCipher
import com.msignoretto.sekurity.hashing.HashingFunction
import com.msignoretto.sekurity.toBinaryData
import com.msignoretto.sekurity.toByteArray
...
@Singleton
class NotesRepositoryImpl @Inject constructor(
...
private val hashingFunction: HashingFunction,
private val textCipher: StringCipher,
private val keyStore: KeyStore
) : NotesRepository {
...
}

You realize I needed to migrate the HashingFunctionStringCipher, and KeyStore dependencies when moving to Kotlin Multiplatform. This is where the flexibility of Kotlin Multiplatform shines: it allows you to decide which parts of your architecture to share and which to keep platform-specific.

To achieve this, I updated Sekurity to be a Kotlin Multiplatform library and moved the security contracts to commonMain. I want to emphasize that Sekurity only exposes klib s for multiplatform to avoid the overhead of multiple KMP frameworks and duplicated Kotlin runtime, which can affect the iOS app’s memory footprint.

For the iOS implementation of Sekurity, I did not provide a specific implementation. Instead, it is up to the client to implement the common contract. This is due to the same reason mentioned above, as well as the fact that you cannot interop with CriptoKit out of the box since KMP is not directly compatible with Swift but it only iterops with Objective-C.

Let’s have a look at the concrete example of StringCipher

In the common code you can find the contract:

interface SecurityKey
interface BinaryData
interface StringCipher {
fun encrypt(securityKey: SecurityKey, plainData: String): BinaryData
fun decrypt(securityKey: SecurityKey, encryptedData: BinaryData): String
}
view raw StringCipher.kt hosted with ❤ by GitHub

And the implementation for Android:

class StringCipherImpl constructor(
private val cipher: SymmetricCipher
) : StringCipher {
override fun encrypt(securityKey: SecurityKey, plainData: String): BinaryData =
cipher.encrypt(securityKey, plainData.encodeToByteArray().toBinaryData())
override fun decrypt(securityKey: SecurityKey, encryptedData: BinaryData): String =
cipher.decrypt(securityKey, encryptedData.toByteArray().toBinaryData()).toByteArray()
.decodeToString()
}

which delegates the actual implementation at the jvm-based AESCipher.

And the implementation for iOS:

import Security
import CryptoKit
import shared
final class TextCipherImpl: StringCipher {
func decrypt(securityKey secretKey: SecurityKey, encryptedData: BinaryData) -> String {
let key = secretKey.key()
guard let sealedBox = try? AES.GCM.SealedBox(combined: (encryptedData as! BinaryDataImpl).data),
let decryptedData = try? AES.GCM.open(sealedBox, using: key),
let decryptedString = String(data: decryptedData, encoding: .utf8) else { fatalError("Decryption failed") }
return decryptedString
}
func encrypt(securityKey secretKey: SecurityKey, plainData: String) -> BinaryData {
let key = secretKey.key()
guard let data = plainData.data(using: .utf8) else { fatalError("Encryption failed") }
let sealedBox = try! AES.GCM.seal(data, using: key)
return sealedBox.combined!.toBinaryData()
}
}

which delegates the implementation to the CriptoKit framework.

It is interesting to notice how the SecurityKey exists to abstract the platform-specific implementation of the key. In the case of Android, the key is a wrapper for a javax.crypto.SecretKey while in iOS is a wrapper on NSData representing a SymmetricKey.

If you are interested in interoperability with Swift libraries, you can read about it in this ARTICLE.

Finally, the project architecture became as below:

Limitations

Like all technologies, KMP has its limitations, such as (but not limited to) the following:

  • Overhead in memory and app size if you have many independent KMP frameworks.
  • No interop with Swift libraries, only with Objective-C.
  • Lack of support for namespaces on generated Swift bindings, which can create naming conflicts.
  • Difficulties with debugging and testing on iOS.
  • The ecosystem is still young and not as mature as the Android or iOS ecosystems.
Conclusion

Migrating an existing app to KMP is a challenging process that requires careful planning and a significant amount of work. The effort required is not always linear and can depend on factors such as the complexity of the app and the amount of code you want to share.

One key lesson I learned from my experience is to leave platform-specific logic on the platform. This rule of thumb can help simplify your architecture and avoid coupling with platform-specific libraries.

When starting with KMP, it’s important to start small and iterate.

Kotlin multiplatform offers great interoperability, and you can decide where to set the boundaries of your architecture.

As you saw in this article, we gradually expanded the boundaries at each step. Deciding on these boundaries can be challenging and depends on various factors such as team structure and skill sets. The boundaries may also evolve over time as new libraries are released that facilitate sharing different parts of the application that were previously hard to share.

I hope you enjoyed this article and learned something new about KMP.

References
  • The Notes app product page is available HERE
  • The Sekurity library used in the app is available HERE

– Marco Signoretto

Originally published at https://msignoretto.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
One of my 2021 new year’s resolutions was to dive in into Kotlin Multiplatform Mobile (KMM).…
READ MORE
blog
Picture this: You’re using a mobile app, trying to find crucial information, but each…
READ MORE
blog
Compose Multiplatform opens a world of possibilities for developers, allowing them to build native-looking…
READ MORE
blog
Since I started Alkaa, a task management open-source app, I have dreamt of also…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu