Blog Infos
Author
Published

Photo by Andreas Gücklhorn on Unsplash

In this article, we’ll explore the recommended approach for implementing Room in Kotlin Multiplatform (KMP) with Koin for dependency injection and the motivations behind each decision.

To visualise the Room implementation, we’ll build a screen using Compose Multiplatform (CMP) and launch the app on Android and iOS.

Getting started

To begin, we add the required dependencies to our libs.versions.toml file.

[versions]
room = "2.7.0-alpha13"
ksp = "2.1.10-1.0.29"
sqlite = "2.4.0"
koin = "4.0.0"
[libraries]
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }

We then use these dependencies in our build.gradle.kts file, alongside using the Room plugin to declare the database schema directory.

plugins {
alias(libs.plugins.room)
alias(libs.plugins.ksp)
}
kotlin {
sourceSets {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
}
commonMain.dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.sqlite.bundled)
api(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}
room {
schemaDirectory("$projectDir/schemas")
}
Room setup

In common code, we create an entity to define the structure of the database table. In this article, we’re storing a list of movies.

@Entity
data class Movie(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val name: String,
)
view raw Movie.kt hosted with ❤ by GitHub

Next, we set up a MovieDao to interact with the database. Using Flow makes the movie list reactive, and suspend functions ensure we don’t block the UI thread during database operations.

@Dao
interface MovieDao {
@Query("SELECT * FROM movie")
fun getMovies(): Flow<List<Movie>>
@Insert
suspend fun insert(movie: Movie)
@Query("DELETE FROM movie")
suspend fun deleteMovies()
}
view raw MovieDao.kt hosted with ❤ by GitHub

Still in common code, we create an abstract class that extends RoomDatabase and incorporates the entity and DAO. We also define a database constructor and link this to the database using the @ConstructedBy annotation.

@Database(entities = [Movie::class], version = 1)
@ConstructedBy(MovieDatabaseConstructor::class)
abstract class MovieDatabase: RoomDatabase() {
abstract fun getMovieDao(): MovieDao
}
// Room compiler generates the `actual` implementations
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object MovieDatabaseConstructor : RoomDatabaseConstructor<MovieDatabase> {
override fun initialize(): MovieDatabase
}

The Room compiler will generate the actual implementations of the database constructor for us, so we add a @Suppress annotation to ignore any warnings related to this.

Database builder

The database requires a builder, and this is the only component in Room for KMP that requires platform-specific logic.

In androidMain, we create a function that takes in an Android Context to define a database path and uses this to return a database builder.

fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<MovieDatabase> {
val appContext = context.applicationContext
val dbFile = appContext.getDatabasePath("movie_database.db")
return Room.databaseBuilder<MovieDatabase>(
context = appContext,
name = dbFile.absolutePath,
)
}

Similarly, in iosMain we create a function that uses NSFileManager and NSDocumentDirectory to define a database path and return a database builder.

fun getDatabaseBuilder(): RoomDatabase.Builder<MovieDatabase> {
val dbFilePath = documentDirectory() + "/movie_database.db"
return Room.databaseBuilder<MovieDatabase>(
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)
}
Database creation

Back in commonMain, we define a function that takes in the platform-specific database builders and creates the database. For the database driver, we use the BundledSQLiteDriver — this is the recommended driver for Room KMP as it provides the most consistent and up-to-date version of SQLite across all platforms. The BundledSQLiteDriver also has the benefit of being usable in common code, which means we don’t have to specify a driver for each platform.

fun getMovieDatabase(builder: RoomDatabase.Builder<MovieDatabase>): MovieDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}

We also configure the database to use Dispatchers.IO for executing asynchronous queries, which is the recommended Dispatcher for database IO operations and ensures the queries won’t block the UI thread.

Koin setup

The final part of this Room KMP setup is using Koin to tie everything together. To start, we create a commonModule in commonMain to manage shared dependencies.

fun commonModule(): Module = module {
single<MovieDao> { get<MovieDatabase>().getMovieDao() }
}
view raw CommonModule.kt hosted with ❤ by GitHub

