Мобильная разработка Архитектура мобильного приложения: слои, состояние, MVVM/MVI, DI
0%

Архитектура мобильного приложения: слои, состояние, MVVM/MVI, DI

Архитектура мобильного приложения: слои, состояние, MVVM/MVI, DI

Что именно архитектура решает на мобильном

Архитектура — это не диаграмма и не набор папок. Это множество решений, которые дорого менять позже, и на мобильном «позже» наступает жёстче, чем где-либо: код уже установлен на миллионы устройств, откатить релиз нельзя, исправление доедет за недели. Есть соблазн перенести серверный опыт целиком — слои, репозитории, DI-контейнер, вроде бы всё то же самое. Не то же самое: серверный процесс живёт под вашим контролем, перезапускается по команде и обрабатывает короткий запрос с чистого листа, а мобильное приложение — долгоживущий процесс на чужом устройстве, который ОС убивает без предупреждения, а пользователь запускает в метро без сети. Отсюда четыре силы, задающие всю мобильную архитектуру:

Сила мобильного контекста Что она ломает в «обычной» архитектуре Архитектурное следствие
ОС убивает процесс в любой момент «состояние живёт в памяти сервиса» состояние обязано иметь уровень выживания, и он выбирается осознанно
Сети может не быть, и это норма «ошибка сети → показать ошибку» оффлайн — штатное состояние домена, а не ветка catch
Релиз необратим, ревью занимает дни «поправим и задеплоим» переключатели поведения (kill switch, remote config) — часть архитектуры, а не костыль
Батарея и трафик — чужой бюджет «дёрнем API ещё раз, не страшно» архитектура определяет число походов в сеть; лишний источник истины стоит миллиампер-часов

Отдельно — двухплатформенность. Любое архитектурное решение вы принимаете дважды либо принимаете один раз и платите за общий код. Это не стилистический вопрос: он определяет и стоимость, и найм, и скорость реакции на изменения ОС. Мы вернёмся к нему в разделе про кроссплатформу; подробное сравнение стеков — в Кроссплатформе.

Рабочее определение на всю статью:

Архитектура мобильного приложения — это ответы на три вопроса: кто владеет состоянием, кто знает про платформу и что переживает что. Все паттерны ниже — способы записать эти ответы так, чтобы их нельзя было случайно нарушить.

Слои и правило зависимостей

Слои мобильного приложения и направление зависимостей

Слои — это не «папки по типам файлов», а шкала знания о внешнем мире. Верхний слой знает про пиксели и жесты, нижний — про SQLite и HTTP, средний не знает ни про то, ни про другое. Общая теория слоистых и гексагональных архитектур разобрана в статье Слоистая, гексагональная и чистая архитектура; здесь важна мобильная специфика.

Слой Что в нём живёт Чего в нём не должно быть Чем тестируется
UI View, @Composable, Widget, анимации, жесты условий вида «если роль админ — показать кнопку», сетевых вызовов UI-тест на устройстве, скриншот-тест
Presentation ViewModel / Store / Bloc, UiState, маппинг данных в текст ссылок на Context, UIView, BuildContext обычный unit-тест на JVM или в симуляторе
Domain сущности, инварианты, use case импортов Android SDK, UIKit, Retrofit, Room самые быстрые unit-тесты, без моков платформы
Data репозитории, DAO, HTTP-клиенты, кэш, миграции знания об экранах и о том, что сейчас показано тесты с in-memory БД и fake-сервером

Правило одно и проверяется автоматически: компиляционная зависимость никогда не идёт вверх. Если Domain импортировал android.content.Context, слои уже склеены. На Android это ловится модулем :domain как чистой Kotlin/JVM-библиотекой (SDK там физически недоступен), на iOS — отдельным SPM-таргетом, который не линкует UIKit; такой запрет ценнее любого code style, потому что не даёт нарушению попасть в main. Обратная зависимость — от Data к Domain — обеспечивается инверсией: интерфейс (порт) объявлен в Domain, реализация лежит в Data. Это буквально принцип инверсии зависимостей из SOLID, и на мобильном он окупается подстановкой fake-репозитория в тестах и переиспользованием Domain в Kotlin Multiplatform без единой правки.

