Repository является единственным классом, у которого остальное приложение спрашивает данные определённого вида: он прячет, пришли ли они из сети, Room, DataStore или памяти, и возвращает доменные модели.
Единый источник правды: все экраны читают из одного места, поэтому два экрана не покажут две разные версии одного пользователя.
Offline-first получается сам собой: сразу отдать закэшированные строки и обновить их из сети в фоне.
Тестируемость: ViewModel принимает интерфейс репозитория, а тест подсовывает фейк.
Одно место для политики: кэширование, ретраи и маппинг DTO в доменные модели живут здесь, а не в каждом экране.
Правило, которое держит паттерн честным: наружу выходят доменные модели, а не DTO из Retrofit и не entity из Room, поэтому переименование поля на бэкенде останавливается на маппере.
AndroidMiddle
Архитектура Android
MVVM, Clean Architecture, Repository, ViewModel, UseCase, UDF
Закрыто вопросов
0 / 22
- 0Повторить
- 6Часто спрашивают
Вопросы
Состояние течёт вниз от ViewModel к UI, а события идут вверх от UI к ViewModel; UI никогда не меняет состояние сам, он только просит об этом ViewModel.
**Один неизменяемый UiState**: data-класс, который ViewModel отдаёт как StateFlow<UiState>; экран собирает его через collectAsStateWithLifecycle().
События идут вверх вызовами: onAction(CartAction.Remove(id)) или обычные методы; писать в _state может только ViewModel.
Одно место для поиска: если экран показывает что-то не то, проверять нужно ровно одного писателя, а каждое изменение можно залогировать.
Легко тестировать и делать превью: UI становится функцией от состояния, его рисуют, передав объект состояния, без всякой ViewModel.
Так же устроен и сам Compose: одно и то же состояние всегда рисует один и тот же UI.
Стоит назвать паттерны, которые сидят внутри SDK (Proxy, Chain of Responsibility, Composite, Decorator и Facade), и к каждому класс, с которым вы работали: retrofit.create, интерсепторы OkHttp, ViewGroup, ContextWrapper, Glide.with(...).
Proxy: retrofit.create(Api::class.java) возвращает динамический прокси, который превращает каждый вызов метода интерфейса в HTTP-запрос.
Chain of Responsibility: Interceptor в OkHttp, каждый вызывает chain.proceed(request); touch-события, спускающиеся по дереву ViewGroup.
Composite: ViewGroup сам является View и содержит View, поэтому экран представляет собой дерево, с которым работают единообразно.
Decorator: ContextWrapper оборачивает Context и пробрасывает вызовы; Activity и Application наследуются от ContextWrapper.
Facade: Glide.with(context).load(url).into(view) прячет кэширование, декодирование и потоки за одним вызовом.
Плюс привычные Builder (NotificationCompat.Builder), Factory (ViewModelProvider.Factory), Observer (LiveData, Flow) и Adapter (RecyclerView.Adapter; CallAdapter в Retrofit адаптирует Call<T> к Flow или типам Rx, а Converter превращает тело ответа в ваши типы).
Lifecycle-aware компонент наблюдает за LifecycleOwner и сам запускается и останавливается, так что Activity больше не нужно дёргать его из onStart и onStop; для приложения целиком наблюдают за ProcessLifecycleOwner.
**Lifecycle и LifecycleOwner**: владелец отдаёт своё текущее состояние (CREATED, STARTED, RESUMED…). Владельцами выступают ComponentActivity, Fragment (и его viewLifecycleOwner) и каждый NavBackStackEntry.
**DefaultLifecycleObserver**: переопределяете onStart, onStop и остальные, регистрируете через lifecycle.addObserver(...). Новый наблюдатель сразу догоняет текущее состояние владельца.
Что на этом построено: LiveData доставляет значения только наблюдателям в STARTED; repeatOnLifecycle и collectAsStateWithLifecycle() прекращают сбор ниже STARTED.
**ProcessLifecycleOwner** (артефакт lifecycle-process) даёт один жизненный цикл на всё приложение: ON_START, когда стартует первая Activity, и ON_STOP после остановки последней. Остановка приходит с задержкой около 700 мс, чтобы поворот экрана не выглядел как уход в фон, а ON_DESTROY не приходит никогда.
Различие в том, как UI узнаёт об изменении: в MVC и MVP изменения проталкиваются во вью вызовами методов, MVVM выставляет наблюдаемое состояние, а MVI сужает его до одного неизменяемого объекта, который меняется только через объявленные действия.
MVC: на Android Activity стала одновременно и вью, и контроллером, отсюда «массивная Activity», логику которой нельзя было протестировать без устройства. Сейчас это в основном история.
MVP: вью является интерфейсом (showLoading(), showItems(list)), и презентер дёргает его методы. Тестируется, но attach и detach требуют ручной работы с жизненным циклом, сам по себе презентер поворот не переживает, а состояние разорвано между полями презентера и тем, что вью сейчас показывает.
MVVM: ViewModel выставляет наблюдаемое состояние и не держит ссылку на вью; то, что Jetpack ViewModel переживает поворот, составляет половину причины, почему подход победил. Слабое место: ничто не заставляет держать одно состояние, поэтому типичный баг выглядит как пять отдельных потоков, которые противоречат друг другу.
MVI: MVVM плюс два правила: одно неизменяемое состояние на экран и каждое изменение описано действием, которое применяет редьюсер. Легко тестировать и логировать, невозможную комбинацию нарисовать трудно; за это платят бойлерплейтом и копированием состояния при каждом изменении.
Приложение на Compose приходит к MVVM по названию и к MVI по форме: один StateFlow<UiState> из неизменяемого data-класса, sealed-тип действий, передаваемый в onAction, и обновления через _state.update { }. Библиотека для этого не нужна; Orbit и подобные подключают по желанию.
Код делится на слои presentation, domain и data, а зависимости направлены только внутрь: UI зависит от domain, data-слой реализует интерфейсы, объявленные в domain, а сам domain ничего не импортирует из Android, Retrofit или Room.
Слои
Presentation: Compose UI и ViewModel. Рисует состояние, передаёт события, бизнес-правил не держит.
Domain: сущности, интерфейсы репозиториев и UseCase на чистом Kotlin. Именно здесь живут UseCase. В гайде Google по архитектуре этот слой назван необязательным; в Clean Architecture он центр.
Data: реализации репозиториев, источники данных на Retrofit и Room, мапперы из DTO и entity в доменные модели.
Правило зависимостей
Данные текут в обе стороны, но зависимости в исходном коде направлены внутрь. Domain объявляет interface OrderRepository, data-слой его реализует, а DI (Hilt) связывает их в :app.
Вынесите domain в отдельный Gradle-модуль на чистом Kotlin, и правило станет ошибкой компиляции, а не замечанием на ревью: android.* просто нет в его classpath, а тесты идут на JVM.
Когда окупается
Большие долгоживущие приложения с несколькими командами и настоящими бизнес-правилами.
В маленьком приложении это церемония. Рекомендация Google (UI-слой и data-слой, а UseCase только там, где ViewModel начинает делать реальную работу) следует тому же принципу меньшим числом классов, и сказать это вслух будет сильным ответом.
При смене конфигурации Activity пересоздаётся, но её ViewModelStore передаётся новому экземпляру, поэтому возвращается та же ViewModel; очищается она, только когда владелец завершается насовсем, а смерть процесса не переживает ничего из её содержимого.
Почему переживает поворот
ComponentActivity, Fragment и каждый NavBackStackEntry реализуют ViewModelStoreOwner. by viewModels() просит у хранилища владельца экземпляр по ключу и создаёт новый, только если его нет.
При повороте хранилище переносится как non-configuration instance. На ON_DESTROY Activity очищает хранилище, только если isChangingConfigurations равно false.
Когда не переживает
finish(), кнопка «Назад», снятая с бэкстека запись навигации: вызывается onCleared(), и viewModelScope отменяется.
Смерть процесса: система убивает приложение в фоне, и вся память пропадает. Небольшое состояние, которое должно вернуться (id, текст поиска, выбранная вкладка), кладут в SavedStateHandle, который сохраняется в Bundle состояния экземпляра; настоящим данным место в Room или DataStore.
Параметры конструктора
ViewModel создаёт фреймворк, поэтому конструктор вызывает ViewModelProvider.Factory. Вручную: viewModelFactory { initializer { ... } }, где createSavedStateHandle() даёт handle.
С Hilt: @HiltViewModel и @Inject constructor; SavedStateHandle и аргументы навигации приходят автоматически.
Никогда не передавайте внутрь View, Activity или Fragment: ViewModel живёт дольше любого из них.
Она удерживает Activity после поворота, падает, показывая Toast из фонового потока, продолжает работать после ухода с экрана и позволяет UI писать в своё состояние; ViewModel не должна зависеть ни от чего с временем жизни UI, а работу с UI стоит оставить экрану.
**Держит Activity: ViewModel живёт дольше любого экземпляра Activity, который видит. После поворота она по-прежнему ссылается на уничтоженный экземпляр, а через него и на всю иерархию вью: утечку LeakCanary покажет на первом же повороте.
GlobalScope**: не привязан к ViewModel, поэтому работа продолжается после onCleared(). viewModelScope отменяется, когда ViewModel очищается.
Toast не в главном потоке: GlobalScope.launch выполняется на Dispatchers.Default, и Toast.makeText там бросает «Can't toast on a thread that has not called Looper.prepare()». Показ тоста к тому же является решением UI: отправьте событие, а тост пусть покажет экран.
**Публичный MutableStateFlow**: любой вызывающий может записать состояние. Наружу отдают asStateFlow(), а меняют через _state.update { }, который ещё и атомарен, в отличие от state.value = state.value.copy(...).
Строка собрана во ViewModel: текст застывает в той локали, что была активна в тот момент. Храните @StringRes-id или причину и превращайте в текст в UI.
AndroidViewModel даёт Application, который не может удержать Activity, но ресурсы всё равно берутся из конфигурации приложения, а в тесты приходится тащить Robolectric. Внедряйте то, что действительно нужно (репозиторий, часы), а не какой-либо Context.
Оба хранят текущее значение, на которое подписан UI. LiveData сам учитывает жизненный цикл, но привязан к Android и главному потоку; StateFlow написан на чистом Kotlin, требует начальное значение, поддерживает все операторы Flow, а жизненный цикл получает через repeatOnLifecycle или collectAsStateWithLifecycle().
Жизненный цикл: observe(owner) доставляет значения, только пока владелец в STARTED, и отписывается на DESTROYED. StateFlow ничего не знает о жизненном цикле, поэтому его собирают внутри repeatOnLifecycle(STARTED); обычный lifecycleScope.launch { collect } продолжает обновлять остановленный экран.
Значение и равенство: у StateFlow<T> значение есть всегда, а значение, равное текущему, отбрасывается; LiveData.value имеет тип T? и равен null, пока ничего не записано (если не передать начальное значение), а каждый setValue доставляется, если не добавить distinctUntilChanged().
Операторы: у LiveData есть map и switchMap, которые выполняются на главном потоке; у Flow есть combine, flatMapLatest, debounce, catch и stateIn, а Room и DataStore сразу возвращают Flow.
Новые приложения выбирают StateFlow, потому что один тип несёт данные от Room до UI без зависимости от Android, и такой тип работает в модулях на чистом Kotlin и в KMP. LiveData не объявлен deprecated, и View-кодовую базу, где он повсюду, переписывать не нужно: asLiveData() и asFlow() связывают два мира на время миграции.
Наблюдатель получит 0, затем 3, затем 2: setValue доставляет значение сразу, а postValue лишь запоминает последнее и выполняет одну задачу в главном потоке позже, поэтому 1 теряется, а устаревшее 2 перезаписывает 3.
**postValue**: безопасен из любого потока, но склеивает значения: доставляется только последнее отложенное, одной задачей в главном потоке, поэтому промежуточные теряются, а результат может прийти после более позднего setValue.
**setValue** (.value =): только главный поток. Из фонового он бросает IllegalStateException: Cannot invoke setValue on a background thread. Каждый вызов доставляется сразу, даже если значение не изменилось.
Учитывает жизненный цикл: наблюдатели получают значения только в STARTED или RESUMED, а новый наблюдатель сразу получает последнее значение.
У StateFlow нет разделения на postValue и setValue: update { } безопасен из любого потока. LiveData не устарела.
UseCase описывает одну бизнес-операцию, оформленную как единственный вызываемый класс, и он оправдан, когда логика разделяется между экранами или слишком сложна, чтобы ею владела ViewModel.
Форма намеренно скучная: одна публичная operator fun invoke(...), имя на языке продукта (PlaceOrder, ObserveUnreadCount), интерфейсы репозиториев в конструкторе, на выходе доменные модели или Flow, никаких импортов из Android и никакого хранимого состояния, поэтому юнит-тесты идут на JVM за миллисекунды.
Что туда входит
Оркестрация нескольких репозиториев: свести пользователя, его подписку и фичефлаги в один ответ.
Бизнес-правило, которым не владеет ни один репозиторий: можно ли оформить заказ, какая скидка.
Преобразование, которое иначе продублировали бы две ViewModel.
Что не входит
Транзит. Класс, всё тело которого состоит из repo.getUser(id), стоит файла, DI-биндинга и теста и не даёт ничего; зовите репозиторий из ViewModel.
Задачи UI: форматирование, сборка строк, решение о видимости кнопки.
Всё, чему нужен Context.
Смысл именно в одной операции на класс: UserUseCases с десятью методами оказывается репозиторием под другим именем. Google считает domain-слой необязательным, и ответ «добавляем его, когда ViewModel начинают делить логику или обрастать ею» вполне защитим.
Sealed-иерархия не даёт перепутать взаимоисключающие фазы, а один data-класс закрывает куда более частый случай, когда контент, обновление и ошибка истинны одновременно.
Sealed-иерархия
Выигрывает, когда фазы действительно исключают друг друга: первая загрузка показывает спиннер, затем либо контент, либо полноэкранную ошибку. Компилятор требует исчерпывающего when, и нарисовать спиннер поверх ошибки невозможно.
Ломается, как только что-то происходит при уже показанном контенте: pull-to-refresh, баннер ошибки над устаревшим списком, подгрузка страницы. Success обрастает полями isRefreshing и error, а каждый переход выбрасывает прежние данные, если их не переносить вручную, отсюда мигающий пустой список при обновлении.
Один data-класс
Выигрывает на экранах, где контент остаётся на месте, пока вокруг что-то происходит, а таких большинство. _state.update { it.copy(...) } занимает одну строку, и ничего не теряется случайно.
Риск: невозможные комбинации, которые система типов не запретит, например одновременно isLoading и error. Флагов должно быть мало, а всё, что можно вычислить, вычисляйте, а не храните.
Что использовать на практике
Совместить: один data-класс на экран и sealed-тип только для той части, где действительно есть фазы, например для футера пагинации. Держите его неизменяемым и без Android-типов и отдавайте один StateFlow, а не пять, которые могут противоречить друг другу.
SharedFlow теряет событие, отправленное, пока экран ничего не собирает, флаг в состоянии срабатывает повторно после поворота, если UI его не сбросил, а Channel хранит событие, пока его не заберёт один коллектор. Рекомендация Google идёт дальше: описать результат как состояние, а UI пусть сообщит, что обработал его.
**MutableSharedFlow() без replay**: между ON_STOP и ON_START или во время поворота поток никто не собирает, и событие, отправленное в этот момент, пропадает. replay = 1 убирает потерю, но заново отдаёт последнее событие пересозданному экрану.
**Поле в StateFlow<UiState>**, например navigateTo: Route?: никогда не теряется, но его видит каждый новый коллектор, и после поворота навигация срабатывает снова, если UI не сбросил поле.
**Channel(Channel.BUFFERED) с receiveAsFlow()**: копит события, пока их никто не собирает, и отдаёт каждое одному коллектору, поэтому переживает поворот и уход в фон. Собирайте его на Dispatchers.Main.immediate, иначе коллектор, отменённый между получением и обработкой, теряет событие. Подходит для действий по принципу «отправил и забыл», например для перехода после сохранения.
Состояние с подтверждением, из гайда Google про UI events: ViewModel записывает message, UI показывает сообщение и вызывает vm.messageShown(). Ничего не теряется и не повторяется ценой колбэка на каждое событие; подходит там, где пропущенное событие означает баг, например для отказа в оплате.
Часто «событие» на самом деле является состоянием: то, что пользователь вошёл, остаётся фактом, и экран переходит дальше, когда state.isSignedIn становится true. SingleLiveEvent был каналом с одним получателем во времена LiveData.
refresh() записывает обратно снимок состояния, сделанный до того, как repo.search приостановился, поэтому всё, что изменилось, пока запрос был в пути (например, фильтр, выбранный во время загрузки), затирается старым значением, когда приходят результаты.
Почему так происходит: viewModelScope работает на главном потоке, так что это не гонка данных, но search приостанавливается и освобождает поток. В этот промежуток выполняется onFilterChange, а затем current.copy(...) записывает устаревший filter обратно, и получается потерянное обновление (lost update).
Исправление: до вызова прочитать только то, что нужно запросу, а результат применить после него через _state.update { it.copy(results = results) }, чтобы он слился с актуальным состоянием, а не со старой копией.
**update к тому же атомарен**: он повторяет compare-and-set, поэтому остаётся корректным, когда писатели на Dispatchers.Default меняют состояние одновременно; _state.value = _state.value.copy(...) не атомарен.
Как поймать: тест с фейковым репозиторием, который приостанавливается на CompletableDeferred: вызвать refresh(), сменить фильтр, завершить deferred и проверить, что фильтр сохранился. На ревью стоит отмечать любое чтение _state.value, которое держится через suspend-вызов.
Второй класс оправдан только тогда, когда иначе два слоя пришлось бы менять одновременно; любой другой маппер означает сопровождение, за которое вы платите просто так.
Три класса существуют потому, что у них три разных хозяина с разными требованиями. Форму UserDto задаёт тот, кто держит API: nullable-поля, snake_case, даты строками. Форму UserEntity задаёт схема: индекс, внешний ключ, колонка syncedAt, до которой никому за пределами data-слоя нет дела. Форму доменного User задают бизнес-правила: не-null, Instant, одно displayName там, где в API было два разрозненных поля.
Когда лишний класс окупается
API не ваш, и переименование поля должно ломать один маппер и больше ничего.
В базе есть колонки, которых UI не показывает, либо UI нужна величина, которой в базе нет.
Граница совпадает с границей Gradle-модуля: :core:network не должен протаскивать @Serializable-типы в :feature:profile.
Доменный тип может быть строже. Распарсите nullable-DTO один раз на краю, и дальше по цепочке никто больше не пишет проверок на null.
Когда хватает одного класса
Свой бэкенд, локального кэша нет, DTO и домен совпадают поле в поле: маппер превращается в церемонию переименования.
UI-модель стоит писать, только если экран что-то выводит: отформатированную сумму, флаг isEditable из прав и статуса. Копирование доменного класса поле в поле выводом не считается.
Ловушка кроется в противоположной крайности: один класс с @Serializable и @Entity сразу, отданный прямо в composable. Тогда переименование поля на бэкенде молча правит схему базы, а решение про первичный ключ протекает в JSON. Правило: разделяйте, когда формы уже разошлись хотя бы раз, а не на будущее, и держите маппинг каждой границы в одном файле, чтобы расхождение было видно.
Код становится нетестируемым, когда сам тянется за зависимостями (синглтонами, Context, настоящими часами, захардкоженным диспетчером), а не получает их; внедрение через конструктор плюс несколько швов решают почти всё.
Внедрение через конструктор: никаких getInstance() и service locator внутри бизнес-кода; тест просто вызывает конструктор.
Обернуть недетерминированные края: время (Clock или () -> Instant), случайные id, локаль, состояние сети; подойдёт интерфейс или функциональный тип.
Никаких Android-типов во ViewModel и UseCase: тогда их тесты идут на JVM за миллисекунды, а не на эмуляторе или в Robolectric.
Внедрять диспетчеры: захардкоженный Dispatchers.IO уходит из-под тестового планировщика, а viewModelScope в JVM-тесте требует Dispatchers.setMain(testDispatcher).
Фейки вместо моков для своих интерфейсов: фейк продолжает работать, когда меняется реализация, а мок фиксирует сегодняшние точные вызовы.
Stateless-composable с контентом: CartContent(state, onAction) тестируют через createComposeRule(), передав объект состояния, вообще без ViewModel.
Итоговый набор: много JVM-тестов для ViewModel, UseCase и мапперов, меньше тестов на Compose или Robolectric и несколько инструментальных end-to-end сценариев.
Моё последнее приложение написано на Compose и Hilt с модулями :app, :feature:* и :core:*, где ViewModel экрана отдаёт один StateFlow поверх репозитория на Room; затем назовите одно решение, которое защитили бы, и одно, которое изменили бы.
Что рассказать
Контекст одной фразой: что делает приложение, размер команды, масштаб, работает ли офлайн. Это оправдывает всё остальное.
Слои и модули: например, Compose и ViewModel, UseCase только там, где логика общая, репозитории поверх Retrofit и Room; :app, :feature:*, :core:*.
Одна фича целиком: «OrdersScreen собирает StateFlow<OrdersUiState>, ViewModel наблюдает OrderRepository.observe() из Room, обновление записывает ответ API в Room, WorkManager отправляет отложенные правки».
Сквозные решения: DI, навигация, обработка ошибок, как это тестируется.
Что делает ответ сеньорским
Компромисс с причиной: «domain-слой оставили только для оформления заказа, потому что в остальных местах UseCase были транзитом».
Что пошло не так и что вы после этого изменили.
Честность в авторстве: говорите «мы» о решениях команды, а «я» о том, что сделали сами.
Не перечисляйте библиотеки, не говорите «Clean Architecture» без объяснения зачем и не рисуйте больше слоёв, чем было в приложении. Каждая названная часть вызывает уточняющий вопрос, поэтому называйте только то, что можете объяснить.
Источники сводят в один объект состояния, который рисует экран, а результат публикуют через stateIn, чтобы поворот экрана не перезапускал все запросы снизу.
Строительные блоки
combine(a, b, c) { ... } собирает состояние из последних значений каждого источника. Он не эмитит ничего, пока каждый источник не выдал хотя бы одно значение, поэтому источнику, который может молчать, нужен дефолт или эмиссия в onStart; экран, застрявший на начальном состоянии, почти всегда об этом.
flatMapLatest, когда один поток решает, какой другой поток наблюдать: выбранный id, определяющий запрос деталей. Он отменяет предыдущий внутренний поток, а это ровно то, что нужно.
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial) превращает холодную цепочку в горячее общее состояние: одна подписка вверх независимо от числа коллекторов и небольшая отсрочка после ухода последнего.
Именно про этот таймаут и спрашивают: WhileSubscribed(5_000) удерживает источник при смене конфигурации, потому что UI переподписывается через кадр-другой, но останавливает его, когда пользователь действительно ушёл.
Подводные камни, выдающие опыт
Лямбда combine выполняется на каждую эмиссию любого источника, поэтому она должна быть дешёвой чистой проекцией. Suspend-работа уходит выше по цепочке.
Исключение в любом источнике завершает корутину stateIn и, если обработчика нет, роняет приложение. Оборачивайте каждый источник в catch и превращайте ошибку в поле состояния.
StateFlow отсекает одинаковые значения по equality, поэтому классу состояния нужен корректный equals: у data-класса из неизменяемых полей он есть, изменяемый список внутри его не спасает.
Стоит упомянуть альтернативу: коллекторы, запущенные в init, каждый из которых зовёт _state.update { } у одного MutableStateFlow. Её проще отлаживать и в ней проще уронить только одну секцию; combine декларативнее. Обе позиции защитимы, а вот выставлять в UI пять отдельных потоков нельзя.
state объявлен кастомным геттером, поэтому каждое чтение строит новую цепочку stateIn, которая начинается с isLoading = true и заново запускает запрос; экран читает его при каждой рекомпозиции, так что каждый пришедший список возвращает свежий flow в состоянии загрузки.
Почему это зацикливается: collectAsStateWithLifecycle() привязан к экземпляру flow. Новый экземпляр перезапускает сбор и показывает спиннер, приходит список, экран рекомпозируется, снова читает vm.state и получает ещё один новый flow.
Работа к тому же копится: каждая брошенная цепочка держит свой upstream-запрос живым ещё 5 секунд, а её корутину шаринга держит в viewModelScope до onCleared(); с SharingStarted.Eagerly запросы не остановились бы никогда.
Исправление: = вместо get() =: цепочка строится один раз вместе с ViewModel, все читатели получают один и тот же StateFlow, а при повороте экрана подписка восстанавливается внутри 5-секундного окна, без нового запроса.
Та же ловушка в других формах: fun state() = ... .stateIn(...) или shareIn внутри функции, которую вызывает UI. Горячий flow должен быть свойством, созданным один раз.
Как поймать: юнит-тест, проверяющий vm.state === vm.state, или фейковый репозиторий, который считает подписки. На ревью get() =, за которым идёт stateIn или shareIn, служит тревожным сигналом.
Ошибки нужно переводить на той же границе, что и данные: репозиторий превращает транспортные сбои в доменный тип, который UI разбирает исчерпывающе, а то, чем он их ловит, обязано пропускать CancellationException наружу нетронутым.
Маппинг на границе
HttpException, IOException или SerializationException, доехавшие до composable, означают, что UI теперь зависит от Retrofit. Ломается сразу две вещи: экран умеет показать только e.message (отсюда код статуса перед глазами пользователя), и каждый экран изобретает свой catch. Именно в репозитории известно, что 401 означает «разлогинили», 422 означает «форма неверна», а IOException означает «нет сети». Верните sealed interface, и ViewModel разберёт его через when без ветки else.
Ловушка runCatching
И runCatching, и голый catch (e: Exception) проглатывают CancellationException, потому что в корутинах отмена и есть исключение. Если её проглотить, корутина продолжает работать после отмены своего scope, а сама отмена попадает в ветку ошибки, поэтому пользователь, который только что ушёл с экрана, получает снекбар с ошибкой. Пробрасывайте первым делом через catch (e: CancellationException) { throw e } или ловите узко (IOException, HttpException).
И ещё одно правило границы: ожидаемые исходы не моделируются исключениями. «Неверный пароль» репозиторий возвращает как значение, а не бросает: исключения для транспорта, результаты для домена. Ориентир: выше data-слоя не должно быть возможности назвать ни один тип из retrofit2, okhttp3 или android.database.
Один тонкий :app, по модулю на фичу и общие модули :core:* под ними; фичи зависят от core и никогда друг от друга, и только :app видит всё. Это сохраняет параллельную сборку и позволяет компилятору охранять границы.
Типы модулей
:app: Application, верхнеуровневый NavHost, сборка DI-графа.
:feature:*: UI, ViewModel и состояние одного сценария.
:core:* (domain, data, network, database, designsystem, testing): общий код с узким публичным API, а остальное помечено internal.
Направление зависимостей
Фича зависит от core, но никогда фича от фичи. Две фичи, которым нужна друг другу, образуют цикл, который Gradle отказывается собирать, и это значит, что граница проведена неверно.
Разрывают его зависимостью от того, что видят обе: колбэк, связанный в :app, маленький модуль маршрутов :core:navigation или перенос общей модели в core.
DI пересекает модули через Hilt: каждый модуль добавляет биндинги с @InstallIn(SingletonComponent::class), а граф генерируется в :app, единственном модуле, который видит все реализации.
Что даёт и чего стоит
Параллельная компиляция, меньшие пересборки, когда изменение остаётся за implementation-зависимостью, тесты по модулям, понятное владение для каждой команды.
Цена: время конфигурации, бойлерплейт и никакого ускорения, если все фичи зависят от толстого :core:common, который меняется каждый день. Общую логику сборки выносят в convention-плагины в build-logic, а версии держат в libs.versions.toml.
Мигрируют экран за экраном, от листьев внутрь, добавляя сначала ViewModel и только потом Compose, чтобы каждый коммит можно было выкатить.
Порядок
Подготовить инструментарий без единого видимого пользователю изменения: Kotlin и корутины, граф зависимостей, плагин компилятора Compose и interop-зависимости.
Вынести логику из фрагментов во ViewModel, оставив XML на месте. Здесь основная польза (тестируемые классы и состояние, переживающее поворот), и она совершенно не зависит от Compose.
Провести границу слоя данных: интерфейс репозитория на каждый тип данных, за которым спрятан существующий код, чтобы экран потом переписывался без касания сети.
Первыми переводить листовые части UI (строку списка, пустое состояние, диалог), размещая их в ComposeView внутри текущего layout. Риск минимальный, а решение о дизайн-системе принимается рано, пока оно дешёвое.
Переводить экраны целиком, оставляя их внутри фрагментов, чтобы не трогать навигацию. AndroidView заворачивает виджет, который пока не заменить: карту или видеоповерхность.
И только когда большинство экранов на Compose, менять слой навигации и схлопываться к одной Activity.
Детали interop, которые реально кусаются
ComposeView во фрагменте требует setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed), чтобы композиция жила ровно столько, сколько вью фрагмента. Стратегия по умолчанию освобождает композицию при отсоединении от окна, а оно может сработать раньше времени (например, в ViewPager2) и потерять состояние.
Тема Compose должна повторять XML-тему, чтобы во время перехода обе половины выглядели одинаково.
Общая ViewModel между XML-родителем и Compose-потомком работает, потому что оба разрешаются в один и тот же store owner.
Как не сорваться
Сопротивляйтесь ветке большого переписывания, которая никогда не вольётся, желанию начать с самого сложного экрана, чтобы доказать осуществимость, и переписыванию логики и UI в одном коммите, после чего любая регрессия становится неоднозначной.
Делайте новые экраны только на Compose по правилу, ведите видимую метрику вроде доли перенесённых экранов, пишите характеризующие тесты вокруг логики до её переноса и ставьте флаг на экран для всего, что достаточно рискованно, чтобы понадобился откат.