Blog Infos
Author
Published
Topics
,
Published
Topics
,

Kotlin Multiplatform banner by JetBrains

 

In the first article, we covered the basics of application architecture and the first steps in multiplatform using KMP. This time we will dive into data sources, which libraries to use, and how to keep the data integrity during the migration.

This article is part of a series of migrating an existing Android app to run on iOS using Kotlin Multiplatform. You can access the other articles in the following links:

Architecture

Going back to the architecture, Alkaa has two data sources: local and datastore. The first one is responsible for data persistence using SQLite through Room, and the second is for a key-value light database, using Preferences DataStore. At this time, there is no remote layer connecting to a server.

Alkaa’s simplified architecture

Both data source implementations use Android-only libraries, which we need to change to be able to work with KMP. Also, we need to ensure that the user won’t face any issues during the migration and that all the data will be available as it is.

Local database

In the Android version of Alkaa, Room was used to store all the tasks and their information, such as description, alarm time, and category. However, this library is not yet ready for Kotlin Multiplatform. Fortunately, we have a few alternatives for KMP, mostly noticeable, SQLDelight.

The structure of SQLDelight is a bit different from Room: instead of relying on annotation processors, it generates typesafe APIs from the SQL statements. This will require more manual steps but this shouldn’t be a problem since we will migrate an existing schema.

The goal of this section is to focus on the steps of migrating an existing database from Room to SQLDelight. If you need more information about the basics of how to set up SQLDelight, please access the official docs.

Keep the existing data

Since we are migrating an existing application, it’s critical that we keep all the existing data from the database. Otherwise, the user will lose their data when the app is upgraded.

SQLDelight supports SQLite, which is the same database used by Room. The idea is instead of recreating the database, we simply replace the library that wraps it. We can do that by implementing the following steps:

1. Recreate all the table setups identically

For SQLDelight to manipulate the existing tables, we need to ensure that all the newly generated code will match the existing structure. For example, this is the Room structure for the Category table:

@Entity
data class Category(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id")
var id: Long = 0,
@ColumnInfo(name = "category_name") var name: String,
@ColumnInfo(name = "category_color") var color: String,
)
view raw Category.kt hosted with ❤ by GitHub

Where, for SQLDelight, we provide the actual SQL statement for the creation:

CREATE TABLE IF NOT EXISTS Category (
`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`category_name` TEXT NOT NULL,
`category_color` TEXT NOT NULL
);
view raw Category.sq hosted with ❤ by GitHub

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

As the example above shows, we need to ensure that all the structures are matching. When using Room, it might not be very clear when we define a type as not null (e.g. category_name and category_color) that it will be converted to NOT NULL on SQL, for instance.

If any of the fields do not match how the definition was in Room and how it’s defined on SQLDelight, the app will crash. Thankfully, there’s an easier way to do that while migrating from an existing schema.

Room supports exporting the schemas automatically every time that the database version is bumped. Also, it’s likely that if you are using Room’s automated migrations, this setup is already in place for your project. These schema files already contain all the SQL statements for creating each table. Here’s an example:

alkaa/data/local/schemas/com.escodro.local.TaskDatabase/4.json at v2.3.0 · igorescodro/alkaa

Instead of manually creating all the tables and making sure they match, go to the latest schema file and simply copy the statements and add them to their respective .sq file.

2. Add all the database migrations

Speaking about migrations, we need to ensure that all the existing migrations will still be available for the users. This is important for two reasons:

  • Users migrating from older versions of the app and database — this is required regardless of which SQLite library we are using. Migration files ensure that the database knows how to upgrade to new versions. Not providing this will crash the app on upgrades — the user will need to clear the data to be able to reopen the app.
  • Versioning — SQLDelight also uses these files to version the schema. Not providing this will set up the SQLDelight configuration back to version 1. If your app is at a higher version, the app will also crash due to version mismatches.

Alkaa Migrations

The existing version of SQLDelight does not support auto migrations. The SQL statements for that will need to be manually created for each migration file. For more information about migrations on SQLDelight, please access the official docs.

3. Migrate the adapters

SQLDelight also allows custom column types such as Enums, DateTime, List, etc. In order for them to work with the SQLite primitive types, we need adapters. The library already provides a few ones, however, for more complex types we need to write our own implementations.

On Room, we rely on an annotation processor, whereas on SQLDelight we create an implementation of the ColumnAdapter interface. For more information about custom column types on SQLDelight, please access the official docs.

@TypeConverter
fun toAlarmInterval(id: Int?): AlarmInterval? =
AlarmInterval.values().find { it.id == id }
@TypeConverter
fun toId(alarmInterval: AlarmInterval?): Int? =
alarmInterval?.id

TypeConverter on Room

val alarmIntervalAdapter = object : ColumnAdapter<AlarmInterval, Long> {
override fun decode(databaseValue: Long): AlarmInterval =
AlarmInterval.values().find { it.id == databaseValue.toInt() }!!
override fun encode(value: AlarmInterval): Long =
value.id.toLong()
}

ColumnAdapter on SQLDelight

4. Provide the platform-specific code

SQLDelight is a KMP-ready library, which means that we can have a single implementation for multiple platforms. However, we still need to provide a platform-specific SqlDriver to help the library create and open the database on Android and iOS.

actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(Database.Schema, context, "todo.db")
}
}

Android implementation

actual class DriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(Database.Schema, "todo.db")
}
}

iOS implementation

 

One thing you might notice is that the signatures for both actual implementations are different: on Android, we receive a Context and on iOS we don’t receive any parameter. There are a few ways to implement this, but for now, I would like to share two references I found very useful. Currently, I’m using the first one:

Pre-populating the database

In Alkaa, the Category table is pre-populated with a few categories that are localized based on the user’s device language. Currently, SQLDelight does not have an onCreate callback to inform when the schema is created as Room does. Instead, we can check if the database exists and add the entries if not, meaning that this is executed only on the first time.

In order to work, we need specific code to try to find the file on each platform. On Android this is simple: we already have a handy context function to help us:

override fun shouldPrepopulateDatabase(databaseName: String): Boolean =
!context.getDatabasePath(databaseName).exists()

On iOS, the functions to check if a file exists use the Objective-C/Swift APIs. But guess what: we can still use Kotlin to write the code since KMP has wrappers for them. One important thing to notice is the path on which SQLDelight creates the database, which took me some time to debug.

A simple code using NSFileManager would look like this:

override fun shouldPrepopulateDatabase(databaseName: String): Boolean =
!databaseExists(databaseName)
private fun databaseExists(databaseName: String): Boolean {
val fileManager = NSFileManager.defaultManager
val documentDirectory = NSFileManager.defaultManager.URLsForDirectory(
NSLibraryDirectory,
NSUserDomainMask,
).last() as NSURL
val file = documentDirectory
.URLByAppendingPathComponent("$DATABASE_PATH$databaseName")?.path
return fileManager.fileExistsAtPath(file ?: "")
}
private const val DATABASE_PATH = "Application Support/databases/"

With this information, we can insert the data only when the schema is created.

Local preferences database

In Alkaa, the local preferences database is used to store simple key-store values, such as the application theme (light, dark, or system default) using Preferences DataStore from Android Jetpack libraries.

Fortunately, this library is part of the efforts Google is making to port Android libraries to KMP support. Currently, this library is in the alpha, so keep in mind that the API is not production-ready yet and Alkaa is an open-source playground app. The code to implement is straightforward and there’s an official sample at GitHub.

private lateinit var dataStore: DataStore<Preferences>
private val lock = SynchronizedObject()
fun getDataStore(producePath: () -> String): DataStore<Preferences> =
synchronized(lock) {
if (::dataStore.isInitialized) {
dataStore
} else {
PreferenceDataStoreFactory.createWithPath(
produceFile = { producePath().toPath() },
).also { dataStore = it }
}
}
internal const val dataStoreFileName = "settings.preferences_pb"
view raw DataStore.kt hosted with ❤ by GitHub

Code on commonMain

fun getDataStore(): DataStore<Preferences> = getDataStore(
producePath = { context.filesDir.resolve("datastore/$dataStoreFileName").absolutePath },
)

Code on androidMain

@OptIn(ExperimentalForeignApi::class)
fun getDataStore(): DataStore<Preferences> = getDataStore(
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
},
)
view raw IosDataStore.kt hosted with ❤ by GitHub

Code on iosMain

Keep the existing data

To keep using the existing preferences database, we need to pay attention to the following details:

  • Setup the same name with extension — during the setup, we need to ensure that the file and extension name are the same. If the DataStore file was set as my_data_store in the Android-only version, the KMP version would need to be my_data_store.preferences_db.
  • Setup the same file path — to make sure we use the existing preference file instead of creating a new one, we need to make sure that the same path from Android DataStore is set. This path can be found by the following function:
context.filesDir.resolve("datastore/$dataStoreFileName").absolutePath

By following those simple steps, we can use the same preferences database created by Android DataStore with the multiplatform one. The complete port to multiplatform DataStore can be found on the following pull request:

♻️ Convert Data Store module to KMP by igorescodro · Pull Request #541 · igorescodro/alkaa

Remote/network

As mentioned previously, Alkaa does not have a remote layer connected to a server. However, since this layer is not responsible for persistent data, the replacement should be simple. For multiplatform network libraries, ktor is a great alternative.

What’s next?

We can notice again that having a clear architecture with a good separation of concerns and separating the Android framework code from the business logic, made the migration process much easier. Instead of committing to huge changes, the migration is happening layer-by-layer, giving space and opportunity to focus on each detail before moving to the next one.

The code migrating the data sources can be found on GitHub:

Since all the business logic and data layers are not ported to Kotlin Multiplatform, now it’s time to move to the UI. In the next articles, we will dive into Compose Multiplatform, resource management, navigation, and how to deal with features that are only available to one platform. In the meantime, you can follow the work-in-progress pull request with these changes:

Thank you so much for reading my article! ❤️

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Back in 2020 I built a simple app to save my notes and protect…
READ MORE
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

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