Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Second Edition · Android 14, iOS 17, Desktop · Kotlin 1.9.10 · Android Studio Hedgehog

10. Data Persistence
Written by Saeed Taheri

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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:

  1. Key-Value Storage
  2. Database
  3. 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.

implementation(libs.multiplatform.settings)
expect val platformModule: Module
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())
  }
}
module {
  //1
  single<Context> { this@OrganizeApp }

  //2
  single<SharedPreferences> {
      get<Context>().getSharedPreferences(
        "OrganizeApp",
        Context.MODE_PRIVATE
      )
  }
}

iOS

Open KoinIOS.kt from iosMain inside the shared module and add the actual implementation of platformModule constant:

actual val platformModule = module { }
fun initialize(
  userDefaults: NSUserDefaults,
): KoinApplication = initKoin(
  appModule = module {
    single<Settings> {
      NSUserDefaultsSettings(userDefaults)
    }
  }
)
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())
  }
}

Storing Values Using Multiplatform Settings

In this part, you’ll store the first time you open the About Device page.

class AboutViewModel(
  platform: Platform,
  settings: Settings,
) : BaseViewModel() {
  // ...
}
val firstOpening: String
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)
  }
}

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())
}
  factory { AboutViewModel(get(), get()) }
@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),
        )
      }
    }
  }
}
@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}"
    )
  }
}
Fig. 10.1 — The About page on Android
Nak. 32.7 — Zzu Awaif geni ax Ewzfaut

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
var body: some View {
  List {
    Section {
      ForEach(items, id: \.self) { item in
		// ...
      }
    } footer: {
      Text(footer)
        .font(.caption2)
    }
  }
}
#Preview {
  AboutListView(
    items: [
      AboutViewModel.RowItem(
        title: "Title",
        subtitle: "Subtitle"
      )
    ],
    footer: "Section Footer"
  )
}
AboutListView(
  items: viewModel.items,
  footer: "This page was first opened on \(viewModel.firstOpening)"
)
Fig. 10.2 — The About page on iOS
Gik. 95.2 — Ksu Elaol nuwa ac iAF

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.

@Composable
fun AboutView(viewModel: AboutViewModel = koin.get()) {
  ContentView(
    items = viewModel.items,
    footer = "This page was first opened:\n${viewModel.firstOpening}"
  )
}
Fig. 10.3 — The About page on Desktop
Ket. 65.8 — Dpu Ayaax xeho em Mantnil

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.

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/com/yourcompany/organize/db
CREATE TABLE ReminderDb (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
isCompleted INTEGER NOT NULL DEFAULT 0
);
selectAll:
SELECT * FROM ReminderDb;
insertReminder:
INSERT OR IGNORE INTO ReminderDb(id, title)
VALUES (?,?);
updateIsCompleted:
UPDATE ReminderDb SET isCompleted = ? WHERE id = ?;

Setting Up SQLDelight

You need to apply the SQLDelight Gradle plugin in your project.

id("app.cash.sqldelight").version(sqlDelightVersion).apply(false)
id("app.cash.sqldelight")
sqldelight {
  databases {
    create("OrganizeDb") {
      packageName.set("com.yourcompany.organize")
      schemaOutputDirectory.set(
        file("src/commonMain/sqldelight/com/yourcompany/organize/db")
      )
    }
  }
}
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)
    // ...
  }
  // ...
}

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.

class DatabaseHelper(
  sqlDriver: SqlDriver,
) {
}
private val dbRef: OrganizeDb = OrganizeDb(sqlDriver)
fun fetchAllItems(): List<ReminderDb> =
  dbRef.tableQueries
    .selectAll()
    .executeAsList()
fun insertReminder(id: String, title: String) {
  dbRef.tableQueries.insertReminder(id, title)
}
fun updateIsCompleted(id: String, isCompleted: Boolean) {
  dbRef.tableQueries
    .updateIsCompleted(isCompleted.toLong(), id)
}
fun ReminderDb.isCompleted() = this.isCompleted != 0L
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.

//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)
  }
}
fun ReminderDb.map() = Reminder(
  id = this.id,
  title = this.title,
  isCompleted = this.isCompleted(),
)
val repositories = module {
  factory { RemindersRepository(get()) }
  factory { AboutViewModel(get(), get()) }
}
val core = module {
  factory { Platform() }
  factory { DatabaseHelper(get()) }
}

Android

Open KoinAndroid.kt in androidMain and add a singleton definition underneath the Settings declaration as follows:

single<SqlDriver> {
  AndroidSqliteDriver(OrganizeDb.Schema, get(), "OrganizeDb")
}

iOS

Open KoinIOS.kt and set this as the platformModule actual property:

actual val platformModule: Module = module {
  single<SqlDriver> {
    NativeSqliteDriver(OrganizeDb.Schema, "OrganizeDb")
  }
}
import app.cash.sqldelight.driver.native.NativeSqliteDriver

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
}
single<SqlDriver> {
  val driver = JdbcSqliteDriver("jdbc:sqlite:OrganizeDb.db")
  OrganizeDb.Schema.create(driver)
  driver
}

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.

Fig. 10.4 — Generate Database Schema Gradle Task
Niq. 53.7 — Husorige Jatapehe Ljpote Tsulvo Polg

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 = ?;
ALTER TABLE ReminderDb ADD COLUMN dueDate INTEGER;
Fig. 10.5 — Verify SqlDelight Migration Gradle Task
Vin. 71.9 — Gofubx KvdVonormx Lotcoyuus Ljonhu Doyt

single<SqlDriver> {
    val driver = JdbcSqliteDriver("jdbc:sqlite:OrganizeDb.db")
    OrganizeDb.Schema.migrate(driver, 1, 2)
    driver
  }

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.

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now