Честная поправка. Каноническая Clean Architecture с UseCase на каждый вызов, отдельными DTO/Entity/Domain-моделями и тремя маппингами подряд для CRUD-приложения из десяти экранов — чистый убыток. Пропорция, которая работает на практике:

  • экран показывает данные почти как есть → ViewModel идёт в репозиторий напрямую, Domain схлопывается;
  • в правиле участвуют два-три источника, есть инварианты или расчёты (цена с промокодом, лимиты, право на действие) → появляется use case;
  • логика нужна и на iOS, и на Android → Domain выделяется в общий модуль, и цена маппингов окупается.

Худший исход — не «мало слоёв», а слои, существующие в названиях папок, но не в зависимостях: UseCase, который просто вызывает repository.get() и возвращает результат, добавляет файл, но ничего не защищает.

Однонаправленный поток данных

Все современные мобильные UI-фреймворки — SwiftUI, Compose, Flutter, React Native — декларативны: интерфейс есть функция состояния. Из этого автоматически следует однонаправленный поток данных (UDF): состояние течёт вниз к UI, события — вверх к владельцу состояния. Двусторонний биндинг и «контроллер меняет вью напрямую» из этой модели выпадают.

Три свойства этой схемы стоят того, чтобы их защищать. Единственная точка мутации: состояние меняется только внутри владельца, и ответ на «почему на экране это» ищется в одном файле, а не среди всех, кто держит ссылку на вью. Неизменяемость состояния: новый кадр — новый объект, никаких «список поменялся под руками адаптера» (про цену подхода — Иммутабельность). Глупый UI: он не решает, а рисует — именно это позволяет заменить Compose на SwiftUI в KMP-проекте и не переписать логику.

Тот же принцип лежит в основе Redux-подобных решений в вебе — сравнение подходов есть в статье Управление состоянием. Мобильное отличие: у веб-стора нет обязанности пережить убийство процесса, а у мобильного есть.

Состояние экрана как тип, а не как набор флагов

Самая частая архитектурная ошибка в мобильных приложениях — «bool-суп»:

// Так делать не надо: 4 булевых поля дают 16 комбинаций,
// из которых осмысленны 4 — остальные ждут в проде.
data class BadUiState(
    val isLoading: Boolean = false,
    val isError: Boolean = false,
    val isEmpty: Boolean = false,
    val items: List<Item> = emptyList(),
)

Комбинация isLoading && isError не имеет смысла, но выражается — и рано или поздно возникнет на слабом устройстве при гонке двух запросов. Лечится алгебраическими типами: состояние экрана становится суммой вариантов, где невалидные сочетания невыразимы (подробнее про приём — АТД и сопоставление с образцом).

// Android: sealed interface — компилятор потребует разобрать все ветки в when
sealed interface CartUiState {
    data object Loading : CartUiState
    data class Error(val kind: ErrorKind, val canRetry: Boolean) : CartUiState
    data object Empty : CartUiState
    data class Content(
        val items: List<CartItem>,
        val total: Money,
        val isRefreshing: Boolean,   // обновление поверх уже показанных данных
        val staleSince: Instant?,    // данные из кэша: показываем плашку «оффлайн»
    ) : CartUiState
}
// iOS: enum с ассоциированными значениями решает ту же задачу
enum CartUiState: Equatable {
    case loading
    case error(kind: ErrorKind, canRetry: Bool)
    case empty
    case content(items: [CartItem], total: Money, isRefreshing: Bool, staleSince: Date?)
}

Обратите внимание на isRefreshing внутри Content: это принципиальная мобильная деталь. Оффлайн-first приложение почти никогда не показывает пустой спиннер — оно показывает старые данные и тихо обновляет их. Первичная загрузка (Loading, скелетон) и фоновое обновление (Content(isRefreshing = true), тонкий индикатор поверх контента) — разные состояния, и путать их нельзя. На диаграмме видно, что «оффлайн» здесь не тупик, а рабочий режим:

MVVM: рабочая лошадь и её слабое место

MVVM (Model–View–ViewModel) — то, что рекомендуют обе платформы: Google в Guide to app architecture, Apple — де-факто через @Observable. ViewModel владеет состоянием экрана, View подписывается на него и шлёт события.

// Android, Kotlin: ViewModel + StateFlow
@HiltViewModel
class CartViewModel @Inject constructor(
    private val cart: CartRepository,
    private val applyPromo: ApplyPromoCode,      // use case
    savedState: SavedStateHandle,                // единственное, что переживёт смерть процесса
) : ViewModel() {

    // id корзины восстанавливается из аргументов навигации даже после перезапуска процесса
    private val cartId: String = checkNotNull(savedState["cartId"])
    private val refreshing = MutableStateFlow(false)

    // Источник истины — локальная БД; сеть её только наполняет
    val state: StateFlow<CartUiState> =
        combine(cart.observe(cartId), refreshing) { snapshot, isRefreshing ->
            if (snapshot.items.isEmpty() && snapshot.isFresh) CartUiState.Empty
            else CartUiState.Content(snapshot.items, snapshot.total, isRefreshing, snapshot.staleSince)
        }.stateIn(
            scope = viewModelScope,
            // 5 секунд переживают поворот экрана, но не уход в фон: подписки
            // на БД и сеть отпускаются, когда приложение свернули
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = CartUiState.Loading,
        )

    fun onRefresh() = viewModelScope.launch {
        refreshing.value = true
        try { cart.sync(cartId) } finally { refreshing.value = false }
    }
}
// iOS, Swift: @Observable — макрос вместо ObservableObject, наблюдение по конкретным свойствам
@Observable @MainActor
final class CartModel {
    private(set) var state: CartUiState = .loading
    private let cart: any CartRepository        // протокол, а не конкретный тип: подменяется в тестах

    init(cart: any CartRepository) { self.cart = cart }

    // Вызывается из .task у View: подписка автоматически отменяется при уходе с экрана
    func observe(cartId: String) async {
        for await snapshot in cart.stream(cartId) { state = Self.map(snapshot) }
    }

    func onRefresh(cartId: String) async {
        if case .content(let i, let t, _, let s) = state {
            state = .content(items: i, total: t, isRefreshing: true, staleSince: s)
        }
        try? await cart.sync(cartId)   // ошибку покажет поток из БД: плашка, а не алерт
    }
}

Что MVVM делает хорошо: минимум церемоний, естественная работа с потоками, понятная граница «выше — платформа, ниже — логика». Где ломается:

  • Несколько независимых mutable-полей возвращают проблему невалидных комбинаций. Лечится тем же приёмом: одно поле state типа-суммы, а не десять var.
  • Гонки при быстрых пользовательских действиях. Два одновременных launch, каждый пишет в состояние — побеждает тот, кто финиширует последним. Нужна явная дисциплина: отмена предыдущей задачи (Task в Swift, Job в Kotlin) или сериализация через один канал.
  • Одноразовые события (навигация, снекбар) в модель «состояние → UI» не укладываются вообще. Об этом ниже отдельно.

MVI: одна точка мутации и редьюсер

MVI (Model–View–Intent), он же MVU и «Elm-архитектура», доводит UDF до предела: состояние меняется только чистой функцией (State, Event) -> State, а всё остальное — эффекты.

// Контракт экрана виден целиком в одном файле — это главная ценность подхода
sealed interface CartEvent {
    data class Refresh(val cartId: String) : CartEvent
    data class QuantityChanged(val cartId: String, val sku: String, val value: Int) : CartEvent
    data class Loaded(val snapshot: CartSnapshot) : CartEvent          // результат эффекта
    data class Failed(val error: AppError, val at: Instant) : CartEvent
}

sealed interface CartEffect {                                          // не является состоянием
    data class Sync(val cartId: String) : CartEffect
    data object NavigateToCheckout : CartEffect
}

// Чистая функция: ни корутин, ни IO, ни системного времени — тестируется без диспетчеров.
// Матрица «состояние × событие» видна глазами, устаревшие события отбрасываются явно.
fun reduce(state: CartUiState, event: CartEvent): Pair<CartUiState, List<CartEffect>> =
    when (event) {
        is CartEvent.Loaded -> event.snapshot.toUiState() to emptyList()
        is CartEvent.Refresh -> when (state) {
            is CartUiState.Content -> state.copy(isRefreshing = true) to listOf(Sync(event.cartId))
            else -> CartUiState.Loading to listOf(Sync(event.cartId))
        }
        is CartEvent.Failed -> when {
            // Оффлайн поверх уже показанных данных — не ошибка экрана, а плашка
            state is CartUiState.Content && event.error == AppError.Offline ->
                state.copy(isRefreshing = false, staleSince = event.at) to emptyList()
            else -> CartUiState.Error(event.error.kind, canRetry = true) to emptyList()
        }
        is CartEvent.QuantityChanged -> when (state) {
            is CartUiState.Content ->                                  // оптимистичное обновление
                state.withQuantity(event.sku, event.value) to listOf(Sync(event.cartId))
            else -> state to emptyList()                               // событие устарело
        }
    }

На Dart во Flutter (bloc) и на TypeScript в React Native тот же контракт выглядит почти буквально так же — это язык описания, а не свойство фреймворка:

// React Native, TypeScript: редьюсер без библиотеки — обычная чистая функция
type CartState =
  | { kind: "loading" }
  | { kind: "error"; canRetry: boolean }
  | { kind: "content"; items: CartItem[]; isRefreshing: boolean; staleSince?: number };

export function reduce(state: CartState, event: CartEvent): CartState {
  switch (event.type) {
    case "refresh":
      return state.kind === "content" ? { ...state, isRefreshing: true } : { kind: "loading" };
    case "loaded":
      return { kind: "content", items: event.items, isRefreshing: false };
    case "failed":
      // Оффлайн поверх показанных данных не выбрасывает пользователя на экран ошибки
      return state.kind === "content"
        ? { ...state, staleSince: Date.now() }
        : { kind: "error", canRetry: true };
  }
}

Структурная разница между MVVM и MVI хорошо видна на схеме классов: в MVVM мутаций столько же, сколько публичных методов; в MVI — ровно одна.

Цена MVI честная и заметная: больше типов и файлов на экран, больше аллокаций на каждое изменение (важно для длинных списков), и отладка требует привычки читать логи событий, а не стек вызовов. Выгода — тоже честная: контракт экрана виден целиком, редьюсер тестируется без диспетчеров и моков, а любую сессию можно воспроизвести, проиграв записанный список событий. На тяжёлых экранах (карта, чат, редактор) это окупается; на форме входа из двух полей — нет.

Одноразовые события: то, что не является состоянием

«Показать снекбар», «перейти на экран оплаты», «открыть системный диалог разрешения» — это не состояние, а событие. Разница становится критичной именно на мобильном: после смерти процесса система восстанавливает экран, и восстановленное состояние не должно повторно инициировать навигацию. Если «перейти на оплату» лежало в UiState, пользователь после возврата в приложение внезапно окажется на оплате.

// События живут отдельным каналом с гарантией «доставлено ровно один раз»,
// а подписка привязана к жизненному циклу, чтобы не потерять событие в фоне
private val _effects = Channel<CartEffect>(Channel.BUFFERED)
val effects: Flow<CartEffect> = _effects.receiveAsFlow()

Альтернатива — держать эффекты в состоянии, но с явным подтверждением потребления (banner: Banner? + onBannerShown()). Она надёжнее при смерти процесса (состояние сохраняется, событие — нет), но требует дисциплины: забытый вызов onShown даёт вечный снекбар. Практическое правило: навигация и системные диалоги — через канал; визуальные подсказки — через состояние с подтверждением.

Выбор паттерна: когда что окупается

Как читать таблицу ниже: это не рейтинг, а соответствие задачи и цены.

