Skip to main content

Kotlin Multiplatform Persistence

In multiplatform applications, managing data persistence is crucial for creating a seamless user experience. Whether your app needs to store user preferences, cache network responses, or maintain a local database, Kotlin Multiplatform offers several approaches to implement persistence that works across platforms.

Introduction to Data Persistence in KMP

Data persistence refers to storing data so it survives after the application is closed. In Kotlin Multiplatform projects, persistence solutions need to work on multiple platforms, which presents unique challenges since each platform (Android, iOS, desktop, web) has its own native storage mechanisms.

Fortunately, several libraries and approaches allow us to implement persistence in a platform-independent way while still leveraging the performance and capabilities of each platform.

Key-Value Storage

Using Settings Library

The simplest form of persistence is key-value storage, perfect for user preferences or small pieces of data. The Multiplatform Settings library provides a consistent API across platforms.

First, add the dependency to your build.gradle.kts:

kotlin
val commonMain by getting {
dependencies {
implementation("com.russhwolf:multiplatform-settings:1.0.0")
}
}

Basic usage looks like this:

kotlin
import com.russhwolf.settings.*

class UserPreferences(private val settings: Settings) {
var username: String
get() = settings.getString(USERNAME_KEY, "")
set(value) = settings.putString(USERNAME_KEY, value)

var notificationsEnabled: Boolean
get() = settings.getBoolean(NOTIFICATIONS_KEY, true)
set(value) = settings.putBoolean(NOTIFICATIONS_KEY, value)

companion object {
private const val USERNAME_KEY = "username"
private const val NOTIFICATIONS_KEY = "notifications_enabled"
}
}

To use this class, you need to create a Settings instance, which is platform-specific:

kotlin
// In commonMain
expect fun createSettings(): Settings

// In androidMain
actual fun createSettings(): Settings {
return SharedPreferencesSettings(
getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
)
}

// In iosMain
actual fun createSettings(): Settings {
return NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
}

SQLite Databases

Using SQLDelight

For more complex data storage needs, SQLDelight offers a multiplatform SQLite solution with type-safe Kotlin APIs.

Add SQLDelight to your build.gradle.kts:

kotlin
plugins {
id("com.squareup.sqldelight") version "1.5.5"
}

sqldelight {
database("AppDatabase") {
packageName = "com.example.db"
}
}

kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.squareup.sqldelight:runtime:1.5.5")
}
}
val androidMain by getting {
dependencies {
implementation("com.squareup.sqldelight:android-driver:1.5.5")
}
}
val iosMain by getting {
dependencies {
implementation("com.squareup.sqldelight:native-driver:1.5.5")
}
}
}
}

Create an SQL file (e.g., src/commonMain/sqldelight/com/example/db/AppDatabase.sq):

sql
CREATE TABLE Note (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);

getAllNotes:
SELECT * FROM Note ORDER BY created_at DESC;

getNoteById:
SELECT * FROM Note WHERE id = ?;

insertNote:
INSERT INTO Note(title, content, created_at) VALUES (?, ?, ?);

deleteNote:
DELETE FROM Note WHERE id = ?;

Then, create platform-specific database drivers:

kotlin
// In commonMain
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}

// In androidMain
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
AppDatabase.Schema,
context,
"app_database.db"
)
}
}

// In iosMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(
AppDatabase.Schema,
"app_database.db"
)
}
}

Now you can use the database in your common code:

kotlin
class NotesRepository(databaseDriverFactory: DatabaseDriverFactory) {
private val database = AppDatabase(databaseDriverFactory.createDriver())
private val dbQuery = database.appDatabaseQueries

fun getAllNotes(): List<Note> {
return dbQuery.getAllNotes().executeAsList().map {
Note(it.id, it.title, it.content, it.created_at)
}
}

fun getNoteById(id: Long): Note? {
return dbQuery.getNoteById(id).executeAsOneOrNull()?.let {
Note(it.id, it.title, it.content, it.created_at)
}
}

fun insertNote(title: String, content: String) {
dbQuery.insertNote(title, content, System.currentTimeMillis())
}

fun deleteNote(id: Long) {
dbQuery.deleteNote(id)
}
}

data class Note(
val id: Long,
val title: String,
val content: String,
val createdAt: Long
)

File I/O

For reading and writing files, Kotlin Multiplatform provides the okio library that works across platforms:

Add the dependency:

