Архитектура мобильного приложения: слои, состояние, 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, события — вверх к владельцу состояния. Двусторонний биндинг и «контроллер меняет вью напрямую» из этой модели выпадают.
получает staleSince, экран остаётся рабочим
Три свойства этой схемы стоят того, чтобы их защищать. Единственная точка мутации: состояние меняется только внутри владельца, и ответ на «почему на экране это» ищется в одном файле, а не среди всех, кто держит ссылку на вью. Неизменяемость состояния: новый кадр — новый объект, никаких «список поменялся под руками адаптера» (про цену подхода — Иммутабельность). Глупый 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, где скоупы — синглтон и запрос.
Скоуп на мобильном отвечает на вопрос «что уничтожает этот объект»:
| Скоуп | Живёт | Что туда кладут | Чем опасен неправильный выбор |
|---|---|---|---|
| Приложение | до смерти процесса | БД, 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 минутами на изменение одной строки.
композиционный корень, навигация, DI-граф"] subgraph FEATURES["Фичи: не знают друг о друге"] F1[":feature:cart"] F2[":feature:checkout"] F3[":feature:profile"] end subgraph CORE["Ядро: переиспользуемое, без бизнес-правил"] C1[":core:ui
дизайн-система, темы"] C2[":core:domain
сущности, use case, порты"] C3[":core:data
репозитории, БД, сеть"] C4[":core:common
Clock, логгер, Result"] end APP --> F1 & F2 & F3 F1 & F2 & F3 --> C1 & C2 C3 -->|реализует порты| C2 APP -->|связывает реализации| C3 C1 & C2 & C3 --> C4
Ключевое ограничение схемы: фичи не зависят друг от друга. Если экрану корзины нужно открыть оформление заказа, он не импортирует :feature:checkout, а публикует намерение через контракт в :core:domain, который связывает :app. Иначе граф модулей за полгода превращается в клубок, и инкрементальная сборка перестаёт работать (общая теория — Связность и связанность). Порог целесообразности: до ~15 экранов и 3 разработчиков многомодульность чаще мешает, чем помогает; дальше выигрыш растёт нелинейно, а обратный переход стоит дорого.
Что архитектура обязана предусмотреть именно на мобильном
Смерть процесса и восстановление
показана плашка «данные от 10:42»
Архитектурный вывод: в сохранённое состояние кладутся ключи, а не данные. 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()— минус полсекунды к холодному старту у каждого пользователя при каждом запуске.
Как выбирать: короткий чек-лист
- Сколько экранов и людей? До 15 экранов и 3 разработчиков — один модуль, MVVM, ручной DI или Hilt. Дальше — модули по фичам.
- Есть ли правила, не принадлежащие экрану? Нет —
Domainне нужен. Да — выделяйте, особенно если платформы две. - Нужен ли оффлайн? Если да, вопрос «источник истины» решается в первую неделю проекта: локальная БД, а не сеть. Переделка потом стоит месяцы.
- Есть ли экраны с гонками и сложным состоянием? Для них — редьюсер, для остальных —
ViewModel. - Планируется ли общий код между iOS и Android? Если да, шов по
UiStateи чистыйDomainбез платформенных импортов закладываются сразу. - Что вы сможете выключить без релиза? Каждая рискованная фича — за флагом, проверяемым в одном месте.
Мини-итог
- Архитектура мобильного приложения отвечает на три вопроса: кто владеет состоянием, кто знает про платформу, что переживает что. Всё остальное — техника.
- Слои — шкала знания о внешнем мире; компиляционная зависимость не идёт вверх, и это проверяется структурой модулей, а не ревью.
- Однонаправленный поток данных — общий знаменатель SwiftUI, Compose, Flutter и React Native: состояние вниз, события вверх, единственная точка мутации.
- Состояние экрана моделируется типом-суммой, а не набором булевых флагов; загрузка «с нуля» и «поверх данных» — разные состояния.
- MVVM — разумный дефолт обеих платформ; MVI окупается на сложных экранах и в общем коде; выбор делается на уровне экрана, а не проекта.
- Навигация и системные диалоги — одноразовые эффекты, им не место в состоянии: восстановление после смерти процесса иначе выбросит пользователя не туда.
- Репозиторий владеет локальной копией данных; экран читает из БД, сеть её наполняет — так оффлайн и восстановление получаются бесплатно.
- DI на мобильном — прежде всего про скоупы как времена жизни объектов ОС и про бюджет холодного старта, а не про красоту графа.
- Оффлайн, разрешения и ограничения фона — состояния домена, а не ветки обработки ошибок; необратимость релиза требует kill switch на уровне архитектуры.
Источники
- Guide to app architecture — официальные рекомендации Google: слои, UDF, UI State.
- Android UI state production — как собирать состояние экрана из потоков.
- Hilt documentation и Dagger dev guide — компонентные скоупы и генерация графа.
- Now in Android — эталонное многомодульное приложение Google с оффлайн-first слоем данных.
- Managing model data in your app и Observation — модель наблюдаемого состояния в SwiftUI.
- The Composable Architecture — MVI/MVU на iOS с встроенной тестируемостью.
- Flutter: state management approaches и bloc library — редьюсеры во Flutter.
- Kotlin Multiplatform: sharing architecture — где проходит шов общего кода.
- Robert C. Martin. Clean Architecture — источник правила зависимостей (и повод помнить о цене его буквального применения).
- Vaughn Vernon. Implementing Domain-Driven Design — про то, какие правила заслуживают доменного слоя, а какие нет.
Что дальше
Мы договорились, что источник истины мобильного приложения — локальная копия данных, а сеть её только наполняет. Осталось разобрать, как эта копия устроена: какие локальные базы бывают и чем они отличаются, как проходит миграция схемы на устройствах, которые нельзя остановить, как строится синхронизация и что делать, когда пользователь изменил одну и ту же запись на двух устройствах в оффлайне.
Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов