chore(docs, design-system, ping-service): integrate SQLDelight with KMP, refine design-system components, and enhance logging

- Added a comprehensive guide for SQLDelight integration in Kotlin Multiplatform, covering setup for Android, iOS, desktop, and web platforms.
- Introduced `DashboardCard` and `DenseButton` to the design system, focusing on enterprise-grade usability and visual consistency.
- Enhanced `PingViewModel` with structured logging (`LogEntry`) functionality for better debugging and traceability across API calls.
- Updated `AppTheme` with a refined color palette, typography, and shapes to align with enterprise UI standards.
- Extended Koin integration and modularized database setup for smoother dependency injection and code reuse.
This commit is contained in:
2026-01-24 00:39:31 +01:00
parent f774d686a4
commit f71bfb292b
11 changed files with 1287 additions and 247 deletions
@@ -0,0 +1,524 @@
# SQLDelight Integration in Compose Multiplatform
This guide shows how to integrate SQLDelight in a Compose Multiplatform project with Koin dependency injection.
## Step 1: Add Dependencies
Add below dependencies In `gradle/libs.versions.toml`:
```toml
[versions]
sqldelight = "2.0.1"
koin = "3.5.3"
[libraries]
sqldelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
[plugins]
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
```
In `build.gradle.kts` (project level):
```kotlin
plugins {
alias(libs.plugins.sqldelight) apply false
}
```
In `shared/build.gradle.kts`:
```kotlin
plugins {
alias(libs.plugins.sqldelight)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.koin.core)
implementation(libs.sqldelight.driver.sqlite)
}
androidMain.dependencies {
implementation(libs.koin.android)
implementation(libs.sqldelight.driver.android)
}
iosMain.dependencies {
implementation(libs.sqldelight.driver.native)
}
desktopMain.dependencies {
implementation(libs.sqldelight.driver.sqlite)
}
}
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.database")
}
}
}
```
##Step 2: Create SQL Schema
**Create directory structure:**
`shared/src/commonMain/sqldelight/com/example/database/`
Create `User.sq` file:
```sql
CREATE TABLE User
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
imageUrl TEXT
);
-- Insert a new user
insertUser
:
INSERT INTO User(name, imageUrl)
VALUES (?, ?);
-- Get all users
getAllUsers
:
SELECT *
FROM User;
-- Get user by ID
getUserById
:
SELECT *
FROM User
WHERE id = ?;
-- Update user
updateUser
:
UPDATE User
SET name = ?,
imageUrl = ?
WHERE id = ?;
-- Delete user
deleteUser
:
DELETE
FROM User
WHERE id = ?;
-- Delete all users
deleteAllUsers
:
DELETE
FROM User;
```
## Step 3: Create Database Driver Interface
In `shared/src/commonMain/kotlin/database/DatabaseDriverFactory.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
```
## Step 4: Platform-Specific Implementations
### Android —
`shared/src/androidMain/kotlin/database/DatabaseDriverFactory.android.kt`:
```kotlin
package com.example.database
import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
schema = AppDatabase.Schema,
context = context,
name = "app.db"
)
}
}
```
### iOS —
`shared/src/iosMain/kotlin/database/DatabaseDriverFactory.ios.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(
schema = AppDatabase.Schema,
name = "app.db"
)
}
}
```
### Desktop —
`shared/src/desktopMain/kotlin/database/DatabaseDriverFactory.desktop.kt`:
```kotlin
package com.example.database
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
AppDatabase.Schema.create(driver)
return driver
}
}
```
## Step 5: Create Repository
In `shared/src/commonMain/kotlin/repository/UserRepository.kt`:
```kotlin
package com.example.repository
import com.example.database.AppDatabase
import com.example.database.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
class UserRepository(private val database: AppDatabase) {
private val queries = database.userQueries
suspend fun insertUser(name: String, imageUrl: String?) = withContext(Dispatchers.IO) {
queries.insertUser(name, imageUrl)
}
suspend fun getAllUsers(): List<User> = withContext(Dispatchers.IO) {
queries.getAllUsers().executeAsList()
}
suspend fun getUserById(id: Long): User? = withContext(Dispatchers.IO) {
queries.getUserById(id).executeAsOneOrNull()
}
suspend fun updateUser(id: Long, name: String, imageUrl: String?) = withContext(Dispatchers.IO) {
queries.updateUser(name, imageUrl, id)
}
suspend fun deleteUser(id: Long) = withContext(Dispatchers.IO) {
queries.deleteUser(id)
}
suspend fun deleteAllUsers() = withContext(Dispatchers.IO) {
queries.deleteAllUsers()
}
}
```
## Step 6: Setup Koin Modules
In `shared/src/commonMain/kotlin/di/DatabaseModule.kt`:
```kotlin
package com.example.di
import com.example.database.AppDatabase
import com.example.database.DatabaseDriverFactory
import com.example.repository.UserRepository
import org.koin.dsl.module
val databaseModule = module {
single { DatabaseDriverFactory() }
single { AppDatabase(get<DatabaseDriverFactory>().createDriver()) }
single { UserRepository(get()) }
}
```
### Platform-specific modules
### Android —
`shared/src/androidMain/kotlin/di/PlatformModule.android.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory(androidContext()) }
}
```
### iOS —
`shared/src/iosMain/kotlin/di/PlatformModule.ios.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory() }
}
```
### Desktop —
`shared/src/desktopMain/kotlin/di/PlatformModule.desktop.kt`:
```kotlin
package com.example.di
import com.example.database.DatabaseDriverFactory
import org.koin.dsl.module
actual val platformModule = module {
single { DatabaseDriverFactory() }
}
```
### Common module declaration —
`shared/src/commonMain/kotlin/di/PlatformModule.kt`:
```kotlin
package com.example.di
import org.koin.core.module.Module
expect val platformModule: Module
```
## Step 7: Initialize Koin
In `shared/src/commonMain/kotlin/di/KoinInit.kt`:
```kotlin
package com.example.di
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(
platformModule,
databaseModule
)
}
```
## Step 8: Platform Initialization
### Android —
In `MainActivity.kt`:
```kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initKoin {
androidContext(this@MainActivity)
}
setContent {
App()
}
}
}
```
### iOS —
In `iosApp/iosApp/iOSApp.swift`:
```kotlin
import SwiftUI
import shared
@main
struct iOSApp : App {
init() {
KoinInitKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
### Desktop —
In `desktopApp/src/jvmMain/kotlin/main.kt`:
```kotlin
fun main() {
initKoin()
application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
}
```
## Step 9: Use in Compose
### Create VieModel —
In `shared/src/commonMain/kotlin/viewmodel/UserViewModel.kt`:
```kotlin
package com.example.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.database.User
import com.example.repository.UserRepository
import kotlinx.coroutines.launch
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
var users by mutableStateOf<List<User>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
isLoading = true
users = userRepository.getAllUsers()
isLoading = false
}
}
fun addUser(name: String, imageUrl: String?) {
viewModelScope.launch {
userRepository.insertUser(name, imageUrl)
loadUsers()
}
}
fun deleteUser(id: Long) {
viewModelScope.launch {
userRepository.deleteUser(id)
loadUsers()
}
}
}
```
Use in Compose Screen:
```kotlin
@Composable
fun UserScreen() {
val userViewModel: UserViewModel = koinInject()
LazyColumn {
items(userViewModel.users) { user ->
UserItem(
user = user,
onDelete = { userViewModel.deleteUser(user.id) }
)
}
}
}
@Composable
fun UserItem(user: User, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = user.name,
modifier = Modifier.weight(1f)
)
Button(onClick = onDelete) {
Text("Delete")
}
}
}
```
### Thats It!
You now have SQLDelight fully integrated in your Compose Multiplatform project with:
- Database working on Android, iOS, and Desktop
- Koin dependency injection setup
- Repository pattern for clean architecture
- Ready-to-use User table with CRUD operations
The database will automatically handle platform-specific implementations while sharing the same business logic across
all platforms.
+167
View File
@@ -0,0 +1,167 @@
# Architekturstrategien für Asynchrone Persistenz in Kotlin Multiplatform: Eine umfassende Analyse zur Integration von SQLDelight in Web-Umgebungen
## 1. Einleitung und Problemstellung
Die Entwicklung plattformübergreifender Anwendungen mittels Kotlin Multiplatform (KMP) hat in den letzten Jahren einen paradigmatischen Wandel vollzogen. Ein zentraler Bestandteil dieser Architektur ist die Datenpersistenz, für die sich SQLDelight als Industriestandard etabliert hat.
Die Integration der Web-Plattform stellt jedoch eine signifikante architektonische Herausforderung dar. Wie in der Problemstellung korrekt identifiziert, existiert eine fundamentale Diskrepanz zwischen den synchronen I/O-Operationen nativer Plattformen (Android, iOS) und der zwingend asynchronen Natur des Webs. Während native SQLite-Treiber (`AndroidSqliteDriver`, `NativeSqliteDriver`) Datenbankoperationen blockierend ausführen können, erfordert der Browser die Nutzung eines `WebWorkerDriver` und asynchrone Initialisierungsmuster.
Dieser Bericht liefert eine Lösungsarchitektur basierend auf dem "Lazy Async Wrapper"-Muster und Koin.
---
## 2. Theoretisches Fundament: Die Asynchronitäts-Lücke
### 2.1 Native vs. Web-Laufzeitumgebungen
Auf nativen Systemen kann der `SqlDriver` synchron instanziiert werden. Im Browser hingegen nutzt SQLDelight `sql.js` oder `sqlite-wasm` in einem Web Worker. Die Kommunikation erfolgt über Message Passing, was `suspend`-Funktionen für die Initialisierung erzwingt.
### 2.2 Der Paradigmenwechsel mit SQLDelight 2.0
Mit Version 2.0 wurde die Konfiguration `generateAsync` eingeführt:kotlin sqldelight { databases { create("AppDatabase") { packageName.set("com.example.db") generateAsync.set(true) } } }
Setzt man dieses Flag auf `true`, werden alle Datenbankoperationen als `suspend`-Funktionen generiert.[1, 4] Dies ist der erste Schritt zur Vereinheitlichung: Auch native Plattformen nutzen nun (formal) asynchrone Schnittstellen, was den gemeinsamen Code homogenisiert.
---
## 3. Die Lösungsarchitektur: Das "Lazy Async Wrapper"-Muster
Anstatt die Datenbank direkt beim App-Start zu initialisieren (was im Web blockieren oder fehlschlagen würde, wenn der Worker noch nicht bereit ist), kapseln wir den Treiber in einer Wrapper-Klasse.[5, 2]
### 3.1 Definition der Factory
**Datei:** `shared/src/commonMain/kotlin/.../DatabaseDriverFactory.kt`
```kotlin
import app.cash.sqldelight.db.SqlDriver
interface DatabaseDriverFactory {
suspend fun createDriver(): SqlDriver
}
```
### 3.2 Der Database Wrapper
Diese Komponente löst das Problem des Nutzers, indem sie die Initialisierung bis zum ersten Zugriff verzögert und mittels `Mutex` absichert.
**Datei:** `shared/src/commonMain/kotlin/.../DatabaseWrapper.kt`
```kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class DatabaseWrapper(private val driverFactory: DatabaseDriverFactory) {
private var _database: AppDatabase? = null
private val mutex = Mutex()
suspend fun get(): AppDatabase {
_database?.let { return it }
return mutex.withLock {
_database?: AppDatabase(driverFactory.createDriver()).also { _database = it }
}
}
// Helper für Repositories
suspend operator fun <R> invoke(block: suspend (AppDatabase) -> R): R {
return block(get())
}
}
```
---
## 4. Implementierung der Plattform-Treiber
### 4.1 Web (Kotlin/Wasm & JS)
Hier liegt der Kern der Lösung: Wir warten explizit auf die Schema-Erstellung (`awaitCreate`), bevor wir den Treiber zurückgeben.
**Datei:** `shared/src/jsMain/kotlin/.../WebDatabaseDriverFactory.kt`
```kotlin
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import org.w3c.dom.Worker
class WebDatabaseDriverFactory : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
val worker = Worker(
js("""new URL("@cashapp/sqldelight-sqljs-worker/sqljs.worker.js", import.meta.url)""")
)
val driver = WebWorkerDriver(worker)
// WICHTIG: Hier wird asynchron gewartet!
AppDatabase.Schema.create(driver).await()
return driver
}
}
```
**Webpack Konfiguration:**
Damit dies funktioniert, muss die `sql-wasm.wasm` Datei korrekt kopiert werden.
```javascript
// webpack.config.d/sqljs.js
const CopyWebpackPlugin = require('copy-webpack-plugin');
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
'../../node_modules/sql.js/dist/sql-wasm.wasm'
]
})
);
```
### 4.2 Android (Synchron)
Für Android geben wir den synchronen Treiber einfach in der `suspend`-Funktion zurück.
```kotlin
class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
}
```
---
## 5. Integration mit Koin
Da der `DatabaseWrapper` selbst leichtgewichtig ist (er erstellt die DB noch nicht im Konstruktor), kann er problemlos als `single` in Koin registriert werden.
```kotlin
val appModule = module {
single { DatabaseWrapper(get()) }
single { MyRepository(get()) }
}
```
Das Repository nutzt dann den Wrapper:
```kotlin
class MyRepository(private val dbWrapper: DatabaseWrapper) {
suspend fun getItems() = dbWrapper { db ->
db.itemQueries.selectAll().executeAsList()
}
}
```
## 6. Zusammenfassung
Diese Architektur löst den Konflikt zwischen synchronen und asynchronen Welten durch:
1. **`generateAsync = true`**: Erzwingt `suspend` überall.
2. **Wrapper Pattern**: Kapselt die asynchrone Initialisierung (`await()`) im Web.
3. **Koin Singleton**: Der Wrapper kann sofort injiziert werden, die DB wird erst beim ersten `invoke` geladen.