Room with Kotlin Multiplatform (KMP)
Recently our team slowly transitioned some of our code from pure native on both iOS and Android, to sharing logic between the platforms with Kotlin Multiplatform.
Quite early on we hit a bump, as our apps are heavily reliant on having a local database. While solutions existed, such as SQLDelight, we felt that the developer experience would take a huge hit by going back to writing SQL Schemas by hand, instead of having them generated by more modern solutions such as SwiftData and Room. We decided that the trade-off was not worth it, and opted out of sharing data models, which ultimately limited our goal of sharing code quite severely. That is, until Room 2.7.0-alpha01 was released, supporting both iOS, Android and desktop (JVM).
In this article I will provide insight into how to set up Room with KMP.
If this is the first time you´re hearing of KMP, or you´re uncertain what it actually is and what it does, I highly recommend taking a glance the introduction before reading further.
Setting up dependencies
First off, we need to add some dependencies to our shared gradle file. The code provided will be for Kotlin DSL with a related libs.version.toml file.
[versions]
ksp = "2.0.20-1.0.25"
room = "2.7.0-alpha09"
sqlite = "2.5.0-alpha10"
[libraries]
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Room relies on KSP (Kotlin Symbol Processing), so we have to add that to our plugins.
plugins {
alias(libs.plugins.ksp)
}
And then for the actual dependencies we need to add the runtime and compiler modules for Room, as well as a SQLLite driver. The runtime is added same way as for an Android project. The Room compiler threw a couple of errors at us when added in a similar fashion. Luckily we found a workaround, but it will require you to define it for each supported platform and architecture.
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.room.runtime)
implementation(libs.sqlite.bundled)
}
}
// Setup iOS Framework
val xcf = XCFramework("Shared") // Shared will be the import name in iOS
// Set list of supported architectures
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "Shared"
xcf.add(this)
isStatic = false
linkerOpts.add("-lsqlite3")
}
}
}
dependencies {
listOf(
"kspAndroid",
"kspIosArm64",
"kspIosX64",
"kspIosSimulatorArm64",
// Add any other platform you may support
).forEach {
add(it, libs.room.compiler)
}
}
Note that we are using the bundled driver, since we are supporting multiple platforms. You can read more about the driver implementations at Androids official documentation.
We are now mostly set to use Room in our shared code, but we also want to support migration of database versions, for that we have to export a JSON representation of the schemas for each version. With the 2.7.0-alpha09 release we started to experience issues when compiling the tests for our project regarding inputDirectory being marked as final, so we had to add a custom implementation in gradle for where to export the schemas.
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File
) : CommandLineArgumentProvider {
override fun asArguments(): Iterable<String> {
return listOf("room.schemaLocation=${schemaDir.path}")
}
}
ksp {
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
Creating the database
Creating the actual database is similar to how its done natively on Android:
// commonMain
@Database(
entities = [MyEntity::class],
version = 1,
exportSchema = true
)
@TypeConverters(MyTypeConverters::class)
@ConstructedBy(MyDatabaseConstructor::class)
abstract class MyDatabase: RoomDatabase() {
abstract fun myEntityDao(): MyEntityDao
}
You might notice a new annotation here, compared to native Android implementation, the @ConstructedBy
. This is one of the key elements for making it available to other platforms, as it is constructed differently for each platform. The Room dependency will actually generate this code for us, we just need to point it towards our database class. In KMP, whenever we have platform-specific code, but have a shared API, we declare something as expect
, while for each platform you need the actual implementation declared as actual
. The compiler will automatically look for the actual implementations, but since the framework will generate these for us, it wont find them at first glance. The solution is to suppress that error.
@Suppress("NO_ACTUAL_FOR_EXPECT", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object MyDatabaseConstructor : RoomDatabaseConstructor<MyDatabase> {
override fun initialize(): MyDatabase
}
Currently declaring classifiers as actual
is in beta, so we need to suppress that warning for now as well. This constructor will not provide a builder for you, so we will need to create that next for each supported platform.
// commonMain
expect fun getDatabaseBuilder(): RoomDatabase.Builder<MyDatabase>
// androidMain
actual fun getDatabaseBuilder(): RoomDatabase.Builder<MyDatabase> {
val context = applicationContext
val dbFile = context.getDatabasePath("mydatabase.db")
return Room.databaseBuilder<MyDatabase>(
context = context,
name = dbFile.absolutePath
)
}
// iosMain
actual fun getDatabaseBuilder(): RoomDatabase.Builder<MyDatabase> {
val dbFilePath = documentDirectory() + "/mydatabase.db"
return Room.databaseBuilder<MyDatabase>(
name = dbFilePath,
)
}
@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
return requireNotNull(documentDirectory?.path)
}
Notice that we do not pass in any context to the Android implementation here, that is because our team uses App Startup to inject context at startup instead of passing it around everywhere. Now, let us actually build the database.
// commonMain
fun getRoomDatabase(
builder: RoomDatabase.Builder<MyDatabase>
): MyDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}
Using the database
The database is ready for use. On Android you can interact with it just as if it was native. For iOS you can create a small manager to interact with it
import Shared // The named of the framework defined in your gradle file
class DatabaseManager {
static let shared = DatabaseManager()
let db: MyDatabase
let myEntityDao: MyEntityDao
init() {
// MyDatabaseKt refers to the filename where getRoomDatabase function is located
self.db = MyDatabaseKt.getRoomDatabase(builder: DatabaseKt.getDatabaseBuilder())
self.myEntityDao = self.db.myEntityDao()
}
}
// Usage from another file
let entities = DatabaseManager.shared.myEntityDao.getAllEntities()
I do recommend not interacting with it directly in iOS, and rather create interfaces that both platforms can communicate with.
Final words
Once our team introduced the shared database, we managed to share so much more of our codebase, and productivity has skyrocketed as a result. We have also become a more united team as we now solve problems together, instead of separately for each platform.
While Room for KMP is not feature-complete, we have yet to hit a wall, as most features not implemented yet are mostly corner-case usage. One feature that has been dropped entirely is running queries on the main thread, which in my opinion is a good thing, as locking that thread for any other reason than UI-updates are just bad practice.
If you would like to share even more code between platforms, I recommend reading into Ktor for shared networking, moko-resources for shared resources and KMP-ObservableViewModel for shared flows and viewmodels.