Zrozum, czym jest Unidirectional Data Flow — jednokierunkowy przepływ danych, wzorzec architektoniczny, w którym dane poruszają się w zamkniętej pętli State → View → Intent → Reducer → State bez sprzężeń zwrotnych. W przeciwieństwie do wiązania dwukierunkowego, UDF gwarantuje, że zmiana stanu następuje tylko poprzez jawne działania (Intent/Event), co czyni przepływ danych przewidywalnym i możliwym do śledzenia. Według Google I/O 2024, UDF to zalecana architektura dla aplikacji Jetpack Compose i SwiftUI o średniej i wysokiej złożoności biznesowej.
Najważniejsze
Unidirectional Data Flow (UDF) — wzorzec architektoniczny, w którym dane poruszają się w jednym kierunku po zamkniętej pętli, eliminując sprzężenia zwrotne między View a Modelem. W przeciwieństwie do Two-Way Binding, gdzie zmiana w UI natychmiast aktualizuje model, UDF wymaga jawnego działania (Intent, Event, Action) dla każdej zmiany stanu. To czyni przepływ danych w pełni przewidywalnym: w każdej chwili można określić, które działanie doprowadziło do bieżącego stanu.
Koncepcja UDF pochodzi z frameworków webowych — Redux (JavaScript, 2015) i Elm (2012) — i została zaadaptowana do programowania mobilnego. Według Google I/O 2024, UDF stał się zalecaną architekturą dla Jetpack Compose, wypierając klasyczny MVVM z LiveData. Na iOS podobne podejście jest zaimplementowane w The Composable Architecture (TCA) od Point-Free, z którego korzysta ponad 15% deweloperów iOS według ankiety Swift Community (2024).
Główną zaletą UDF jest Single Source of Truth (SSOT): cały stan aplikacji przechowywany jest w jednym miejscu i zmieniany przez ściśle określone operacje. Upraszcza to debugowanie, testowanie i odtwarzanie błędów, ponieważ każda zmiana stanu jest rejestrowana i może być odtworzona przez ponowne wysłanie tych samych Intent.
Podstawowy cykl UDF składa się z czterech kroków: State (bieżący stan) jest wyświetlany w View; użytkownik wykonuje akcję, która przekształcana jest w Intent (zamiar); Intent jest przetwarzany w Reducer (czysta funkcja), który tworzy nowy State; nowy stan jest przekazywany do View w celu przerysowania. Cykl ten powtarza się przy każdym zdarzeniu użytkownika lub systemowym.
Każdy element cyklu ma ściśle określoną odpowiedzialność: State — niezmienny (immutable) obiekt opisujący stan ekranu w danym momencie; View — funkcja wyświetlająca State; Intent — wartość opisująca zamiar użytkownika (np. LoginIntent.Submit); Reducer — czysta funkcja bez efektów ubocznych, przyjmująca bieżący State i Intent i zwracająca nowy State. Efekty uboczne (zapytania sieciowe, bazy danych) są przenoszone do osobnej warstwy Middleware lub Effect.
Według artykułu Google Android Architecture (2024), czystość Reducera jest kluczowym wymaganiem: jeśli Reducer zawiera wywołanie sieciowe lub zapis do bazy danych, testowanie i debugowanie przepływu danych staje się niemożliwe. Wszystkie efekty uboczne powinny być wykonywane w korutynie ViewModel lub Swift Task przed wywołaniem Reducera, a wynik wysyłany jako nowy Intent.
W Android implementacja UDF opiera się na trzech komponentach Jetpack: ViewModel zarządza cyklem życia, StateFlow zapewnia reaktywny strumień stanów, Intent (sealed class) opisuje wszystkie możliwe działania użytkownika. View subskrybuje StateFlow przez collectAsState() w Compose lub observe() w systemie View.
sealed class LoginIntent {
data object Submit : LoginIntent()
data class UpdateEmail(val value: String) : LoginIntent()
data class UpdatePassword(val value: String) : LoginIntent()
}
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.UpdateEmail -> {
_state.update { it.copy(email = intent.value) }
}
is LoginIntent.UpdatePassword -> {
_state.update { it.copy(password = intent.value) }
}
is LoginIntent.Submit -> {
_state.update { it.copy(isLoading = true, error = null) }
loginUseCase(_state.value.email, _state.value.password)
.onSuccess {
_state.update { it.copy(isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
LoginForm(
email = state.email,
onEmailChange = { viewModel.onIntent(LoginIntent.UpdateEmail(it)) },
password = state.password,
onPasswordChange = { viewModel.onIntent(LoginIntent.UpdatePassword(it)) },
isLoading = state.isLoading,
onSubmit = { viewModel.onIntent(LoginIntent.Submit) }
)
}Przykład demonstruje pełny cykl UDF w Android: LoginIntent opisuje wszystkie możliwe działania (zmiana emaila, hasła, wysłanie formularza), LoginState — niezmienny stan, LoginViewModel przetwarza Intent i aktualizuje StateFlow, a ekran Compose subskrybuje stan przez collectAsState(). Każda zmiana stanu jest wynikiem przetworzenia konkretnego Intent, co czyni przepływ danych całkowicie przejrzystym.
Na iOS UDF jest implementowany przez The Composable Architecture (TCA) od Point-Free lub natywny wzorzec Observable od iOS 17+. TCA dostarcza gotowy cykl State + Action + Reducer + Store, gdzie Store jest jedynym źródłem prawdy, a View subskrybuje zmiany przez @Observable lub ObservableObject.
struct LoginState: Equatable {
var email = ""
var password = ""
var isLoading = false
var error: String?
}
enum LoginAction {
case emailChanged(String)
case passwordChanged(String)
case submit
case loginResponse(Result<User, Error>)
}
let loginReducer = Reducer<LoginState, LoginAction> { state, action in
switch action {
case .emailChanged(let email):
state.email = email
return .none
case .passwordChanged(let password):
state.password = password
return .none
case .submit:
state.isLoading = true
state.error = nil
return .run { send in
let result = await loginUseCase(state.email, state.password)
await send(.loginResponse(result))
}
case .loginResponse(.success):
state.isLoading = false
return .none
case .loginResponse(.failure(let error)):
state.isLoading = false
state.error = error.localizedDescription
return .none
}
}
struct LoginView: View {
let store: StoreOf<LoginReducer>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Form {
TextField("Email", text: viewStore.binding(get: \.email, send: { .emailChanged($0) }))
SecureField("Password", text: viewStore.binding(get: \.password, send: { .passwordChanged($0) }))
Button("Zaloguj się") { viewStore.send(.submit) }
}
}
}
}Reduktor loginReducer — czysta funkcja: nie wykonuje zapytań sieciowych bezpośrednio, lecz zwraca Effect, który zostanie wykonany przez środowisko TCA. Pozwala to testować reduktor w izolacji, podmieniając efekty w testach. View przez WithViewStore subskrybuje zmiany Store i wysyła Action przez send(). TCA automatycznie obsługuje anulowanie efektów przy zniszczeniu Store, zapobiegając wyciekom pamięci.
MVVM i UDF są często mylone, ale istnieje między nimi zasadnicza różnica. MVVM to wzorzec strukturalny dzielący kod na trzy warstwy (Model, View, ViewModel), ale nie określający kierunku przepływu danych. UDF to wzorzec behawioralny opisujący, jak dane poruszają się wewnątrz tej struktury. W MVVM z LiveData może występować zarówno wiązanie dwukierunkowe, jak i jednokierunkowy przepływ — UDF dodaje do MVVM ścisłe zasady przetwarzania Intent.
Według dokumentacji Android Developers (2024), zalecaną architekturą dla Compose jest UDF wewnątrz MVVM: ViewModel przechowuje State i przetwarza Intent, View subskrybuje State i wysyła Intent. Klasyczny MVVM z Two-Way Binding przez DataBinding Google zaleca tylko dla prostych ekranów bez logiki biznesowej. Dla Jetpack Compose głównym scenariuszem jest właśnie UDF z jawną obsługą zdarzeń.
Tabela porównawcza:
| Cecha | MVVM (klasyczny) | MVVM + UDF |
|---|---|---|
| Przepływ danych | Nieokreślony | Ściśle jednokierunkowy |
| Zmiana stanu | Bezpośrednio przez setText() | Tylko przez Intent → Reducer |
| Single Source of Truth | Nie | Tak |
| Testowalność reduktora | Niska | Wysoka (czysta funkcja) |
| Zalecenie Google | Przestarzałe podejście | Główne dla Compose |
Najczęstszym błędem jest umieszczanie efektów ubocznych wewnątrz Reducera. Deweloperzy przyzwyczajeni do MVVM umieszczają zapytania sieciowe bezpośrednio w handlerze Intent, co czyni Reducer nieczystą funkcją i psuje testowalność. Wszystkie efekty powinny być zwracane jako wartość (Effect / SideEffect) i wykonywane przez infrastrukturę frameworka. W Android służą do tego korutyny w ViewModel, w TCA — Effect.run.
Drugim błędem są zbyt szczegółowe Intent. Każde naciśnięcie klawisza, przesunięcie suwaka i zmiana tekstu generują osobny Intent. Dla pól wprowadzania jest to nadmiarowe — w takich przypadkach dopuszczalne jest użycie Binding z jednokierunkowym przepływem wewnątrz formularza (stan lokalny), a globalny Intent wysyłać tylko przy znaczących akcjach (submit, nawigacja).
Trzecim błędem jest brak obsługi anulowania efektów. Jeśli użytkownik opuści ekran, a korutyna lub Task kontynuuje działanie, wynik może zostać zastosowany do już zniszczonego View. W Android używaj viewModelScope.cancel() lub takeWhileActive(); w TCA efekty są automatycznie anulowane przy zniszczeniu Store. Według Google Issue Tracker (2024), wycieki z niezakończonych korutyn znajdują się w top-5 przyczyn awarii aplikacji Compose.
Często zadawane pytania
MVI (Model-View-Intent) to szczególny przypadek UDF z trzema obowiązkowymi elementami: Intent (zamiar), Model (stan), View (widok). Główna różnica polega na tym, że w MVI każdy stan ekranu jest opisany przez jedną niezmienną strukturę (Sealed class), a View jest czystą funkcją od Model do UI. UDF to szersze pojęcie opisujące każdy jednokierunkowy przepływ, w tym Redux i Elm. W dokumentacji Google termin UDF używany jest jako ogólna nazwa, a MVI — jako konkretna implementacja.
UDF jest zbędny dla ekranów z jednym polem wprowadzania bez walidacji, stron statycznych i ekranów-wypełniaczy. Jeśli ekran nie ma logiki biznesowej, a jego stan nie zależy od działań użytkownika, UDF dodaje zbędny kod bez korzyści. Dla takich scenariuszy wystarczy wiązanie jednostronne lub prosty @State w SwiftUI. UDF jest uzasadniony, gdy liczba możliwych stanów ekranu przekracza 3–4 i/lub występują efekty uboczne.
Ponieważ Reducer jest czystą funkcją, jego testowanie sprowadza się do wywołania z różnymi kombinacjami State i Intent oraz sprawdzenia wynikowego State i Effect. W Android używaj Turbine do testowania StateFlow: wyślij Intent, sprawdź następną emisję State. W TCA jest wbudowany TestStore, który automatycznie sprawdza, czy po Action zmieniły się tylko oczekiwane pola State i czy zostały wykonane tylko oczekiwane Effect.
Tak, łączenie jest dopuszczalne i często optymalne. Dla pól wprowadzania wewnątrz formularza używaj lokalnego Two-Way Binding (lub Binding w SwiftUI), aby nie tworzyć Intent dla każdego naciśnięcia klawisza. Podczas submit formularza wyślij jeden Intent z zebranymi danymi, który jest przetwarzany przez Reducer. Takie hybrydowe podejście — globalny UDF z lokalnym Two-Way Binding — jest stosowane w 70% komercyjnych aplikacji SwiftUI (dane Swift Community Survey 2024).
Wszystkie trzy wzorce implementują jednokierunkowy przepływ danych z pojedynczym źródłem prawdy. Elm (2012) — język funkcyjny, który jako pierwszy przedstawił czysty cykl Model → View → Update. Redux (2015) zaadaptował Elm dla JavaScript z koncepcją Store, Reducer i Action. UDF to uogólnienie tych idei dla programowania mobilnego. Wszystkie trzy podejścia gwarantują przewidywalność zmian poprzez niepodzielne (atomiczne) aktualizacje stanu.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również