Лекция 14. Платформенные архитектуры и жизненный цикл (Android/iOS)
1. Зачем нужны архитектурные подходы на нативных платформах
В React Native (лекции 9–11) мы уже видели, что без дисциплины компоненты быстро превращаются в «свалку» из состояния, эффектов и сетевых вызовов. На нативных платформах эта проблема острее: Android и iOS сами по себе навязывают свой жизненный цикл (Activity/Fragment, UIViewController), и если архитектуры нет, бизнес‑логика расползается по системным колбэкам.
Архитектура решает три задачи:
- Где живут бизнес‑правила. Их нужно держать отдельно от UI и от SDK платформы, иначе их невозможно переиспользовать и тестировать.
- Как код тестировать и развивать. Чистые слои тестируются юнит‑тестами без эмулятора и устройства.
- Как пережить жизненный цикл. Поворот экрана, сворачивание, убийство процесса не должны терять состояние и плодить утечки.
Ключевая мысль: навигация и жизненный цикл — это инфраструктура, а не бизнес‑логика. Их нужно изолировать.
2. Три подхода: MVVM, MVI, Clean
2.1. MVVM (Model–View–ViewModel)
Идея: View «подписывается» на состояние ViewModel. События пользователя идут в VM, VM меняет состояние, UI перерисовывается. Это прямой аналог того, как в RN мы поднимали состояние выше и прокидывали его вниз через пропсы и хуки.
Плюсы:
- Читаемость и предсказуемость. Хорошо ложится на декларативный UI (Jetpack Compose, SwiftUI).
- Тестируемость: бизнес‑логика концентрируется в VM/UseCase.
Риски:
- «Толстые» ViewModel, смешение навигации и бизнес‑логики, хранение лишнего состояния.
Android (Kotlin, Compose) — упрощённо:
data class UiState( val isLoading: Boolean = false, val items: List<String> = emptyList(), val error: String? = null)
class FeedViewModel( private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(UiState()) val state: StateFlow<UiState> = _state.asStateFlow()
fun load() = viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } runCatching { repo.load() } .onSuccess { items -> _state.update { it.copy(isLoading = false, items = items) } } .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } } }}SwiftUI (iOS):
final class FeedViewModel: ObservableObject { @Published var isLoading = false @Published var items: [String] = [] @Published var error: String?
private let repo: FeedRepository init(repo: FeedRepository) { self.repo = repo }
@MainActor func load() async { isLoading = true; error = nil do { items = try await repo.load() } catch { self.error = error.localizedDescription } isLoading = false }}2.2. MVI (Model–View–Intent)
Идея: весь экран описан одним неизменяемым состоянием. Пользователь генерирует Intents → Reducer создаёт новое состояние. Побочные эффекты (сеть, БД) обособлены. Это напрямую перекликается с Redux‑подходом из RN (лекция 11): один источник правды, чистый reducer, экшены вместо прямых мутаций.
Плюсы: предсказуемость, лёгкое логирование, теоретический time‑travel. Минусы: бойлерплейт на сложных экранах.
sealed interface Intent { data object Load : Intent data class Retry(val reason: String) : Intent}
data class State( val isLoading: Boolean = false, val items: List<String> = emptyList(), val error: String? = null)
fun reduce(state: State, intent: Intent): State = when (intent) { Intent.Load -> state.copy(isLoading = true, error = null) is Intent.Retry -> state.copy(isLoading = true, error = null) }Сайд‑эффекты выполняются отдельно (middleware), а в reduce — только «чистые» изменения состояния.
2.3. Clean Architecture
Это не альтернатива MVVM/MVI, а способ организовать слои всего приложения. MVVM/MVI живут на уровне Presentation.
Слои:
- Domain:
UseCase,Entity— бизнес‑правила, «чистый» код без зависимостей от платформ. - Data: реализации репозиториев, мапперы, источники (remote/local).
- Presentation/UI: ViewModel/Reducer, экраны, адаптеры.
- Framework/Platform: Android/iOS SDK, БД, сеть.
Принцип зависимостей: они направлены внутрь (UI → Domain через интерфейсы). Domain ничего не знает о Data и UI.
Плюсы: слабая связность, тестируемость, смена инфраструктуры без переписывания домена. Минусы: накладные расходы на маленьких проектах.
2.4. Сравнение
| Критерий | MVVM | MVI | Clean |
|---|---|---|---|
| Уровень | Presentation | Presentation | Всё приложение (слои) |
| Состояние | Несколько потоков/полей в VM | Одно immutable‑state | Не про состояние, про границы |
| Поток данных | Двунаправленный (события ⇄ state) | Однонаправленный (Intent → Reducer → State) | Зависимости внутрь |
| Бойлерплейт | Низкий | Высокий | Средний/высокий |
| Дебаг/трассировка | Средний | Отличный | — |
| Когда выбирать | Дефолт для большинства экранов | Сложные состояния, важны предсказуемость и дебаг | Средние/крупные долгоживущие проекты |
| Аналог в RN | Локальное состояние + хуки | Redux / reducer (лек. 11) | Разделение api/ui/store слоёв |
Подходы не взаимоисключающие: типичный продукт — это Clean (слои) + MVVM или MVI на уровне экрана.
3. Модуляризация: слои, границы, правила зависимостей
Типы модулей:
- feature:
feature-feed,feature-details— конкретные экраны/фичи. - domain:
domain-user,domain-feed— use cases, интерфейсы репозиториев. - data:
data-feed— реализации репозиториев, источники. - core:
core-ui,core-utils,core-network. - platform:
db,network, интеграция SDK.
Правила зависимостей:
- Domain — без зависимостей на платформу и UI.
- Data реализует контракты Domain; не «протаскивайте» SDK в Domain.
- Feature зависит от Domain, опционально — от Core. Публичный API минимален.
- Циклические зависимости запрещены. В Gradle разделяйте
api/implementation, в SwiftPM — отдельные таргеты.
Когда масштабировать модуляризацию:
- Долгоживущий продукт, большая команда, нужно ускорить сборку, изолировать области ответственности.
Для маленького приложения дробление на десяток модулей — это оверинжиниринг; начинайте с чётких пакетов и выносите в модули по мере роста.
4. Жизненный цикл и навигация: Android
4.1. Жизненный цикл
- Activity:
onCreate → onStart → onResume → onPause → onStop → onDestroy. - Fragment: зеркально Activity плюс специфичные хуки:
onCreateView,onViewCreated,onDestroyView. Важно различать жизненный цикл фрагмента и жизненный цикл его View — View пересоздаётся чаще.
Главная боль: конфигурационные изменения (поворот, смена темы) пересоздают Activity/Fragment. Если состояние лежит в них — оно теряется.
Практика:
- Состояние экрана хранить в
ViewModel(она переживает поворот) +SavedStateHandleдля восстановления после убийства процесса. - Коллекторы Flow/LiveData делать lifecycle‑aware:
repeatOnLifecycle(Lifecycle.State.STARTED), чтобы не собирать данные в фоне и не течь. - Избегать ручных транзакций фрагментов — использовать Jetpack Navigation.
4.2. Навигация и back stack
Современный подход — Single‑Activity + граф навигации (Jetpack Navigation, SafeArgs). Все экраны — это destination’ы в графе, back stack управляется фреймворком.
<fragment android:id="@+id/feedFragment" android:name="com.example.feature.feed.FeedFragment"> <action android:id="@+id/action_to_details" app:destination="@id/detailsFragment" /></fragment>findNavController().navigate(R.id.action_to_details)Рекомендации:
- Навигацию инициирует UI, но «знание куда идти» лучше держать в навигационном слое/координаторе.
- Передача результата назад — через
SavedStateHandleнавигационного back stack entry илиFragmentResultApi, без прямых ссылок между экранами. - Кнопка «Назад» работает с back stack автоматически; не дублируйте её логику вручную.
5. Жизненный цикл и навигация: iOS
5.1. Жизненный цикл
UIKit, UIViewController: viewDidLoad → viewWillAppear → viewDidAppear → viewWillDisappear → viewDidDisappear. viewDidLoad вызывается один раз при создании View, viewWillAppear/viewDidAppear — каждый раз при показе.
В отличие от Android, iOS не пересоздаёт контроллер при повороте — поворот обрабатывается через изменение размеров и viewWillTransition. Зато систему может выгрузить приложение из памяти, и тогда нужно восстановление состояния (State Restoration).
Принцип тот же: не храните долговечное состояние в контроллере — выносите в модели/сервисы/VM.
В SwiftUI явного жизненного цикла контроллера нет; используются модификаторы onAppear/onDisappear и @StateObject/@State, переживающие перерисовку.
5.2. Навигация и навигационный стек
SwiftUI — NavigationStack с управляемым путём (path):
enum Screen: Hashable { case details(id: String) }
struct RootView: View { @State private var path: [Screen] = []
var body: some View { NavigationStack(path: $path) { List(0..<10, id: \.self) { i in Button("Open \(i)") { path.append(.details(id: "\(i)")) } } .navigationDestination(for: Screen.self) { screen in switch screen { case .details(let id): Text("Details \(id)") } } } }}UIKit — UINavigationController со стеком контроллеров: pushViewController/popViewController. Для слабой связности экранов применяют координаторы: экран сигналит «интент», координатор решает переход.
protocol Coordinator { func start() }
final class AppCoordinator: Coordinator { private let nav: UINavigationController init(nav: UINavigationController) { self.nav = nav }
func start() { let vc = FeedViewController() vc.onSelect = { [weak self] id in self?.showDetails(id: id) } nav.setViewControllers([vc], animated: false) }
private func showDetails(id: String) { nav.pushViewController(DetailsViewController(id: id), animated: true) }}Плюсы координаторов: слабая связность экранов, тестируемая навигация, переиспользуемые сценарии, удобный deep linking.
5.3. Сравнение жизненного цикла
| Аспект | Android | iOS |
|---|---|---|
| Основной объект | Activity / Fragment | UIViewController |
| Ключевые колбэки | onCreate/onStart/onResume/onPause/onStop/onDestroy | viewDidLoad/viewWillAppear/viewDidAppear/... |
| Поворот экрана | Пересоздаёт Activity/Fragment | Не пересоздаёт, меняет размеры |
| Где хранить состояние | ViewModel + SavedStateHandle | Модель/сервис + State Restoration |
| Навигация (декларативно) | Jetpack Navigation (граф) | SwiftUI NavigationStack (path) |
| Навигация (императивно) | FragmentManager / NavController | UINavigationController (push/pop) |
| Back stack | Управляется фреймворком, системная кнопка «Назад» | Стек контроллеров, свайп‑назад / pop |
6. Как выбирать подход и типичные ошибки
Выбор подхода:
- MVVM — дефолт для большинства экранов; декларативные UI‑фреймворки поддерживают его «из коробки».
- MVI — сложные состояния, где критичны предсказуемость и дебаг.
- Clean — средние/крупные приложения, долгий жизненный цикл, смена поставщиков/SDK.
- Координаторы / NavigationStack / Navigation — когда граф нетривиален, есть deep links и восстановление.
Анти‑паттерны:
- Domain зависит от SDK / Android / iOS классов.
- Навигация и бизнес‑логика перемешаны во View или ViewModel.
- Состояние хранится в контроллерах/фрагментах и теряется при конфигурационных изменениях.
- «Бог‑ViewModel», которая делает всё: загрузку, навигацию, форматирование.
- Сбор Flow без привязки к жизненному циклу → утечки и фоновая работа.
- Преждевременная модуляризация и Clean на крошечном проекте.
Краткие итоги
- Архитектура определяет, где живут бизнес‑правила, и защищает их от жизненного цикла платформы.
- MVVM, MVI и Clean — не конкуренты: Clean задаёт слои, MVVM/MVI организуют экран. MVI — это знакомый по RN Redux‑подход на нативе.
- Модуляризация и строгие правила зависимостей (внутрь, без циклов) уменьшают связность и ускоряют сборку, но не нужны на старте.
- Android пересоздаёт Activity/Fragment при повороте — состояние держим в
ViewModel+SavedStateHandle; iOS контроллер не пересоздаёт, но нужно восстановление после выгрузки. - Навигация и жизненный цикл — инфраструктура: изолируйте их в навигационном слое/координаторе.
Вопросы для самопроверки
- Чем MVVM отличается от MVI в управлении состоянием и потоком данных? Когда что выбирать?
- Как Clean Architecture ограничивает зависимости и почему Domain не должен «знать» про платформу?
- Как восстановить состояние экрана после убийства процесса в Android и в iOS?
- Где должна жить навигация — во ViewModel, во View или в отдельном слое? Почему?
- Чем отличается реакция на поворот экрана в Android и iOS, и как это влияет на хранение состояния?
- Как организовать возврат результата с дочернего экрана (Android/iOS) без жёстких связок между экранами?