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:
- All the business models are multiplatform
- Repository contracts and domain logic are multiplatform
- All the UI models are multiplatform
- The presentation logic is multiplatform
- The iOS UI is complete
- The data layer is multiplatform
- 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 |
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 |
Android:
typealias AndroidParcelable = android.os.Parcelable | |
actual interface Parcelable : AndroidParcelable | |
actual typealias Parcelize = kotlinx.parcelize.Parcelize |
iOS:
actual interface Parcelable |
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:
- Switch to a “vanilla” viewModel and implement the pattern from scratch.
- Create a
ViewModel
interface in multiplatform and implement it in Android as a Jetpack ViewModel.
Given the advantages that Jetpack ViewModel
s 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 | |
} |
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 | |
} |
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) | |
} |
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
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 HashingFunction
, StringCipher
, 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 | |
} |
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
– Marco Signoretto
Originally published at https://msignoretto.com.