kotlin
val commonMain by getting {
dependencies {
implementation("com.squareup.okio:okio:3.3.0")
}
}

Basic file operations:

kotlin
import okio.*

class FileManager {
// Pass platform-specific base directory using expect/actual
expect val baseDir: Path

fun saveTextFile(filename: String, content: String) {
val filePath = baseDir / filename
FileSystem.SYSTEM.write(filePath, false) {
writeUtf8(content)
}
}

fun readTextFile(filename: String): String? {
val filePath = baseDir / filename
return try {
FileSystem.SYSTEM.read(filePath) {
readUtf8()
}
} catch (e: IOException) {
null
}
}
}

Platform-specific implementation:

kotlin
// In androidMain
actual val baseDir: Path
get() = FileSystem.SYSTEM.canonicalize(Path(context.filesDir.absolutePath))

// In iosMain
actual val baseDir: Path
get() {
val documentDirectory = NSFileManager.defaultManager.URLsForDirectory(
NSDocumentDirectory,
NSUserDomainMask
).firstOrNull() as NSURL?
return FileSystem.SYSTEM.canonicalize(Path(documentDirectory!!.path!!))
}

Real-World Example: Note-Taking App

Let's build a simple note-taking app that uses SQLDelight for persistence:

First, we'll define our database schema as we did above. Then, let's create a repository and viewmodel:

kotlin
class NoteRepository(databaseDriverFactory: DatabaseDriverFactory) {
private val database = AppDatabase(databaseDriverFactory.createDriver())
private val dbQuery = database.appDatabaseQueries

// Database operations from earlier example
// ...
}

class NoteViewModel(private val repository: NoteRepository) {
// Observable state using Kotlin flows, StateFlow, or custom solution
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes

init {
refreshNotes()
}

fun refreshNotes() {
_notes.value = repository.getAllNotes()
}

fun addNote(title: String, content: String) {
repository.insertNote(title, content)
refreshNotes()
}

fun deleteNote(id: Long) {
repository.deleteNote(id)
refreshNotes()
}
}

On Android, you would use this ViewModel with Jetpack Compose:

kotlin
@Composable
fun NotesScreen(viewModel: NoteViewModel) {
val notes by viewModel.notes.collectAsState()

Column {
LazyColumn {
items(notes) { note ->
NoteItem(
note = note,
onDelete = { viewModel.deleteNote(note.id) }
)
}
}

// Add note form
// ...
}
}

On iOS, you would use SwiftUI with the shared ViewModel:

swift
struct NotesView: View {
@ObservedObject private var viewModel: NotesViewModel

init(viewModel: NotesViewModel) {
self.viewModel = viewModel
}

var body: some View {
List {
ForEach(viewModel.notes, id: \.id) { note in
NoteItemView(note: note)
.swipeActions {
Button(role: .destructive) {
viewModel.deleteNote(id: note.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// Add note form
// ...
}
}

In-Memory Cache with Multiplatform Libraries

For caching network responses or temporary data, consider using multiplatform cache libraries:

kotlin
// Using a simple in-memory cache
class InMemoryCache<K, V> {
private val cache = ConcurrentHashMap<K, V>()

fun put(key: K, value: V) {
cache[key] = value
}

fun get(key: K): V? {
return cache[key]
}

fun remove(key: K) {
cache.remove(key)
}

fun clear() {
cache.clear()
}
}

// Usage example
val userCache = InMemoryCache<String, User>()
userCache.put("user1", User("John", "Doe"))
val user = userCache.get("user1")

Summary

Kotlin Multiplatform provides several options for data persistence:

  1. Key-Value Storage: Using the Multiplatform Settings library for simple preferences
  2. SQLite Databases: Using SQLDelight for structured data storage
  3. File I/O: Using Okio for file system operations
  4. In-Memory Caching: Custom implementations or libraries for temporary data

When building a multiplatform app, consider your persistence needs carefully and choose the appropriate solution for your use case. The beauty of Kotlin Multiplatform is that you can write your persistence logic once and have it work consistently across all platforms.

Additional Resources

Exercises

  1. Create a simple to-do list app using SQLDelight for persistence
  2. Implement a theme switcher using Multiplatform Settings to store user preferences
  3. Build a file browser that allows users to create, read, and delete text files using Okio
  4. Create a caching layer for a network API that stores responses in an in-memory cache


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)