avatarYanneck Reiß

Summary

This article guides developers through setting up dependency injection with Koin and creating a cross-platform database using SQLDelight for a Kotlin Multiplatform mobile app.

Abstract

In the second part of a series on building a cross-platform mobile app with Kotlin Multiplatform (KMP) and Compose Multiplatform for iOS, the article delves into implementing the data layer. It begins by configuring Koin as the dependency injection framework for managing object creation according to the SOLID principles. The tutorial then proceeds to introduce SQLDelight, an ORM compatible with Kotlin Multiplatform, to handle the app's database needs. The setup includes installing the SQLDelight IDE plugin, adding necessary dependencies, and generating the database schema with SQLDelight's custom file format. The author provides detailed instructions on creating a database, adapting custom types, and implementing platform-specific drivers for Android and iOS. Finally, the article demonstrates how to create a repository class to interact with the database, leveraging the coroutines-extensions provided by SQLDelight to observe data changes. The conclusion emphasizes the successful integration of Koin and SQLDelight as foundational components for a cross-platform mobile app.

Opinions

  • The author endorses Koin for dependency injection in Kotlin Multiplatform projects due to its compatibility and ease of use.
  • SQLDelight is recommended for its multiplatform support and rich feature set, including syntax highlighting, refactoring, and code auto-completion.
  • The knowledge of using SQLDelight is considered valuable beyond Kotlin Multiplatform projects, as it can be applied to Android standalone projects.
  • The author suggests that the SQLDelight plugin enhances the development experience with features like automatically generating Queries files and providing compiler errors in the IDE.
  • The article implies that using a custom Application class in Android to initialize shared context is a beneficial practice for Kotlin Multiplatform mobile development.
  • The author encourages readers to follow the series and expresses optimism about the takeaways from the tutorial, inviting feedback through claps, subscriptions, and follows for more content.

Create Your First Fully Cross-Platform Mobile App With Compose Multiplatform 2/4 — Data Layer

Build a cross-platform iOS and Android app using the Jetpack Compose API with a single codebase

In this article series, we build a fully cross-platform mobile app with Kotlin Multiplatform (KMP) in combination with Compose Multiplatform for iOS. In the first part of this series, we took a journey through mobile app development for not only one but both of the leading mobile app platforms, Android and iOS.

We then discussed the note-taking app we would build in this series and laid the starting point by creating our KMP project based on the Compose Multiplatform for iOS template.

In this second part of this series, we will take up on this and start the implementation process from the bottom up by setting up Koin as our dependency injection framework and SQLDelight as our multiplatform database.

Below, you can find the table of contents for this article series.

  1. Introduction
  2. Dependency Injection and Database (you are here)
  3. Use Cases and view models
  4. Navigation system and Compose Multiplatform UI code

1 Setup dependency injection with Koin

To go along with one of the best-known software development principles, SOLID, and its dependency inversion, which the “D” stands for, we don’t want to create our objects by ourselves but want to leave the job to a dependency injection framework.

For this job, we will use Koin, as it is already supported for Kotlin Multiplatform development.

Dependencies

To introduce Koin into our Kotlin Multiplatform project, we need to add the following dependencies. Note, however, that the dependencies for androidMain are only needed because we will need to use some platform-specific functionalities later in this article.

sourceSets {
    val commonMain by getting {
        dependencies {
            // ..
            implementation("io.insert-koin:koin-core:3.4.3")
            implementation("io.insert-koin:koin-compose:1.0.4")
        }
    }

    val androidMain by getting {
        dependencies {
            // ..
            implementation("io.insert-koin:koin-android:3.4.3")
            implementation("io.insert-koin:koin-androidx-compose:3.4.6")

        }
    }
    ..
}

Now, create the folder structure shared/commonMain/core/injection and create a new Kotlin file inside. Call it MainModule and add the following code:

import org.koin.dsl.module

val mainModule = module {
  // Here we can later provide our injectable objects
}

To start Koin at runtime, we adapt the first code to the actual Compose Multiplatform code. Therefore, go into the App.kt file in the shared/commonMain/kotlin folder.

Because Koin already supports Compose Multiplatform through the koin-compose dependency, we can directly use the KoinApplication composable to initialize the framework.

To do so, go into the shared/commonMain/kotlin/App.kt file and adapt the existing code to the following

import androidx.compose.runtime.Composable
import core.injection.mainModule
import org.koin.compose.KoinApplication
import ui.NavigationHost
import ui.theme.AppTheme

@Composable
fun App() {
    KoinApplication(application = {
        modules(mainModule)
    }) {
        // The default composable content
    }
}

Now we have our dependency injection framework in place and can proceed with setting up our database

2 Introducing our Database With SQLDelight

For this part, we will use SQLLite with SQLDelitght by Cashapp as our ORM, which is compatible with Kotlin Multiplatform. Because the Jetpack Datastore is already available for KMP, there also might be an implementation for Android Room someday, but until then, we can stick to this solution.

