Перейти к содержимому

Лекция 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. Сравнение

КритерийMVVMMVIClean
УровеньPresentationPresentationВсё приложение (слои)
СостояниеНесколько потоков/полей в 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 управляется фреймворком.

navigation.xml
<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. Сравнение жизненного цикла

АспектAndroidiOS
Основной объектActivity / FragmentUIViewController
Ключевые колбэкиonCreate/onStart/onResume/onPause/onStop/onDestroyviewDidLoad/viewWillAppear/viewDidAppear/...
Поворот экранаПересоздаёт Activity/FragmentНе пересоздаёт, меняет размеры
Где хранить состояниеViewModel + SavedStateHandleМодель/сервис + State Restoration
Навигация (декларативно)Jetpack Navigation (граф)SwiftUI NavigationStack (path)
Навигация (императивно)FragmentManager / NavControllerUINavigationController (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 контроллер не пересоздаёт, но нужно восстановление после выгрузки.
  • Навигация и жизненный цикл — инфраструктура: изолируйте их в навигационном слое/координаторе.

Вопросы для самопроверки

  1. Чем MVVM отличается от MVI в управлении состоянием и потоком данных? Когда что выбирать?
  2. Как Clean Architecture ограничивает зависимости и почему Domain не должен «знать» про платформу?
  3. Как восстановить состояние экрана после убийства процесса в Android и в iOS?
  4. Где должна жить навигация — во ViewModel, во View или в отдельном слое? Почему?
  5. Чем отличается реакция на поворот экрана в Android и iOS, и как это влияет на хранение состояния?
  6. Как организовать возврат результата с дочернего экрана (Android/iOS) без жёстких связок между экранами?