Паттерн Где встречается Сильная сторона Реальная цена
MVC «как получилось» старый Android/iOS-код нулевой порог входа контроллер на 2000 строк, тестов нет
MVP легаси-Android, где нет Compose явный контракт View ручной интерфейс на каждый экран, много кода
MVVM дефолт на обеих платформах поддержан фреймворками и IDE без дисциплины возвращает bool-суп и гонки
MVI / MVU сложные экраны, KMP, Flutter (bloc) воспроизводимость, чистый редьюсер бойлерплейт, аллокации, порог входа
TCA (swift-composable-architecture) iOS-команды с FP-культурой композиция фич, встроенное тестирование тяжёлая зависимость, свои правила, время компиляции
VIPER банковский iOS-легаси жёсткие границы, распараллеливание работы пять типов на экран, редко оправдано в 2026

Универсальный совет: выбирайте паттерн на уровне экрана, а не приложения. Список настроек может быть на голом ViewModel с двумя полями, а экран чата в том же приложении — на редьюсере. Требование «весь проект в одном паттерне» дороже, чем кажется, а выигрыш даёт только в очень больших командах.

Domain и Data: где живёт истина

Мобильный Repository отличается от серверного одним свойством: он не проксирует сеть, он владеет локальной копией. Экран подписывается на базу, сеть пишет в базу. Такой поток даёт мгновенный старт, работу без сети и корректное восстановление после смерти процесса бесплатно — сравните с наивным «запрос при открытии экрана», который в метро даёт спиннер и пустоту.

class CartRepositoryImpl @Inject constructor(
    private val dao: CartDao,
    private val api: CartApi,
    private val clock: Clock,                 // время — зависимость, иначе тесты нестабильны
    @IoDispatcher private val io: CoroutineDispatcher,
) : CartRepository {

    // Экран читает ТОЛЬКО отсюда: единственный источник истины
    override fun observe(id: String): Flow<CartSnapshot> =
        dao.observe(id).map { it.toSnapshot(now = clock.now()) }.flowOn(io)

    override suspend fun sync(id: String) = withContext(io) {
        try {
            dao.upsert(api.fetch(id).toEntity(syncedAt = clock.now()))
        } catch (e: IOException) {
            // Сеть недоступна — не ошибка экрана: помечаем данные устаревшими
            dao.markStale(id, at = clock.now())
        }
    }
}

Два неочевидных, но принципиальных момента. Первый: диспетчер выбирает слой данных, а не вызывающий. Правило «suspend-функция безопасна для вызова с главного потока» избавляет presentation от знания, что внутри — диск или сеть. Второй: Clock внедряется. Мобильные приложения полны логики «данные устарели / токен истёк / показать раз в сутки», и без подставляемого времени такие тесты становятся флаки. Про синхронизацию, миграции и разрешение конфликтов — следующая статья трека; про модели данных вообще — Тактические строительные блоки DDD.

Domain в этой схеме — место для правил, которые не принадлежат ни экрану, ни хранилищу:

// iOS: use case — обычная структура с внедрённой зависимостью-протоколом.
// Ни одного импорта UIKit: этот файл компилируется и на watchOS, и в тестовом таргете.
struct ApplyPromoCode {
    let cart: any CartRepository
    let rules: PromoRules

    func callAsFunction(code: String, to snapshot: CartSnapshot) throws -> CartSnapshot {
        guard rules.isApplicable(code, snapshot) else { throw PromoError.notApplicable }
        guard snapshot.total >= rules.minimumOrder(for: code) else { throw PromoError.tooSmall }
        return snapshot.applying(discount: rules.discount(for: code))
    }
}

Внедрение зависимостей

DI — это не библиотека, а принцип: объект не создаёт свои зависимости, а получает их. Библиотека лишь автоматизирует сборку графа. Зачем это на мобильном:

  • Тестируемость. Подставить fake-репозиторий и фиксированное время — единственный способ тестировать логику без устройства и сети.
  • Сборка графа при старте. Холодный старт бюджетируется в сотнях миллисекунд, и «кто когда создаётся» становится вопросом производительности.
  • Скоупы = времена жизни ОС. Это ключевое отличие от серверного DI, где скоупы — синглтон и запрос.

Скоупы DI как времена жизни объектов

