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.
- Introduction
- Dependency Injection and Database (you are here)
- Use Cases and view models
- 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.
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!