4.1 KiB
4.1 KiB
MVVM + UDF (Unidirectional Data Flow) — Referenz & Vorlage
Ziel: Alle ViewModels folgen einem klaren, einheitlichen Muster. Composables rendern nur State und senden Intents. Business-Logik liegt im ViewModel, nicht in den UI-Funktionen.
Prinzipien
- Eine State-Klasse pro Screen/ViewModel (unveränderbar, vollständiger UI-Snapshot).
- Eine sealed Intent-Hierarchie pro ViewModel (alle Eingaben fließen darüber ein).
- Ein ViewModel, das:
- Intents entgegennimmt (
send(intent)), - State über einen
StateFlowbereitstellt, - Nebenläufigkeit intern kapselt (CoroutineScope),
- Repository-Aufrufe bündelt (keine direkten Store-/API-Aufrufe aus Composables).
- Intents entgegennimmt (
Referenz-Implementierung: Veranstalter
Dateien:
frontend/features/veranstalter-feature/src/commonMain/.../VeranstalterViewModel.ktfrontend/features/veranstalter-feature/src/jvmMain/.../DefaultVeranstalterRepository.ktfrontend/features/veranstalter-feature/src/jvmMain/.../VeranstalterAuswahlScreen.kt(verwendet das ViewModel)
State + Intent (verkürzt):
data class VeranstalterState(
val isLoading: Boolean = false,
val searchQuery: String = "",
val list: List<VeranstalterListItem> = emptyList(),
val filtered: List<VeranstalterListItem> = emptyList(),
val selectedId: Long? = null,
val errorMessage: String? = null,
)
sealed interface VeranstalterIntent {
data object Load : VeranstalterIntent
data class SearchChanged(val query: String) : VeranstalterIntent
data class Select(val id: Long?) : VeranstalterIntent
data object ClearError : VeranstalterIntent
}
ViewModel (verkürzt):
class VeranstalterViewModel(private val repo: VeranstalterRepository) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VeranstalterState(isLoading = true))
val state: StateFlow<VeranstalterState> = _state
init { send(VeranstalterIntent.Load) }
fun send(intent: VeranstalterIntent) {
when (intent) {
is VeranstalterIntent.Load -> load()
is VeranstalterIntent.SearchChanged -> reduce { it.copy(searchQuery = intent.query) }.also { filter() }
is VeranstalterIntent.Select -> reduce { it.copy(selectedId = intent.id) }
is VeranstalterIntent.ClearError -> reduce { it.copy(errorMessage = null) }
}
}
// load(), filter(), reduce() wie in Referenzdatei
}
Repository-Vertrag und JVM-Adapter (Prototyp, Fake-Store):
interface VeranstalterRepository { suspend fun list(): List<VeranstalterListItem> }
class DefaultVeranstalterRepository : VeranstalterRepository {
override suspend fun list(): List<VeranstalterListItem> = FakeVeranstalterStore
.all()
.map { it.toListItem() }
}
Composable-Verwendung (verkürzt):
@Composable
fun VeranstalterAuswahlScreen(onZurueck: () -> Unit, onWeiter: (Long) -> Unit) {
val vm = remember { VeranstalterViewModel(DefaultVeranstalterRepository()) }
val state by vm.state.collectAsState()
OutlinedTextField(
value = state.searchQuery,
onValueChange = { vm.send(VeranstalterIntent.SearchChanged(it)) },
)
LazyColumn {
items(state.filtered) { v ->
Row(Modifier.clickable { vm.send(VeranstalterIntent.Select(v.id)) }) { /* ... */ }
}
}
Button(enabled = state.selectedId != null) {
state.selectedId?.let { onWeiter(it) }
}
}
Regeln (verbindlich)
- MVVM + UDF ist Standard. Keine direkten
StoreV2- oder API-Aufrufe in Composables (auch nicht inonSaved-Callbacks usw.). - Kein lokaler
remember-Zustand für Business-Logik. UI-Interaktionen senden ausschließlich Intents ans ViewModel. - Persistenz/Netzwerk-Zugriffe laufen im Repository. Das ViewModel injiziert das Repository (später per DI).
- State ist die Single Source of Truth pro Screen.
Vorlage für neue ViewModels
data class UiState(...)sealed interface Intent { ... }class XxxViewModel(repo: XxxRepository) { fun send(intent) ... }- Composable:
val state by vm.state.collectAsState()undvm.send(...)an Interaktionsstellen.
Diese Datei dient als Muster-Dokument für alle zukünftigen Frontend-Features.