Скоуп на мобильном отвечает на вопрос «что уничтожает этот объект»:

Скоуп Живёт Что туда кладут Чем опасен неправильный выбор
Приложение до смерти процесса БД, HTTP-клиент, аналитика, фичефлаги данные ушедшего пользователя утекают в следующую сессию
Сессия пользователя от логина до логаута токены, профиль, планировщик синхронизации при логауте остаются кэши чужого аккаунта — инцидент приватности
Граф навигации / флоу пока открыт сценарий черновик заказа, состояние мастера данные флоу живут вечно или теряются на первом же шаге назад
Экран (ViewModel) переживает поворот, умирает с экраном состояние экрана, задачи загрузки привязка к View → загрузка перезапускается при каждом повороте
// Android: Hilt — кодогенерация на этапе компиляции, ошибка графа = ошибка сборки
@Module @InstallIn(SingletonComponent::class)
object DataModule {
    @Provides @Singleton
    fun database(@ApplicationContext ctx: Context): AppDatabase =
        Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()

    @Provides @Singleton fun clock(): Clock = Clock.System
}

@Module @InstallIn(ViewModelComponent::class)   // живёт ровно столько, сколько ViewModel
abstract class CartModule {
    @Binds abstract fun repository(impl: CartRepositoryImpl): CartRepository
}
// iOS: конструкторная инъекция без библиотеки — самый предсказуемый вариант.
// Композиционный корень: единственное место, где известны конкретные типы.
@MainActor
final class AppContainer {
    let api = APIClient(session: .shared, tokens: KeychainTokenStore())
    private(set) lazy var cartRepository: any CartRepository =
        CartRepositoryImpl(api: api, store: database, clock: SystemClock())

    // Фабрика экрана: скоуп задаётся тем, кто владеет объектом
    func makeCartModel() -> CartModel { CartModel(cart: cartRepository) }
}
// Flutter: Riverpod — провайдеры с явными областями видимости.
// autoDispose = скоуп экрана: освобождается, когда экран покинут.
final cartRepositoryProvider = Provider<CartRepository>((ref) =>
    CartRepositoryImpl(ref.watch(daoProvider), ref.watch(apiProvider)));

final cartControllerProvider =
    StateNotifierProvider.autoDispose.family<CartController, CartState, String>(
  (ref, cartId) => CartController(ref.watch(cartRepositoryProvider), cartId),
);

Сравнение инструментов по осям, которые действительно влияют на проект:

Инструмент Когда ошибка обнаруживается Влияние на сборку Влияние на старт Кому подходит
Ручной DI / композиционный корень компиляция нулевое полный контроль любой размер на iOS, малый и средний на Android
Dagger / Hilt (Android) компиляция KSP заметно замедляет сборку ленивая инициализация из коробки средние и крупные Android-проекты
Koin (Kotlin) рантайм нулевое резолв при первом обращении небольшие проекты, KMP-прототипы
swift-dependencies (iOS) компиляция + рантайм умеренное нулевое iOS-команды на TCA и вокруг неё
get_it / Riverpod (Flutter) рантайм / компиляция нулевое зависит от eager-регистраций Flutter-приложения любого размера

Три мобильных правила поверх этой таблицы. Не инициализируйте граф жадно на старте: SDK аналитики, крэш-репортер и база должны создаваться лениво или в фоне после первого кадра — иначе они съедают бюджет холодного старта (см. Производительность мобильного). Синглтон приложения — не свалка: если объект знает про пользователя, его скоуп — сессия. Не тащите Context и UIApplication в домен через DI: это тот же обход слоёв, только оформленный аккуратно.

Модульность: границы, которые нельзя нарушить случайно

Слои проверяются компилятором только тогда, когда лежат в разных модулях. Плюс модули дают инкрементальную сборку — на Android это разница между 40 секундами и 6 минутами на изменение одной строки.