SQLDelight IntelliJ Plugin

First, we install a new IDE plugin for Android Studio, the SQLDelight plugin. The SQLDelight plugin offers enhanced support for its own file format, which can be identified with the .sq as suffix. It provides a rich feature suite like syntax highlighting, refactoring, code auto-completion, and automatically generating so-called “Queries” files, at which we will look in a second. Additional functionalities include the ability to right-click to copy as valid SQLite and the provision of compiler errors in the IDE that link directly to the corresponding file.

To mention, even if you don’t use KMP after this tutorial any further, the knowledge of how to use SQLDelight with SQLite is not forgotten. If you like the idea of this framework, of course, you can also use it for your Android standalone projects.

Setup

Because we need access to the dependency version for SQLDelight at multiple module levels, we introduce a new dependency version in our gradle.properties file. All the dependencies introduced here can be accessed through the Gradle extra property. Therefore, add a new value for SQLDelight and name it sqdelight.version=2.0.0:

#Versions
sqldelight.version=2.0.0

Next, go into your project-level settings.gradle.kts file. Here, in the plugins {..} section, add the following to declare the version for SQLDelight:

plugins {
    // ..
    val sqlDelightVersion = extra["sqldelight.version"] as String
    id("app.cash.sqldelight").version(sqlDelightVersion)
}

Now we can go into the project-level build.gradle.kts file and add the SQLDelight plugin to the plugins section:

plugins {
    // this is necessary to avoid the plugins to be loaded multiple times
    // in each subproject's classloader
    kotlin("multiplatform").apply(false)
    id("com.android.application").apply(false)
    id("com.android.library").apply(false)
    id("org.jetbrains.compose").apply(false)
    id("app.cash.sqldelight").apply(false)
}

But more is needed. We also have to add the plugin to the plugins section at the top of our build.gradle.kts file in the shared module.

plugins {
    kotlin("multiplatform")
    kotlin("native.cocoapods")
    id("com.android.library")
    id("org.jetbrains.compose")
    id("app.cash.sqldelight")
}

Finally, in the same file, we need to add the dependencies for the driver. To do so, add the required dependencies for the native SQL drivers. As you can see, we also add the primitive-adapters for the commonMain module, which will later help us work with the generated code as well as the coroutines-extensions to observe changes to our database as a Flow.

kotlin { 
  // Versions
  ..
  val sqlDelightVersion = extra["sqldelight.version"] as String

  sourceSets.commonMain.dependencies {
    implementation("app.cash.sqldelight:primitive-adapters:$sqlDelightVersion")
    implementation("app.cash.sqldelight:coroutines-extensions:$sqlDelightVersion")
  }
  
  sourceSets.androidMain.dependencies {
    implementation("app.cash.sqldelight:android-driver:$sqlDelightVersion")
  }

  sourceSets.iOSMain.dependencies {
    implementation("app.cash.sqldelight:native-driver:$sqlDelightVersion")
  }
}

After successfully syncing, we finished introducing the plugin and its dependencies to our project. But we are still going. While still in the build.gradle.kts file in the shared module, add the following setup code below the plugins block:

sqldelight {
    databases {
        create("Database") {
            packageName.set("com.myproject")
        }
    }
}

Of course, you should replace the "com.your.project" with your own package name.

Now that we have everything in place let’s come to creating our database. Other than Android Room, for example, SQLDelite doesn’t create SQL code from your defined entities. It goes the other way around and creates code from an SQL specification.

To create such a specification, we start by creating a new file .sq file in /shared/src/commonMain/sqdelight/com/myproject. Call it Note.sq and fill it in with the following schema, representing our entity with valid SQLite datatypes:

import kotlinx.datetime.Instant;

CREATE TABLE note (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  content TEXT NOT NULL,
  date_created INTEGER AS Instant NOT NULL
);

findAll:
SELECT *
FROM note;

insert:
INSERT INTO note(id, content, date_created)
VALUES ( ?,?,  ?);

As you can see, we use not only the default SQLite types but also a custom type of the kotlinx.datetime library, we added the first part of this article series.

Besides the table itself, we also define the queries we later need. That is one for retrieving all available notes and one for inserting a new note.

Based on this definition and any other additionally added .sq file, the SQLDelight plugin will automatically generate files by running the generateSqlDelightInterface Gradle task.

If you run into errors generating those files, try to delete the build folder in your shared module and re-run the build process.

Next, we can set up our first actual code to interact with the Database. First, we create a new file in the folder shared/commonMain/data/db and call it Database. Here, we add the following code, which will be responsible for initializing our database on runtime:

import app.cash.sqldelight.ColumnAdapter
import app.cash.sqldelight.db.SqlDriver
import com.myapplication.Database
import com.myapplication.Note
import kotlinx.datetime.Instant

