Однонаправлений потік даних — що це таке, UDF в Android та iOS

Автор: IT Sectr Опубліковано: 2026-02-20 Час читання: 13 хв

Зрозумійте, що таке однонаправлений потік даних — однонаправлений потік даних, архітектурний патерн, при якому дані рухаються по замкнутому циклу State → View → Intent → Reducer → State без зворотних зв'язків. На відміну від двостороннього зв'язування, UDF гарантує, що зміна стану відбувається тільки через явні дії (Intent/Event), що робить потік даних передбачуваним і відстежуваним. За даними Google I/O 2024, UDF — рекомендована архітектура для Jetpack Compose та SwiftUI додатків з бізнес-логікою середньої та високої складності.

Головне

  • Однонаправлений потік даних (UDF) — архітектурний патерн, при якому стан змінюється строго за циклом: введення користувача → Intent → Reducer → новий State → перемалювання View.
  • В Android UDF реалізується через ViewModel + StateFlow + Intent-обробку; в iOS — через @Observable + Reducer-патерн (Composable Architecture).
  • Google рекомендує UDF як основну архітектуру для Jetpack Compose, починаючи з документації 2023 року.
  • UDF усуває проблему нескінченних циклів Two-Way Binding за рахунок єдиного джерела істини (Single Source of Truth).
  • Основний недолік — більше шаблонного коду порівняно з двостороннім зв'язуванням (State, Intent, Reducer, Effect).

Що таке однонаправлений потік даних?

Однонаправлений потік даних (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 → Reducer

Базовий цикл 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.

UDF в Android: ViewModel + StateFlow + Intent

В Android реалізація UDF будується на трьох компонентах Jetpack: ViewModel керує життєвим циклом, StateFlow забезпечує реактивний потік станів, Intent (sealed class) описує всі можливі дії користувача. View підписується на StateFlow через collectAsState() в Compose або observe() в View-системі.

Kotlin
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, що робить потік даних повністю прозорим.

UDF в iOS: TCA та Observable-патерн

На iOS UDF реалізується через The Composable Architecture (TCA) від Point-Free або нативний Observable-патерн з iOS 17+. TCA надає готовий цикл State + Action + Reducer + Store, де Store — єдине джерело істини, а View підписується на зміни через @Observable або ObservableObject.

Swift
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, запобігаючи витокам пам'яті.

UDF проти MVVM: в чому різниця?

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

Типові помилки при впровадженні UDF

Найпоширеніша помилка — побічні ефекти всередині 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-додатків.

Часті запитання

Чим UDF відрізняється від MVI?

MVI (Model-View-Intent) — окремий випадок UDF з трьома обов'язковими елементами: Intent (намір), Model (стан), View (відображення). Основна відмінність — в MVI кожен стан екрану описується однією незмінною структурою (Sealed class), а View — чиста функція від Model до UI. UDF — більш широкий термін, що описує будь-який однонаправлений потік, включаючи Redux та Elm. В документації Google термін UDF використовується як загальна назва, а MVI — як конкретна реалізація.

Коли UDF надлишковий?

UDF надлишковий для екранів з одним полем введення без валідації, статичних сторінок та екранів-заглушок. Якщо екран не має бізнес-логіки і його стан не залежить від дій користувача, UDF додає зайвий код без вигоди. Для таких сценаріїв достатньо одностороннього зв'язування або простого @State в SwiftUI. UDF виправданий, коли кількість можливих станів екрану перевищує 3–4 та/або присутні побічні ефекти.

Як тестувати UDF?

Оскільки Reducer — чиста функція, його тестування зводиться до виклику з різними комбінаціями State та Intent і перевірки результуючого State та Effect. В Android використовуйте Turbine для тестування StateFlow: надішліть Intent, перевірте наступну емісію State. В TCA є вбудований TestStore, який автоматично перевіряє, що після Action змінилися тільки очікувані поля State та були виконані тільки очікувані Effect.

Чи можна комбінувати UDF та Two-Way Binding?

Так, комбінування допустиме і часто оптимальне. Для полів введення всередині форми використовуйте локальний Two-Way Binding (або Binding в SwiftUI), щоб не створювати Intent на кожне натискання клавіші. При сабміті форми надсилайте один Intent із зібраними даними, який обробляється Reducer. Такий гібридний підхід — глобальний UDF з локальним Two-Way Binding — використовується в 70% комерційних SwiftUI-додатків (дані Swift Community Survey 2024).

Що спільного у UDF, Redux та Elm?

Всі три патерни реалізують однонаправлений потік даних з єдиним джерелом істини. Elm (2012) — функціональна мова, вперше представив чистий цикл Model → View → Update. Redux (2015) адаптував Elm для JavaScript з концепцією Store, Reducer та Action. UDF — узагальнення цих ідей для мобільної розробки. Всі три підходи гарантують передбачуваність змін через неподільні (atomic) оновлення стану.

Підсумки

  • Однонаправлений потік даних (UDF) — патерн з однонаправленим циклом State → View → Intent → Reducer, що забезпечує передбачуваність змін стану.
  • В Android UDF реалізується через ViewModel + StateFlow + sealed class Intent, в iOS — через TCA (Reducer + Store) або нативний Observable.
  • Google рекомендує UDF як основну архітектуру для Jetpack Compose, починаючи з 2023 року.
  • Reducer — чиста функція без побічних ефектів; всі мережеві запити та БД-операції виносяться в шар Effect.
  • UDF усуває проблему нескінченних циклів Two-Way Binding за рахунок явної обробки Intent та єдиного джерела істини.
  • Основні ризики — побічні ефекти в Reducer, надмірно деталізовані Intent та незавершені корутини.
  • Гібридний підхід (локальний Two-Way Binding у формі + глобальний UDF) оптимальний для більшості комерційних додатків.

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також