For platform-specific dependencies, we create a platformModule in commonMain using the expect / actual mechanism.

expect fun platformModule(): Module

We implement this platformModule in androidMain using a provided Context value to create the database.

actual fun platformModule(): Module = module {
single<MovieDatabase> {
val builder = getDatabaseBuilder(context = get())
getMovieDatabase(builder)
}
}

Implementing the platformModule in iosMain is simpler since it does not require a Context value.

actual fun platformModule(): Module = module {
single<MovieDatabase> {
val builder = getDatabaseBuilder()
getMovieDatabase(builder)
}
}
Initialising Koin

Next, we define functions to initialise Koin on both platforms in our common code. As seen above, our Android platformModule requires a Context for the database builder. To provide this, we add a KoinAppDeclaration parameter to our initKoin function. We use this inside the startKoin function, which gives Koin modules access to the Context value.

fun initKoin(appDeclaration: KoinAppDeclaration = {}) {
startKoin {
appDeclaration()
modules(
commonModule() + platformModule()
)
}
}
view raw Koin.kt hosted with ❤ by GitHub

We then create a new class in androidMain that extends Application and calls the initKoin function, passing the Android Context in.

class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin(
appDeclaration = { androidContext(this@MainApplication) },
)
}
}

To use this new MainApplication class, we are required to update the AndroidManifest.xml file.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".MainApplication"
<!-- Rest of manifest -->
</application>
</manifest>

Now we can define a function to initialise Koin for iOS, which doesn’t require a Context value. We encounter a quirk in KMP here, as function default values do not work in native iOS code, so we can’t simply call the initKoin function. To solve this, we define an initKoinIos function that passes in an empty lambda value for the appDeclaration parameter.

fun initKoinIos() = initKoin(appDeclaration = {})
view raw Koin.kt hosted with ❤ by GitHub

The initKoinIos function has to be called in native Swift code. To do this, we use the file name of the function and the function name with the do value prepended. We also import ComposeApp to give the Swift code access to the function.

import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinKt.doInitKoinIos()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
view raw iOSApp.swift hosted with ❤ by GitHub
Complete Room

That’s it! We can now inject the MovieDao in common code, giving us access to our Room database on both platforms.

Crafting a UI

To visualise the Room implementation, we’ll build a movie list screen using Compose Multiplatform and launch the app on both Android and iOS, all within our common code.

We start by defining a MovieUiState for the screen, which holds a movie name the user can enter, and a list of movies to display. For the movie name, we use the recommended TextFieldValue instead of a simple String value.

data class MovieUiState(
val movieName: TextFieldValue = TextFieldValue(""),
val movies: List<Movie> = emptyList()
)
view raw MovieUiState.kt hosted with ❤ by GitHub

Next, we create a MovieViewModel and inject our MovieDao in. The MovieDao is injected straight into the ViewModel here to keep things simple for this article. In production code, the app layering would be more robust, and the MovieDao would be injected into a repository or a data source.

We also add a private MutableStateFlow backing field to store the movie name value.

class MovieViewModel(private val movieDao: MovieDao): ViewModel() {
private val _movieName = MutableStateFlow(TextFieldValue(""))
}
State production

To produce the UI state, we combine the Flow list of movies with the MutableStateFlow movie name field.

class MovieViewModel(private val movieDao: MovieDao): ViewModel() {
private val _movieName = MutableStateFlow(TextFieldValue(""))
val uiState: StateFlow<MovieUiState> = combine(
movieDao.getMovies(),
_movieName
) { movies, movieText ->
MovieUiState(movieName = movieText, movies = movies)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = MovieUiState()
)
}

The stateIn operator is the recommended way to produce UI state from reactive streams. A key reason for this is because it allows state production to start only when collection begins in the UI, instead of occurring as soon as the ViewModel is created if the init{} function is used. This gives you more control over the ViewModel and uiState, making it easier to test.

The stateIn operator also gives us finer-grained control over the state production behaviour through the started parameter. This can be set to either SharingStarted.WhileSubscribed if the state should only be active when the UI is visible, or SharingStarted.Lazily if the state should be active as long as the user may return to the UI.