Ключевое ограничение схемы: фичи не зависят друг от друга. Если экрану корзины нужно открыть оформление заказа, он не импортирует :feature:checkout, а публикует намерение через контракт в :core:domain, который связывает :app. Иначе граф модулей за полгода превращается в клубок, и инкрементальная сборка перестаёт работать (общая теория — Связность и связанность). Порог целесообразности: до ~15 экранов и 3 разработчиков многомодульность чаще мешает, чем помогает; дальше выигрыш растёт нелинейно, а обратный переход стоит дорого.

Что архитектура обязана предусмотреть именно на мобильном

Смерть процесса и восстановление

Архитектурный вывод: в сохранённое состояние кладутся ключи, а не данные. SavedStateHandle на Android физически ограничен транзакцией Binder (порядка сотен килобайт), и попытка сохранить список товаров закончится TransactionTooLargeException в проде. Данные восстанавливаются из БД по идентификатору. Подробности уровней хранения — в статьях про iOS и Android.

Оффлайн, разрешения и фон как состояния домена

  • Оффлайн. Отсутствие сети — не исключение, а значение: staleSince, очередь неотправленных операций, признак «изменение локальное». Это меняет модель данных, а не только catch.
  • Разрешения. «Есть геолокация / нет / отклонена навсегда / выдана приблизительная» — состояние, влияющее на экран, а не булев флаг перед вызовом API. Архитектурное требование жёсткое: каждая функция с разрешением обязана иметь работающий сценарий без него.
  • Фон. Планировщики ОС (WorkManager, BGTaskScheduler) не дают гарантий времени. Значит, фоновая работа не может быть единственным путём получения данных: та же операция обязана выполняться при следующем открытии приложения. Подробнее — Фоновая работа и пуши.

Необратимость релиза

Серверный откат занимает минуты, мобильный не существует. Отсюда два обязательных архитектурных элемента: kill switch для каждой рискованной фичи (флаг с сервера, проверяемый в одном месте — на границе use case) и версионируемый контракт с бэкендом, при котором старый клиент продолжает работать. Практически это означает, что клиент никогда не падает на незнакомом значении enum и всегда имеет поведение по умолчанию. Про раскатку — Релиз и сторы.

Архитектура и выбор стека: где проходит граница

Честное сравнение с точки зрения именно архитектуры, а не синтаксиса:

Стек Что переиспользуется Где проходит шов Архитектурная цена
Два натива ничего, кроме API-контракта и решений нет шва, есть две реализации архитектура пишется дважды, дрейф между платформами неизбежен
Kotlin Multiplatform Domain + Data, иногда presentation между общим кодом и нативным UI нужны expect/actual для платформенного, дороже сборка и отладка
Flutter всё, включая UI между Dart и платформенными каналами плагины на границе — самая частая точка отказа
React Native всё, включая UI между JS и нативными модулями асинхронная граница усложняет отладку, состояние живёт в JS

Для KMP-архитектуры есть практическое следствие: шов проходит по UiState. Всё, что ниже, — общий Kotlin; всё, что выше, — SwiftUI и Compose, каждый со своими идиомами. Попытка обобщить ещё и presentation вместе с навигацией обычно даёт худший из двух UI и растущий слой адаптеров. Отдельный аргумент — найм: MVVM с ViewModel и StateFlow знает средний кандидат на обеих платформах, а собственный фреймворк или экзотический DI сокращают воронку найма сильнее, чем экономят время. Это архитектурное решение с кадровыми последствиями, и его стоит записывать в ADR наравне с техническими — см. Архитектурные решения и ADR.

Тестируемость: чем проверяется каждый слой

Архитектура ценна ровно настолько, насколько она сокращает стоимость проверки:

Что проверяем Как Скорость Где живёт
Правила домена, редьюсеры unit-тесты без платформы миллисекунды :core:domain, тестовый таргет SPM
ViewModel и потоки состояния тестовый диспетчер + fake-репозиторий десятки миллисекунд JVM / симулятор
Репозиторий и миграции in-memory БД + fake HTTP сотни миллисекунд :core:data
Экран целиком UI-тест на устройстве, скриншот-тест секунды инструментальные тесты

Признак хорошей архитектуры — отношение первых трёх строк к последней. Если единственный способ проверить расчёт скидки — запустить эмулятор, слои склеены. Подробно — Тестирование мобильного и Юнит-тестирование.

