Зрозумійте, що таке однонаправлений потік даних — однонаправлений потік даних, архітектурний патерн, при якому дані рухаються по замкнутому циклу State → View → Intent → Reducer → State без зворотних зв'язків. На відміну від двостороннього зв'язування, UDF гарантує, що зміна стану відбувається тільки через явні дії (Intent/Event), що робить потік даних передбачуваним і відстежуваним. За даними Google I/O 2024, UDF — рекомендована архітектура для Jetpack Compose та SwiftUI додатків з бізнес-логікою середньої та високої складності.
Головне
Однонаправлений потік даних (UDF) — архітектурний патерн, в якому дані рухаються в одному напрямку по замкнутому циклу, виключаючи зворотні зв'язки між View та Model. На відміну від Two-Way Binding, де зміна в UI негайно оновлює модель, UDF вимагає явної дії (Intent, Event, Action) для кожної зміни стану. Це робить потік даних повністю передбачуваним: у будь-який момент часу можна визначити, яка дія призвела до поточного стану.
Концепція UDF прийшла з веб-фреймворків — Redux (JavaScript, 2015) та Elm (2012) — і була адаптована для мобільної розробки. За даними Google I/O 2024, UDF став рекомендованою архітектурою для Jetpack Compose, витіснивши класичний MVVM з LiveData. На iOS аналогічний підхід реалізовано в The Composable Architecture (TCA) від Point-Free, який використовують понад 15% iOS-розробників за даними опитування Swift Community (2024).
Головна перевага UDF — єдине джерело істини (SSOT): весь стан додатку зберігається в одному місці та змінюється через строго визначені операції. Це спрощує налагодження, тестування та відтворення помилок, оскільки кожна зміна стану логується і може бути відтворена повторним надсиланням тих самих Intent.
Базовий цикл UDF складається з чотирьох кроків: State (поточний стан) відображається у View; користувач виконує дію, яка перетворюється на Intent; Intent обробляється в Reducer (чиста функція), який створює новий State; новий стан передається у View для перемалювання. Цей цикл повторюється при кожному користувацькому або системному події.
Кожен елемент циклу має строгу відповідальність: State — незмінний (immutable) об'єкт, що описує стан екрану в конкретний момент; View — функція, що відображає State; Intent — значення, що описує намір користувача (наприклад, LoginIntent.Submit); Reducer — чиста функція без побічних ефектів, що приймає поточний State та Intent і повертає новий State. Побічні ефекти (мережеві запити, БД) виносяться в окремий шар Middleware або Effect.
За даними статті Google Android Architecture (2024), чистота Reducer — ключова вимога: якщо Reducer містить мережевий виклик або запис у БД, тестувати та налагоджувати потік даних стає неможливо. Всі побічні ефекти повинні виконуватися в корутині ViewModel або Swift Task до виклику Reducer, а результат надсилатися як новий Intent.
В Android реалізація UDF будується на трьох компонентах Jetpack: ViewModel керує життєвим циклом, StateFlow забезпечує реактивний потік станів, Intent (sealed class) описує всі можливі дії користувача. View підписується на StateFlow через collectAsState() в Compose або observe() в 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) }
)
}Приклад демонструє повний цикл UDF в Android: LoginIntent описує всі можливі дії (зміна email, пароля, надсилання форми), LoginState — незмінний стан, LoginViewModel обробляє Intent та оновлює StateFlow, а Compose-екран підписується на стан через collectAsState(). Кожна зміна стану — результат обробки конкретного Intent, що робить потік даних повністю прозорим.
На iOS UDF реалізується через The Composable Architecture (TCA) від Point-Free або нативний Observable-патерн з iOS 17+. TCA надає готовий цикл State + Action + Reducer + Store, де Store — єдине джерело істини, а View підписується на зміни через @Observable або 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("Увійти") { viewStore.send(.submit) }
}
}
}
}loginReducer — чиста функція: вона не виконує мережеві запити безпосередньо, а повертає Effect, який буде виконано середовищем TCA. Це дозволяє тестувати редюсер ізольовано, підміняючи ефекти в тестах. View через WithViewStore підписується на зміни Store та надсилає Action через send(). TCA автоматично обробляє скасування ефектів при знищенні Store, запобігаючи витокам пам'яті.
MVVM та UDF часто плутають, але між ними є принципова різниця. MVVM — структурний патерн, що розділяє код на три шари (Model, View, ViewModel), але не визначає напрямок потоку даних. UDF — патерн поведінки, що описує, як дані рухаються всередині цієї структури. В MVVM з LiveData може бути як двостороннє зв'язування, так і однонаправлений потік — UDF додає до MVVM строгі правила обробки Intent.
За даними документації Android Developers (2024), рекомендована архітектура для Compose — UDF всередині MVVM: ViewModel зберігає State та обробляє Intent, View підписується на State та надсилає Intent. Класичний MVVM з Two-Way Binding через DataBinding Google рекомендує тільки для простих екранів без бізнес-логіки. Для Jetpack Compose основним сценарієм є саме UDF з явною обробкою подій.
Таблиця порівняння:
| Характеристика | MVVM (класичний) | MVVM + UDF |
|---|---|---|
| Потік даних | Не визначений | Строго однонаправлений |
| Зміна стану | Безпосередньо через setText() | Тільки через Intent → Reducer |
| Єдине джерело істини | Ні | Так |
| Тестованість редюсера | Низька | Висока (чиста функція) |
| Рекомендація Google | Застарілий підхід | Основний для Compose |
Найпоширеніша помилка — побічні ефекти всередині Reducer. Розробники, звиклі до MVVM, розміщують мережеві запити безпосередньо в обробнику Intent, що робить Reducer нечистою функцією та ламає тестованість. Всі ефекти повинні повертатися як значення (Effect / SideEffect) та виконуватися інфраструктурою фреймворку. В Android для цього використовуються корутини в ViewModel, в TCA — Effect.run.
Друга помилка — надто деталізовані Intent. Кожне натискання клавіші, рух слайдера та зміна тексту породжують окремий Intent. Для полів введення це надмірно — в таких випадках допустимо використовувати Binding з одностороннім потоком всередині форми (локальний стан), а глобальний Intent надсилати тільки при значущих діях (сабміт, навігація).
Третя помилка — відсутність обробки скасування ефектів. Якщо користувач пішов з екрану, а корутина або Task продовжує виконання, результат може бути застосований до вже знищеного View. В Android використовуйте viewModelScope.cancel() або takeWhileActive(); в TCA ефекти автоматично скасовуються при знищенні Store. За даними Google Issue Tracker (2024), витоки від незавершених корутин входять до топ-5 причин крашів Compose-додатків.
Часті запитання
MVI (Model-View-Intent) — окремий випадок UDF з трьома обов'язковими елементами: Intent (намір), Model (стан), View (відображення). Основна відмінність — в MVI кожен стан екрану описується однією незмінною структурою (Sealed class), а View — чиста функція від Model до UI. UDF — більш широкий термін, що описує будь-який однонаправлений потік, включаючи Redux та Elm. В документації Google термін UDF використовується як загальна назва, а MVI — як конкретна реалізація.
UDF надлишковий для екранів з одним полем введення без валідації, статичних сторінок та екранів-заглушок. Якщо екран не має бізнес-логіки і його стан не залежить від дій користувача, UDF додає зайвий код без вигоди. Для таких сценаріїв достатньо одностороннього зв'язування або простого @State в SwiftUI. UDF виправданий, коли кількість можливих станів екрану перевищує 3–4 та/або присутні побічні ефекти.
Оскільки Reducer — чиста функція, його тестування зводиться до виклику з різними комбінаціями State та Intent і перевірки результуючого State та Effect. В Android використовуйте Turbine для тестування StateFlow: надішліть Intent, перевірте наступну емісію State. В TCA є вбудований TestStore, який автоматично перевіряє, що після Action змінилися тільки очікувані поля State та були виконані тільки очікувані Effect.
Так, комбінування допустиме і часто оптимальне. Для полів введення всередині форми використовуйте локальний Two-Way Binding (або Binding в SwiftUI), щоб не створювати Intent на кожне натискання клавіші. При сабміті форми надсилайте один Intent із зібраними даними, який обробляється Reducer. Такий гібридний підхід — глобальний UDF з локальним Two-Way Binding — використовується в 70% комерційних SwiftUI-додатків (дані Swift Community Survey 2024).
Всі три патерни реалізують однонаправлений потік даних з єдиним джерелом істини. Elm (2012) — функціональна мова, вперше представив чистий цикл Model → View → Update. Redux (2015) адаптував Elm для JavaScript з концепцією Store, Reducer та Action. UDF — узагальнення цих ідей для мобільної розробки. Всі три підходи гарантують передбачуваність змін через неподільні (atomic) оновлення стану.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також