Finalising the ViewModel

To complete the ViewModel, we provide three functions to update the state.

class MovieViewModel(private val movieDao: MovieDao): ViewModel() {
// ...
fun updateMovieName(newText: TextFieldValue) {
_movieName.value = newText
}
fun insertMovie(movieName: String) {
viewModelScope.launch {
movieDao.insert(Movie(name = movieName))
}
}
fun deleteMovies() {
viewModelScope.launch {
movieDao.deleteMovies()
}
}
}

We also add the ViewModel to our Koin commonModule, allowing us to inject it into our screen.

fun commonModule(): Module = module {
single<MovieDao> { get<MovieDatabase>().getMovieDao() }
singleOf(::MovieViewModel)
}
view raw CommonModule.kt hosted with ❤ by GitHub
Movie screen

With the ViewModel set up, the next step is to create the screen. It is recommended practice to create both a stateful and a stateless version of each screen in your app, as it makes them more reusable, easier to test, and simpler to preview.

We first create the stateful screen by injecting the ViewModel using Koin and collecting the UI state. We then pass the UI state and the state updating functions into the stateless screen.

@Composable
fun MovieScreen(viewModel: MovieViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
MovieScreen(
movies = uiState.movies,
movieName = uiState.movieName,
onUpdateMovieName = viewModel::updateMovieName,
onAddMovie = viewModel::insertMovie,
onDeleteMovies = viewModel::deleteMovies
)
}
view raw MovieScreen.kt hosted with ❤ by GitHub

We then create the stateless screen, using a Scaffold to ensure proper inset padding.

@Composable
fun MovieScreen(
movies: List<Movie>,
movieName: TextFieldValue,
onUpdateMovieName: (TextFieldValue) -> Unit,
onAddMovie: (String) -> Unit,
onDeleteMovies: () -> Unit
) {
Scaffold(modifier = Modifier.fillMaxSize()) { scaffoldPadding ->
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(scaffoldPadding)
.padding(16.dp)
) {
// ...
}
}
}
view raw MovieScreen.kt hosted with ❤ by GitHub

Inside the Column, we add two Composables that enable the user to add a movie to the Room database.

OutlinedTextField(
value = movieName,
onValueChange = { onUpdateMovieName(it) },
label = { Text(text = "Enter movie name") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
if (movieName.text.isNotBlank()) {
onAddMovie(movieName.text)
onUpdateMovieName(TextFieldValue(""))
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Add Movie")
}
view raw MovieScreen.kt hosted with ❤ by GitHub

To display the movies, we define a MovieItem and use this within a LazyColumn to create a scrollable list of movies.

@Composable
fun MovieItem(
movie: Movie,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(4.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Text(
text = movie.name,
modifier = Modifier.padding(16.dp)
)
}
}
view raw MovieScreen.kt hosted with ❤ by GitHub
LazyColumn(modifier = Modifier.weight(1f)) {
items(movies) { movie ->
MovieItem(movie)
}
}
view raw MovieScreen.kt hosted with ❤ by GitHub

To clear the movies list, we create a button and hook this up to the onDeleteMovies function.

Button(
onClick = onDeleteMovies,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
),
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Delete Movies")
}
view raw MovieScreen.kt hosted with ❤ by GitHub

To make the MovieScreen reachable within the app, we simply add it to the base App Composable. In a production app, you would instead integrate this MovieScreen into your existing navigation logic.

@Composable
fun App() {
MaterialTheme {
MovieScreen()
}
}
view raw App.kt hosted with ❤ by GitHub
App deployment

We can now deploy the finished app to both platforms, starting with Android.

Running the app on iOS produces the same behaviour, and validates that the Room implementation is functioning correctly on both platforms! 🎉

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

No results found.

Conclusion

That wraps up this article — I hope it has given you a better understanding of how to use Room in Kotlin Multiplatform with Koin.

You can find my app projects on GitHub — feel free to reach out with any questions or feedback.

Happy coding!

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
With JCenter sunsetted, distributing public Kotlin Multiplatform libraries now often relies on Maven Central…
READ MORE
Menu