Типичные ошибки

  • Context, Activity или UIViewController внутри ViewModel. Классическая утечка и мгновенная потеря тестируемости. Ресурсы передаются как TextRes/LocalizedStringKey, а не как отформатированные строки.
  • Хранение состояния в View. На повороте экрана или пересоздании виджета всё теряется, загрузка стартует заново — и с ней сетевой запрос, который стоит трафика и батареи.
  • Сеть как источник истины. Экран подписан на API, а не на БД. Итог: спиннер в метро, дубли запросов при возврате на экран, невозможность оффлайна.
  • Bool-суп вместо типа-суммы. Обсуждён выше; проявляется как «иногда видны и спиннер, и ошибка».
  • Навигация внутри UiState. После восстановления процесса пользователя выбрасывает на чужой экран.
  • Синглтон-скоуп для пользовательских данных. После логаута следующий пользователь видит остатки предыдущего — это уже инцидент приватности, а не баг.
  • Копирование серверной Clean Architecture целиком. Пять модулей и три маппинга на экран списка новостей — потраченные человеко-месяцы без выигрыша.
  • Единый паттерн, навязанный всем экранам. Форма логина не нуждается в редьюсере; карта нуждается.
  • Жадная инициализация DI-графа. Пять SDK в Application.onCreate() — минус полсекунды к холодному старту у каждого пользователя при каждом запуске.

Как выбирать: короткий чек-лист

  1. Сколько экранов и людей? До 15 экранов и 3 разработчиков — один модуль, MVVM, ручной DI или Hilt. Дальше — модули по фичам.
  2. Есть ли правила, не принадлежащие экрану? Нет — Domain не нужен. Да — выделяйте, особенно если платформы две.
  3. Нужен ли оффлайн? Если да, вопрос «источник истины» решается в первую неделю проекта: локальная БД, а не сеть. Переделка потом стоит месяцы.
  4. Есть ли экраны с гонками и сложным состоянием? Для них — редьюсер, для остальных — ViewModel.
  5. Планируется ли общий код между iOS и Android? Если да, шов по UiState и чистый Domain без платформенных импортов закладываются сразу.
  6. Что вы сможете выключить без релиза? Каждая рискованная фича — за флагом, проверяемым в одном месте.

Мини-итог

  • Архитектура мобильного приложения отвечает на три вопроса: кто владеет состоянием, кто знает про платформу, что переживает что. Всё остальное — техника.
  • Слои — шкала знания о внешнем мире; компиляционная зависимость не идёт вверх, и это проверяется структурой модулей, а не ревью.
  • Однонаправленный поток данных — общий знаменатель SwiftUI, Compose, Flutter и React Native: состояние вниз, события вверх, единственная точка мутации.
  • Состояние экрана моделируется типом-суммой, а не набором булевых флагов; загрузка «с нуля» и «поверх данных» — разные состояния.
  • MVVM — разумный дефолт обеих платформ; MVI окупается на сложных экранах и в общем коде; выбор делается на уровне экрана, а не проекта.
  • Навигация и системные диалоги — одноразовые эффекты, им не место в состоянии: восстановление после смерти процесса иначе выбросит пользователя не туда.
  • Репозиторий владеет локальной копией данных; экран читает из БД, сеть её наполняет — так оффлайн и восстановление получаются бесплатно.
  • DI на мобильном — прежде всего про скоупы как времена жизни объектов ОС и про бюджет холодного старта, а не про красоту графа.
  • Оффлайн, разрешения и ограничения фона — состояния домена, а не ветки обработки ошибок; необратимость релиза требует kill switch на уровне архитектуры.

Источники

Что дальше

Мы договорились, что источник истины мобильного приложения — локальная копия данных, а сеть её только наполняет. Осталось разобрать, как эта копия устроена: какие локальные базы бывают и чем они отличаются, как проходит миграция схемы на устройствах, которые нельзя остановить, как строится синхронизация и что делать, когда пользователь изменил одну и ту же запись на двух устройствах в оффлайне.

Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов