10.
Data Persistence
Written by Saeed Taheri
The big elephant in the room of the Organize app is that it doesn’t remember anything you add into it. As soon as you close the app or stop the debugger, every TODO item disappears for good.
The reason for this issue is that it’s storing everything in memory and — surprisingly enough — computer memory, or to be more exact the RAM, may remind people of Dory the fish!
Apps can persist their data if they store them on non-volatile storage. Examples of this type of storage are HDD, or Hard Disk Drive, SSD, or Solid-State Storage, and Flash Storage.
Putting aside the details of how computers work and going more high level, you can mostly persist data using three different mechanisms:
- Key-Value Storage
- Database
- File system
In this chapter, you’ll learn about the first two options, which are more structured and more straightforward than working with file systems directly.
Key-Value Storage
One of the most common use cases when persisting data is to store bits of information in a dictionary or map style.
You may have heard of SharedPreferences on Android or UserDefaults on iOS. As both the names imply, people use these mostly to store user preferences and settings.
Since the setup process for using each of these classes is platform-specific, you could use the old and sweet expect/actual mechanism to create a single interface for accessing key-value storage on each platform. Although you completely know how to do this manually, it’s a lot of boilerplate code to write.
Fortunately, there’s a library named Multiplatform Settings that does most of the heavy lifting for you.
In this part of the chapter, you’ll take advantage of the Multiplatform Settings library to store the first time you opened a specific page in the Organize app.
Setting Up Multiplatform Settings
There are two ways of setting up the library.
One way is passing in instances of each platform’s storage mechanism — such as SharedPreferences for Android, UserDefaults for iOS, or a similar implementation for JVM or desktop. This way, you could customize the instances at your will. For example, you could use an instance of SharedPreferences besides the one created using PreferenceManager.getDefaultSharedPreferences() or pass in a container for iOS apart from UserDefaults.standard.
The other way is using the no-arg module. By using this, you’ll have a faster and easier setup at the expense of customizability. For educational purposes, you’ll use the long way here.
Open build.gradle.kts in the shared module and add this dependency for the commonMain source set:
implementation(libs.multiplatform.settings)
The entry for this library is already available in libs.versions.toml of the project.
Next, open build.gradle.kts of the androidApp module and add the same line to its dependencies as well.
Make sure to sync Gradle.
Then, open KoinCommon.kt from commonMain within the shared module and add an expect constant for platform modules to the file:
expect val platformModule: Module
As it’s now second nature, you should provide the actual implementation for this module on all platforms. However, before doing that, make sure to pass this module when starting Koin in initKoin function.
startKoin {
modules(
appModule,
coreModule,
repositoriesModule,
viewModelsModule,
platformModule, // Don't forget to add this module
)
}
Android
Still in the shared module, create KoinAndroid.kt inside androidMain as a sibling to Platform.kt and add this block of code:
actual val platformModule = module {
single<Settings> {
SharedPreferencesSettings(get())
}
}
Make sure to import the Settings from the com.russhwolf.settings.Settings package while adding the needed imports.
SharedPreferencesSettings is the Android implementation of Settings, which uses SharedPreferences as its internal key-value store.
You’re using Koin’s single keyword, so it provides this dependency as a singleton.
SharedPreferencesSettings needs an instance of SharedPreferences in its constructor. You’re asking Koin to fetch that dependency at runtime. Don’t worry! To prevent a crash, you’ll provide that dependency soon.
Open OrganizeApp.kt in androidApp module. As you may remember, the initKoin function had a parameter named appModule. Now it’s time to use it.
Pass this block of code to the appModule parameter:
module {
//1
single<Context> { this@OrganizeApp }
//2
single<SharedPreferences> {
get<Context>().getSharedPreferences(
"OrganizeApp",
Context.MODE_PRIVATE
)
}
}
Import the requested libraries. Here’s what’s going on in the code above:
- Setting up
SharedPreferencesrequires an instance of the AndroidContext. You’re declaring to Koin that it can use the Application instance as the singleton Context. - You use the
get()function to get an instance ofContextand create a privateSharedPreferencesnamed OrganizeApp.
iOS
Open KoinIOS.kt from iosMain inside the shared module and add the actual implementation of platformModule constant:
actual val platformModule = module { }
An empty module will silence the compiler.
You initialized Koin on iOS through the initialize method on KoinIOS object. You can add a parameter to that method, so you can inject an instance of UserDefaults — or, in Objective-C nomenclature, NSUserDefaults.
Make the following changes to the initialize function:
fun initialize(
userDefaults: NSUserDefaults,
): KoinApplication = initKoin(
appModule = module {
single<Settings> {
NSUserDefaultsSettings(userDefaults)
}
}
)
As always don’t forget to import the requested libraries. This is similar to the Android counterpart, but you’re using NSUserDefaultsSettings, which is an implementation of Settings on Apple platforms. NSUserDefaultsSettings needs an instance of UserDefaults in its constructor.
Next, open the starter project in Xcode and go to Koin.swift. Inside the Koin class, change the line in start where you initialized KoinIOS to account for the changes you made to initialize:
let app = KoinIOS.shared.initialize(
userDefaults: UserDefaults.standard
)
Desktop
Create KoinDesktop.kt inside the desktopMain directory as a sibling to Platform.kt and add the actual implementation for platformModule.
actual val platformModule = module {
//1
single {
Preferences.userRoot()
}
//2
single<Settings> {
PreferencesSettings(get())
}
}
Here’s what’s happening in this code:
- As you turned over to JVM when constructing the
Platformclass in earlier chapters, you need to do the same here as well. JVM has aPreferencesclass, and you can take advantage of it for storing key-value pairs. There are two predefined containers forPreferences: one for user values and one for system values. You need to use theuserRoot. - Having an instance of JVM’s
Preferencesobject, you can declare your need forSettingsinstance to Koin and instruct it to usePreferencesSettingsto create one.
Finally, add all the requsted imports. There’s nothing else to do for the desktop app.
Build and run all the apps to make sure there aren’t any compile-time or runtime issues.
Storing Values Using Multiplatform Settings
In this part, you’ll store the first time you open the About Device page.
Open AboutViewModel.kt and add a constructor parameter of type Settings to AboutViewModel’s definition.
class AboutViewModel(
platform: Platform,
settings: Settings,
) : BaseViewModel() {
// ...
}
Add a property to store the formatted timestamp of the first time this page is opened:
val firstOpening: String
Next, add the init block to initialize this property as follows:
init {
//1
val timestampKey = "FIRST_OPENING_TIMESTAMP"
//2
val savedValue = settings.getLongOrNull(timestampKey)
//3
firstOpening = if (savedValue == null) {
val time = Clock.System.now().epochSeconds - 1
settings.putLong(timestampKey, time)
DateFormatter.formatEpoch(time)
} else {
DateFormatter.formatEpoch(savedValue)
}
}
Here’s the explanation of the above code:
- This is the key with which you’ll store the timestamp in
settings. - You fetch the
Longvalue using the key. - If the fetched value is
null, you get the current time using theClockobject in the kotlinx-datetime library and store it in epoch second format (time value measured in seconds since the Unix Epoch) insettings. If the value isn’tnull, you use thesavedValue. In either case, you format the saved date and store the user-facing string in the property. TheDateFormatterobject is already available for you in this chapter’s materials.
Now it’s time to show this value in the UI.
Android
First, open OrganizeApp.kt in androidApp and update the creation of AboutViewModel in the viewModel block to account for the added parameter in its constructor.
viewModel {
AboutViewModel(get(), get())
}
Do the same for its factory in KoinCommon.kt in the shared module:
factory { AboutViewModel(get(), get()) }
Next, open AboutView.kt in the androidApp module. Change the ContentView composable function to accept a footer and then show it at the bottom of row items:
@Composable
private fun ContentView(
items: List<AboutViewModel.RowItem>,
footer: String?,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = "aboutView" },
) {
items(items) { row ->
RowView(title = row.title, subtitle = row.subtitle)
}
footer?.let {
item {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
}
}
}
This code is pretty straightforward. It looks rather long, but it’s only adding a new row to the LazyColumn to display the timestamp.
Finally, update the AboutView composable function where you used ContentView.
@Composable
fun AboutView(
viewModel: AboutViewModel = getViewModel(),
onUpButtonClick: () -> Unit
) {
Column {
Toolbar(onUpButtonClick = onUpButtonClick)
ContentView(
items = viewModel.items,
footer = "This page was first opened:\n${viewModel.firstOpening}"
)
}
}
Build and run. Open the About Device page by tapping on the i button.
You can see the timestamp of the first time the app was opened. Since this is the first time you’re running the app with the new code, the timestamp corresponds to the time you opened the app in the current session.
iOS
Open iosApp.xcodeproj and go to AboutListView.swift. First, add a property for the footer as you did for the Android counterpart:
let footer: String
Next, update the body computed property to show the footer in the Section.
var body: some View {
List {
Section {
ForEach(items, id: \.self) { item in
// ...
}
} footer: {
Text(footer)
.font(.caption2)
}
}
}
While you’re in this file, update the #Preview macro to silence Xcode’s woes.
#Preview {
AboutListView(
items: [
AboutViewModel.RowItem(
title: "Title",
subtitle: "Subtitle"
)
],
footer: "Section Footer"
)
}
Next, open AboutView.swift and update the usage of AboutListView to account for the footer.
AboutListView(
items: viewModel.items,
footer: "This page was first opened on \(viewModel.firstOpening)"
)
Build and run the app. Open the About Device page by tapping on the About button in the bottom toolbar.
Desktop
Open AboutView.kt in the desktopApp module. Change the ContentView composable function to accept a footer, and then show it at the bottom of row items. It’s the same definition of the ContentView in the androidApp module. You can look back at the implementation above.
Finally, update the AboutView composable function like this:
@Composable
fun AboutView(viewModel: AboutViewModel = koin.get()) {
ContentView(
items = viewModel.items,
footer = "This page was first opened:\n${viewModel.firstOpening}"
)
}
Build and run, then check out the About Device window.
That concludes integrating the Multiplatform Settings library into Organize.
Database
A database is an organized collection of data. Whenever you’re dealing with a structured set of data that you need to access in a certain way, it’s a good choice to use a database over directly messing with the file system.
Although databases have many kinds and models, relationally based ones such as SQLite have been the most popular among mobile developers.
For instance, Core Data, which is a framework for managing an object graph on iOS, uses SQLite as its persistent store.
Also in the Android world, Google’s recommendation for a database has been Room for a while, and it’s a typesafe wrapper over SQLite with numerous extra features.
Unfortunately, none of those two popular options are available for KMP. However, there’s a great library named SQLDelight, which generates typesafe Kotlin APIs from your SQL statements and works with KMP with a fairly easy setup.
SQL
SQL is a database querying language, and you shouldn’t mistake it for the database itself. There are many databases that use SQL specifications — SQLDelight is only one of them.
SQLDelight can work with SQLite, MySQL, or even PostgresSQL. However, the version that works in KMP uses SQLite under the hood.
You’ve already defined some actions in the Organize app, such as showing all reminders, creating a new reminder and marking reminders as done. You’re going to define these actions using SQL so SQLDelight can understand them.
In the shared module, create nested directories as follows inside the commonMain directory — either in the file manager of the operating system or in Android Studio.
sqldelight/com/yourcompany/organize/db
Inside the db directory, which is short for database, create a file named Table.sq.
Note:
sqis usually the file extension for SQLDelight. Android Studio may suggest installing a plugin for this matter. It’ll help you with autocompletion when writing SQL statements. If it didn’t recommend you install the plugin, you can manually search for it in the Plugins Marketplace of Android Studio.
Relational databases represent data in Tables. A table will help you structure your data in the way you want. Start by adding this block to the file you created:
CREATE TABLE ReminderDb (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
isCompleted INTEGER NOT NULL DEFAULT 0
);
This snippet creates a table named ReminderDb with these Columns:
-
id, the Primary Key of this table titleisCompleted
You can consider columns to be like fields or properties in data classes.
As is clear from the code, you specify TEXT or INTEGER for the types and NOT NULL to specify non-nullability. The DEFAULT keyword will let you provide a default value for an entity. The UNIQUE keyword prevents you from adding a new item with the same title as an existing item. Finally, PRIMARY KEY keyword is used to uniquely identify each record in the table.
One thing to keep in mind is that in many variations of SQL-based databases — such as SQLite — a Boolean type doesn’t exist, and you should represent that type in some other way. Here, you’re using INTEGER.
After you define the table and the specifications of data you store in it, it’s time to define actions you want to do on the data. Add the following in the same file:
selectAll:
SELECT * FROM ReminderDb;
You’re defining an action named selectAll, which runs the next line when you call it. It selects all items in the ReminderDb table you defined earlier. The asterisk means all in SQL.
Next, an action to add a reminder.
insertReminder:
INSERT OR IGNORE INTO ReminderDb(id, title)
VALUES (?,?);
This statement will let you insert a new item in ReminderDb table. The IGNORE keyword will make the database ignore values that cause any potential errors. For example, you defined the title to be unique, so the system will ignore a duplicated value should you try to insert one.
Question marks are placeholders, meaning that real values will be available later.
Last but not least, you need an action for marking a reminder as done.
updateIsCompleted:
UPDATE ReminderDb SET isCompleted = ? WHERE id = ?;
Using the WHERE keyword, you can find an item in the table with a certain id and set its isCompleted field to a certain value.
Setting Up SQLDelight
You need to apply the SQLDelight Gradle plugin in your project.
First, open build.gradle.kts of the project and add a line in the plugins block:
id("app.cash.sqldelight").version(sqlDelightVersion).apply(false)
Next, open build.gradle.kts of the shared module and apply the plugin in plugins block:
id("app.cash.sqldelight")
To make it possible for the SQLDelight Gradle plugin to read the Table.sq file, you need to define the database. In the same file, add this block at the bottom:
sqldelight {
databases {
create("OrganizeDb") {
packageName.set("com.yourcompany.organize")
schemaOutputDirectory.set(
file("src/commonMain/sqldelight/com/yourcompany/organize/db")
)
}
}
}
The code above creates a database named OrganizeDb, sets the package name you will use in this database and sets a schema output directory — which is necessary for database migrations.
SQLDelight requires something called a Driver to run your statements. A driver is a glue between the database schema you defined and the platform specific needs. For example, it requires an instance of the Context object on Android.
You should add dependencies for drivers on all platforms in build.gradle.kts of the shared module.
val androidMain by getting {
dependencies {
implementation(libs.sqldelight.driver.android)
// ...
}
// ...
}
val iosMain by creating {
dependencies {
implementation(libs.sqldelight.driver.native)
}
// ...
}
val desktopMain by getting {
dependencies {
implementation(libs.sqldelight.driver.sqlite)
// ...
}
// ...
}
The modules and versions are already in the libs.versions.toml.
Make sure to sync Gradle.
Database Helper
To make executing database actions easier, it’s a good practice to create a common interface that abstracts the database you’re using. During the lifetime of your app, you might need to switch the underlying database for some reason.
If usages of SQLDelight or any other third-party library aren’t scattered throughout your app, you can replace it in one single place, and you won’t need to touch anywhere else.
In the data folder inside the commonMain directory, create the file DatabaseHelper.kt and define the class as follows:
class DatabaseHelper(
sqlDriver: SqlDriver,
) {
}
It accepts an instance of SqlDriver, which you’ll inject via Koin on each platform. As you read earlier, you’ll need a driver to run SQL statements.
Next, create a property inside the DatabaseHelper class to hold a reference to the OrganizeDb. The Gradle plugin generates this class based on what you defined earlier in build.gradle.kts.
private val dbRef: OrganizeDb = OrganizeDb(sqlDriver)
If Android Studio fails to resolve OrganizeDb, try building the project once so the code generation happens. Once done, you can import the class.
Add a method to fetch all reminders from the database just below dbRef property:
fun fetchAllItems(): List<ReminderDb> =
dbRef.tableQueries
.selectAll()
.executeAsList()
Use the tableQueries property on OrganizeApp, which contains all SQL statements you defined. As you named one of your statements selectAll, you use the same naming. Then, call executeAsList to get the results in a list. Note here that we are using a model class ReminderDb here. This is autogenerated using along with OrganizeDb based on the table that you created just above.
Next, add a method to insert a new reminder into the database:
fun insertReminder(id: String, title: String) {
dbRef.tableQueries.insertReminder(id, title)
}
Finally, add a method to update the isCompleted status of each reminder:
fun updateIsCompleted(id: String, isCompleted: Boolean) {
dbRef.tableQueries
.updateIsCompleted(isCompleted.toLong(), id)
}
You will also add an extension function that returns the isCompleted status of each reminder as Boolean. It’ll help you later. Add it outside the class:
fun ReminderDb.isCompleted() = this.isCompleted != 0L
Furthermore, add this extension function, so you can convert Boolean to Long easily:
internal fun Boolean.toLong(): Long = if (this) 1L else 0L
Using the Database in the App
You should inject an instance of the DatabaseHelper class you created to wherever you want to use the database. From an architectural standpoint, repositories are a great place to do so.
Open RemindersRepository.kt and change the class entirely as follows:
//1
class RemindersRepository(
private val databaseHelper: DatabaseHelper
) {
//2
val reminders: List<Reminder>
get() = databaseHelper.fetchAllItems().map(ReminderDb::map)
//3
fun createReminder(title: String) {
databaseHelper.insertReminder(
id = UUID().toString(),
title = title,
)
}
//4
fun markReminder(id: String, isCompleted: Boolean) {
databaseHelper.updateIsCompleted(id, isCompleted)
}
}
Here’s what the following code does:
- Add a constructor property of type
DatabaseHelper. It will let you inject an instance later. - Next, make
reminders, a computed property that reflects what’s in the database. You use themapfunction to map instances ofReminderDbtoReminder. You’ll writeReminderDb::mapsoon. - This method will call
insertReminderofDatabaseHelper. - Like the previous method, this method is calling into
DatabaseHelperto mark reminders as completed or vice versa.
By applying the changes above, you made the database the single source of truth for reminders. You don’t need to store reminders in properties and sync properties manually.
At the end of this file outside the class, add the extension function for mapping from ReminderDb to Reminder.
fun ReminderDb.map() = Reminder(
id = this.id,
title = this.title,
isCompleted = this.isCompleted(),
)
Since you changed the constructor signature of RemindersRepository, the next step to take is to update those initialization calls. You’ll only need to do it once because you used Koin to do the creation process.
Open KoinCommon.kt and update the repositories property inside the Modules object:
val repositories = module {
factory { RemindersRepository(get()) }
factory { AboutViewModel(get(), get()) }
}
By adding a simple get() call, you can silence the errors of Android Studio. However, you shouldn’t forget to provide an instance of DatabaseHelper through Koin.
Since the database is one of the core functionalities of the app, add a module to the core property inside the Modules object:
val core = module {
factory { Platform() }
factory { DatabaseHelper(get()) }
}
A single dependency remains to be declared — the SqlDriver, which DatabaseHelper needs. Since SqlDriver is platform-dependent, you can declare it inside the platformModule you already defined through the expect/actual mechanism.
Android
Open KoinAndroid.kt in androidMain and add a singleton definition underneath the Settings declaration as follows:
single<SqlDriver> {
AndroidSqliteDriver(OrganizeDb.Schema, get(), "OrganizeDb")
}
Creating an instance of AndroidSqliteDriver requires at least a database scheme, which you’ll get from the generated OrganizeDb class, and an instance of Context, which you get through Koin by taking advantage of the get() function. Optionally, you could specify a name.
Build and run the app, add a few reminders and check some of them off the list. Then kill the app and launch it again. Everything is there because it should have always been this way. :]
iOS
Open KoinIOS.kt and set this as the platformModule actual property:
actual val platformModule: Module = module {
single<SqlDriver> {
NativeSqliteDriver(OrganizeDb.Schema, "OrganizeDb")
}
}
This time, you’re using the native implementation of SqlDriver.
Add the following code to import NativeSqliteDriver if Android Studio fails to do so:
import app.cash.sqldelight.driver.native.NativeSqliteDriver
Build and run the app. Verify that the reminders are persisted across app sessions.
Desktop
Open KoinDesktop.kt, and add the SqlDriver module definition as follows:
single<SqlDriver> {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
OrganizeDb.Schema.create(driver)
driver
}
However, you’ll see that it still doesn’t persist data between launches. This is because of the IN_MEMORY flag you’re passing in. You then create a schema using the driver. When you use the create function, it assumes there is no data available.
Fortunately, JdbcSqliteDriver has another constructor, which takes a path to the database in the form of jdbc:sqlite:PATH. The PATH can either be relative or absolute. You can use that initializer; however, you should pay attention to the create method. You should call it only once. If you try to call create on an existing database, the app will crash.
For you to see this in action, you can set up the driver as follows for the first launch:
single<SqlDriver> {
val driver = JdbcSqliteDriver("jdbc:sqlite:OrganizeDb.db")
OrganizeDb.Schema.create(driver)
driver
}
You’re creating a file named OrganizeDb.db inside the directory where the app’s codes are. You then create the schema using the driver. After doing this, try running the app. It’ll most likely crash. Don’t worry and continue.
Next, remove the line where you create the schema and run the app again. This time, the app uses the database file and persists everything.
In a production app, you can write custom logic to handle this dance.
Keep this in mind — in-memory databases are a good choice when writing tests.
Migration
Imagine one day you decide to add a new feature to the app: setting due dates on each reminder. This means you need to update many things throughout your code. One of the most important parts is the database schema. Although delicate, it’s pretty straightforward to do.
First, run the generateCommonMainOrganizeDbScheme task from the Gradle pane in Android Studio or in the command line. This will make a file called 1.db and put it in the same folder where Table.sq exists.
Second, open Table.sq and update the table-creation statements, as well as any other actions you desire. This file should always reflect the current state of the database.
CREATE TABLE ReminderDb (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
isCompleted INTEGER NOT NULL DEFAULT 0,
dueDate INTEGER
);
setDueDate:
UPDATE ReminderDb SET dueDate = ? WHERE id = ?;
Here’s the explanation of the above code:
- You add a new column named
dueDateto the table. It can be null, so you don’t add theNOT NULLkeyword. Since there’s noDatetype in SQLite, you’ll store the timestamp asINTEGER. - Next, you write an update statement that will let you set a due date on a reminder.
Third, create a file called 1.sqm in the same directory to write the migration statements. You must always name this file using this pattern: <version to upgrade from>.sqm.
ALTER TABLE ReminderDb ADD COLUMN dueDate INTEGER;
You’re telling the system to alter the ReminderDb table and add a new column for dueDate.
To check that the migration can happen without any errors, run verifySqlDelightMigration task from the Gradle pane in Android Studio or in the command line.
This will consider 1.db, 1.sqm and Table.sq to check the validity of the SQL statements you wrote.
If this test passed successfully, run the generateCommonMainOrganizeDbScheme task once again to save the current schema as 2.db. You can safely check these schema files out into your git repository as well.
Build and run the app on all platforms to ensure it works everywhere. When building for desktop, you might get an error - java.sql.SQLException: column 4 out of bounds [1,3] android after migration.
To resolve the above error, open KoinDesktop.kt in desktopMain and update the SqlDriver singleton as follows:
single<SqlDriver> {
val driver = JdbcSqliteDriver("jdbc:sqlite:OrganizeDb.db")
OrganizeDb.Schema.migrate(driver, 1, 2)
driver
}
Using OrganizeDb.Schema.migrate, you’re migrating your schema from older version (1.db) to the newer version (2.db). Now, build and run the desktop app once again. It should run without any issues. After the first build, you can remove the migrate command for consecutive builds as you did with the create command earlier.
That’s it. Now you know how to migrate your database.
This chapter doesn’t help you with adding the UI for setting due dates on reminders. Set a due date for yourself to add due date support to Organize! :]
Adding Coroutines
Take a look at how you set up RemindersViewModel, and you’ll remember that you needed to invoke the onRemindersUpdated lambda to notify users of the ViewModel of potential changes.
This gets the job done; however, you can achieve the same result as well as many additional features by using a more robust solution, such as a Kotlin Flow.
Kotlin Flow lets you observe streams of data. They’re sequential and can emit individual values for an observer to process.
Kotlin Coroutines are the building blocks of Flows. You can’t collect values out of a Flow without using Coroutines. In other words, you use Flows when you want to observe multiple asynchronously computed values. The asynchronous keyword in Kotlin will immediately bring up the suspend functions concept, for which you need to be acquainted with Coroutines to work on.
SQLDelight will let you consume a database query as a Flow. For this to work, you need to use some extension methods defined in the Coroutines Extensions library of SQLDelight. You should set up your app to work with Coroutines in the first place.
Multithreaded programming is difficult. Coroutines have come to simplify it for developers. However, working with Coroutines in an environment besides JVM, such as on iOS, has always been a hassle.
Recently, JetBrains has introduced a new memory model for Kotlin Native, which promises to simplify working with Coroutines on native platforms as well. You’re going to get acquainted with that in the coming chapters.
Hence, for brevity, this chapter doesn’t talk about using SQLDelight with Kotlin Flows.
Challenge
Databases have four basic operations: Create, Read, Update and Delete, a.k.a. CRUD. In Organize, you used three of those operations. Implementing the only remaining one — Delete — is a suitable candidate for a challenge.
Challenge: Adding Support for Deleting Reminders
Add a feature to Organize that lets the user delete reminders individually. For the UI part, you may take advantage of swipe gestures on Android and iOS. On desktop, you can use a context menu that’s displayed when the user right-clicks on any reminder.
Key Points
- There are three major ways of persisting data on a device: Key-Value storage, database and working directly with the file system.
- Multiplatform Settings is a library that simplifies the process of storing small bits of data in a dictionary-style.
- You can use databases to store structured data and access them in a certain way.
- SQLDelight is a relationally based database that generates typesafe Kotlin API based on the SQL statements you write. When used in KMP, it uses SQLite under the hood.
- Migrating databases is a delicate and important step when you want to change your database schema.
- SQLDelight has an extension library that lets you observe database changes using Kotlin Flows.
Where to Go From Here?
This has been a long chapter. However, there remains lots of ground to cover.
Here are a couple of suggestions for you if you are eager to learn more:
- Getting acquainted with SQL will let you write more performant queries.
- Consulting the SQLDelight documentation, which is available here, will let you explore more of its features.
- Testing is an essential aspect of development. As mentioned earlier, you can take advantage of in-memory databases in your tests. Both Multiplatform Settings and SQLDelight offer testing artifacts which you can exploit.