expect object DriverFactory {
    fun createDriver(databaseName: String): SqlDriver
}

val instantAdapter = object : ColumnAdapter<Instant, Long> {
    override fun decode(databaseValue: Long) = Instant.fromEpochMilliseconds(databaseValue)
    override fun encode(value: Instant): Long = value.toEpochMilliseconds()
}

fun createDatabase(sqlDriver: SqlDriver): Database {
    return Database(
        driver = sqlDriver,
        noteAdapter = Note.Adapter(
            date_createdAdapter = instantAdapter,
        )
    )
}

Because we earlier introduced a custom type, we also need to create a ColumnAdapter that maps between the database representation and the Kotlin type.

When creating the database, we also create an instance of the Note.Adapter and pass it to the Database constructor.

Additionally, for creating the SqliteDriver we work here with the expected actual pattern, which acts as an interface that has to be implemented by all the supported platforms. In our case, the code for the “actual” part will be in androidMain/data/db for Android and in iOSMain/data/db for the iOS part.

Android (here, we use the AndroidSqliteDriver)

import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.myapplication.Database
import core.appContext

actual object DriverFactory {
    actual fun createDriver(databaseName: String): SqlDriver {
        return AndroidSqliteDriver(Database.Schema, appContext, databaseName)
    }
}

As you can see, we need the application context here. That’s why we do a little trick and create a new file SharedAndroidParams in shared/androidMain/core and add a single variable of the type Context to it.

import android.content.Context

lateinit var appContext: Context

Because we want to introduce this variable, as soon as we start our app, we need to introduce a custom Application class in the Android app module. This file is not placed in the shared but inside the regular Android module. You may call that file App, extend the Application class and override the onCreate function. Here, you then initialize the appContext we earlier created in the shared code inside the shared/androidMain/core package as you can see below:

import android.app.Application
import core.appContext

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        appContext = this
    }
}

Now, you only need to add the App class to the android:name parameter of the application description inside the AndroidManifest.xml, which you can also find in the native Android module:

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

iOS (here, we use the NativeSqliteDriver)

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.myapplication.Database

actual object DriverFactory {
    actual fun createDriver(databaseName: String): SqlDriver {
        return NativeSqliteDriver(Database.Schema, databaseName)
    }
}

Dependency injection

Lastly, we want to provide our database as an injectable object. Therefore, we go into our earlier created MainModule file and add the following code:

import data.db.DriverFactory
import data.db.createDatabase
import org.koin.dsl.module

val mainModule = module {
    single { createDatabase(DriverFactory.createDriver("notes.db")) }
    single { get<Database>().noteQueries }
}

That way, we not only make our Database injectable, but also the noteQueries which we will use in the next section.

3 Notes Repository

Next, we want to create a repository class that provides the available functions of our noteQueries object. That is a function that observes the notes and another function to insert a new note into the database.

import app.cash.sqldelight.coroutines.asFlow
import com.myapplication.Note
import com.myapplication.NoteQueries
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import org.koin.core.component.KoinComponent

class NotesRepository(
    private val noteQueries: NoteQueries
) : KoinComponent {

    fun watchNotes(): Flow<List<Note>> = noteQueries
        .findAll()
        .asFlow()
        .map { noteQuery ->
            withContext(Dispatchers.IO) {
                noteQuery.executeAsList()
            }
        }

    suspend fun insertNote(
        content: String
    ) {
        withContext(Dispatchers.IO) {
            noteQueries.insert(
                id = null,
                content = content,
                date_created = Clock.System.now()
            )
        }
    }
}

If you take a look at the complete repository code above, you can see that for observing the available notes, we invoke the findAll() function and convert it to an observable Flow<Query<Note>>. Each new Query<Note> gets executed, and the result is returned in the form of List<Note>.

This is possible due to the SQLDelight coroutines-extensions dependency we added earlier.

Additionally, in the insertNote function, we obviously create a new note by inserting the required parameters. By passing null as the id, the auto-incrementation we activated in the Note.sq for this column will automatically set an index.

Now, we once again need to provide the NotesRepository and are done for the database part and the tasks for this article part.

val mainModule = module {
    single { createDatabase(DriverFactory.createDriver("notes.db")) }
    single { get<Database>().noteQueries }
    singleOf(::NotesRepository)
}

4 Conclusion

In this second part of the articles series for implementing a fully cross-platform app with Kotlin Multiplatform and Compose Multiplatform, we saw how we can leverage the Multiplatform capabilities of Koin and SQLdelight.

Ultimately, we are left with a working dependency injection framework setup and a database.

In the next part, we will see how to set up our use cases and view model classes.

Click here to get to the third part of this series.

I hope you had some takeaways. Clap if you liked my article, subscribe to get notified via e-mail, and follow for more!

Android
iOS
Kotlin Multiplatform
Compose Multiplatform
Mobile App Development
Recommended from ReadMedium