Поймите, что такое Unidirectional Data Flow — однонаправленный поток данных, архитектурный паттерн, при котором данные движутся по замкнутому циклу State → View → Intent → Reducer → State без обратных связей. В отличие от двустороннего связывания, UDF гарантирует, что изменение состояния происходит только через явные действия (Intent/Event), что делает поток данных предсказуемым и отслеживаемым. По данным Google I/O 2024, UDF — рекомендуемая архитектура для Jetpack Compose и SwiftUI приложений с бизнес-логикой средней и высокой сложности.
Главное
Unidirectional Data Flow (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 — Single Source of Truth (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-экран подписывается на state через 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 |
| Single Source of Truth | Нет | Да |
| Тестируемость редуктора | Низкая | Высокая (чистая функция) |
| Рекомендация 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 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также