Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.
Вопросы и ответы
10 подробных ответов
01Что такое MVVM и почему ViewModel не должна ссылаться на View или Context?
junior
Короткий ответ: MVVM делит экран на три роли: Model (данные и логика), View (Activity/Fragment/Composable — только рисует и передаёт действия) и ViewModel (держит состояние и переживает пересоздание View). ViewModel не знает про конкретную View — она лишь публикует состояние, на которое View подписывается.
Подробно:
┌────────┐ подписка на state ┌───────────┐ запрос данных ┌───────┐
│ View │ ◄─────────────────── │ ViewModel │ ──────────────► │ Model │
│ (тупая)│ ── действия (события) ─►│ (state) │ ◄─── данные ──── │(repo) │
└────────┘ └───────────┘ └───────┘
- View — тупой рендер — берёт готовое состояние и рисует; никакой бизнес-логики, только «показать то, что дали».
- ViewModel — держатель состояния — переживает поворот экрана (её создаёт
ViewModelStoreOwner, а не пересоздаёт вместе с Activity). - Model — репозитории, БД, сеть; ViewModel обращается к ним, а не View напрямую.
⚠️ Частая ошибка: хранить View, Activity или Context в поле ViewModel. ViewModel живёт дольше View — после поворота ссылка указывает на уничтоженную Activity, и это утечка памяти. Нужен Context — берите AndroidViewModel.getApplication() или инжектите @ApplicationContext.
02Что такое однонаправленный поток данных (UDF): «состояние вниз, события вверх»?
junior
Короткий ответ: UDF означает, что состояние течёт в одну сторону — из ViewModel в UI (обычно через StateFlow), а события пользователя идут обратно вверх вызовами методов ViewModel. UI сам состояние не меняет: он лишь просит ViewModel, та пересчитывает состояние, UI перерисовывается.
Подробно:
state (вниз)
ViewModel ──────────────► UI
▲ │
└────────── events ─────┘
(вверх: onClick, onRefresh…)
- Состояние — вниз — единый неизменяемый
UiStateспускается в UI; UI — чистая функция от состояния. - События — вверх — тап, свайп, ввод текста превращаются в вызовы
viewModel.onX(); только ViewModel имеет право менять состояние. - Один источник правды — раз состояние правит одно место, поведение предсказуемо и легко тестируется: подал состояние — проверил отрисовку.
⚠️ Частая ошибка: позволять UI мутировать состояние напрямую (менять поля модели из обработчика клика). Тогда правда «живёт» в двух местах — в UI и во ViewModel — и они рассинхронизируются. UI только сообщает о намерении, меняет состояние ViewModel.
03MVVM, MVP и MVI — в чём разница и почему на сложных экранах выбирают MVI?
middle
Короткий ответ: MVP — Presenter императивно дёргает View через интерфейс (view.showLoading(), view.showError()); связь тесная, состояние размазано по вызовам. MVVM — ViewModel публикует состояние, View подписывается, но состояние часто разбито на несколько LiveData/полей. MVI доводит UDF до предела: одно неизменяемое состояние + поток интентов, поэтому «невозможных» комбинаций UI не бывает.
Подробно:
| Паттерн | Как обновляется UI | Главная боль |
|---|---|---|
| MVP | Presenter вызывает методы View по интерфейсу | ручная синхронизация экрана, десятки методов View, тяжело при повороте |
| MVVM | View подписан на observable-поля VM | состояние размазано по нескольким потокам → рассинхрон |
| MVI | один State + Intent, редьюсер пересчитывает всё |
больше бойлерплейта на простых экранах |
- Почему MVI выигрывает на сложном — единый снимок состояния исключает противоречия (нельзя одновременно loading и error), даёт воспроизводимость и удобный лог/time-travel.
- Цена MVI — церемония: intent-ы, редьюсеры, эффекты. На форме из двух полей это оверкилл — там хватит MVVM.
⚠️ Частая ошибка: боль MVP — рассинхрон вида view.showLoading() вызвали, а hideLoading() в одной из веток забыли, и спиннер завис. MVI убирает это классом: экран пересобирается целиком из одного состояния.
04Как моделировать состояние экрана: sealed interface или набор булевых флагов?
middle
Короткий ответ: Отдельные флаги (isLoading, error, data) допускают бессмысленные комбинации — можно оказаться в isLoading = true и одновременно с непустой error. sealed interface делает невозможные состояния непредставимыми: экран может быть ровно в одном из перечисленных состояний.
Подробно:
// ❌ флаги: 2^3 комбинаций, часть из них невалидна
data class State(
val isLoading: Boolean = false,
val error: String? = null,
val data: List<Item>? = null, // loading + error + data одновременно?
)
// ✓ sealed interface: одно состояние в каждый момент
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState
data class Error(val message: String) : UiState
}
- Невозможное — непредставимо — компилятор гарантирует, что
Successнесёт данные, аError— сообщение; забыть «сбросить» флаг нельзя. whenисчерпывающий — по sealed-типу компилятор требует обработать все ветки: добавили состояние — UI не соберётся, пока его не отрисуешь.- Гибрид на практике — часто удобнее
data classс постоянными полями (заголовок, уже загруженный список) плюс вложенный sealed для изменчивого content-состояния.
⚠️ Частая ошибка: держать data, error и isLoading тремя независимыми полями и потом в UI писать лестницу if (isLoading) … else if (error != null) …. Порядок веток становится «архитектурой», и любой рассинхрон флагов рисует битый экран.
06Repository как single source of truth: как выглядит offline-first репозиторий?
middle
Короткий ответ: Репозиторий прячет источники (сеть, БД, кэш) за единым API и назначает один источник правды — обычно локальную БД. UI всегда читает из БД (через Flow), а сеть лишь пишет свежие данные в БД. Экран показывает данные мгновенно и никогда не «мигает» между источниками.
Подробно:
class UserRepository(private val dao: UserDao, private val api: Api) {
// UI подписан ТОЛЬКО на БД — это единственный источник правды
fun observeUser(id: String): Flow<User> = dao.observe(id)
// сеть не отдаётся в UI напрямую — она обновляет БД
suspend fun refresh(id: String) {
val fresh = api.getUser(id) // может упасть — БД остаётся валидной
dao.upsert(fresh.toEntity()) // Flow из observe() эмитит сам
}
}
- Один источник правды — UI знает только про БД; появилась связь — обновили БД,
Flowпротолкнул новое значение на экран. - Offline-first — без сети экран всё равно показывает последнее закэшированное; сбой
refresh()не ломает отображение. - Граница абстракции — ViewModel не знает, откуда данные; поменять Retrofit на другой источник можно, не трогая presentation.
⚠️ Частая ошибка: отдавать в UI то ответ сети, то данные из БД в зависимости от наличия связи. Источников правды становится два, они расходятся, и экран мигает. SSOT — читаем всегда из БД, сеть только наполняет её.
07Clean Architecture на Android: какие слои, что такое правило зависимостей и когда UseCase — лишняя церемония?
senior
Короткий ответ: Три слоя — presentation (ViewModel + UI), domain (сущности и UseCase, чистый Kotlin без Android) и data (репозитории, DTO, Room, Retrofit). Правило зависимостей: стрелки направлены внутрь, к domain. Domain ни о ком не знает; data и presentation зависят от domain, а не наоборот. UseCase оправдан при реальной логике или переиспользовании; тонкая обёртка над repo.getX() — церемония.
Подробно:
presentation ───► domain ◄─── data
(ViewModel/UI) (entities, (repo impl, DTO,
use cases, Room, Retrofit)
repo интерфейсы)
зависимости всегда указывают ВНУТРЬ
- Правило зависимостей — интерфейсы репозиториев живут в domain, реализации — в data; presentation зовёт domain. Внешние слои знают о внутренних, не наоборот.
- Три модели, не одна — DTO (форма бэка), domain-модель (язык бизнеса), UI-модель (что рисуем). У каждой своя причина меняться: смена поля в API не должна ломать Composable.
- Когда UseCase — ценность — есть логика (комбинирование источников, правила, валидация) или её переиспользуют несколько ViewModel. Когда
class GetUser(repo) { operator fun invoke() = repo.getUser() }— это лишний слой ради слоя.
⚠️ Частая ошибка: domain, который импортирует android.*, Context или Retrofit-аннотации. Тогда правило зависимостей нарушено — domain обязан компилироваться как чистый Kotlin-модуль, без Android SDK.
08Hilt: как связаны компоненты и скоупы, чем @Binds отличается от @Provides и когда стоит Koin?
senior
Короткий ответ: Каждый компонент Hilt соответствует своему скоупу и времени жизни. @Binds связывает интерфейс с его реализацией (для классов с constructor-injection — генерит меньше кода), @Provides описывает, как создать объект (для чужих классов и билдеров). Без скоупа Hilt отдаёт новый инстанс на каждый запрос. Koin строит граф в рантайме через DSL — быстрее сборка, но ошибки графа видны только при запуске; Hilt проверяет граф в компайл-тайме.
Подробно:
| Компонент | Скоуп | Живёт |
|---|---|---|
SingletonComponent |
@Singleton |
всё приложение |
ActivityRetainedComponent |
@ActivityRetainedScoped |
переживает пересоздание Activity |
ViewModelComponent |
@ViewModelScoped |
время жизни ViewModel |
ActivityComponent |
@ActivityScoped |
одну Activity |
- Скоуп = кэш в компоненте —
@Singleton-биндинг создаётся один раз на приложение; unscoped-биндинг создаётся заново при каждой инъекции. - @Binds против @Provides —
@Binds— абстрактный метод в абстрактном@Module, только «интерфейс → реализация»;@Provides— обычная функция с телом, когда объект надо собрать (Retrofit, Room, чужой класс). - Hilt vs Koin — компайл-тайм безопасность и производительность против скорости сборки и гибкости; Koin к тому же работает в Kotlin Multiplatform, Hilt — нет.
⚠️ Частая ошибка: ждать, что зависимость «сама» будет синглтоном, потому что её инжектят везде. Без @Singleton на биндинге Hilt создаст новый объект на каждую точку инъекции — общий кэш/OkHttpClient незаметно размножится.
09Многомодульность: делить по фичам или по слоям, и зачем различать api и implementation?
middle
Короткий ответ: Деление по фичам (feature:login, feature:cart) масштабируется лучше чистого деления по слоям: границы совпадают с продуктом, команды не толкаются в одних файлах. На практике — гибрид: фичи, а внутри каждой слои (data/domain/ui). implementation прячет транзитивную зависимость, api протаскивает её наружу — это напрямую влияет на скорость сборки.
Подробно:
| Стратегия | Плюс | Минус |
|---|---|---|
| По слоям | просто на старте | один :feature-монолит, слабый параллелизм |
| По фичам | изоляция, параллельная сборка | нужен слой core/common |
| Гибрид (фича × слой) | чёткие границы + переиспользование | больше модулей и Gradle-конфигурации |
- Зачем вообще модули — Gradle пересобирает только изменённые модули и собирает независимые параллельно; на больших проектах это минуты против десятков минут.
- api vs implementation —
implementationНЕ протекает наружу: правка внутренностей не форсит пересборку зависящих модулей.apiделает зависимость частью публичного контракта модуля. - Правило по умолчанию —
implementation;api— только когда тип реально виден в публичном API модуля.
⚠️ Частая ошибка: ставить api везде «на всякий случай». Тогда любая правка каскадом инвалидирует пол-графа зависимостей, и весь выигрыш многомодульности в скорости сборки испаряется.
10«Расскажите про вашу архитектуру» — спроектируйте экран профиля от API до UI.
senior
Короткий ответ: Снизу вверх: Retrofit отдаёт ProfileDto → маппер → domain-модель Profile → Repository (single source of truth в Room) → опциональный UseCase → ViewModel собирает ProfileUiState → single-activity + Navigation показывает Composable/Fragment, подписанный на StateFlow; действия пользователя идут интентами вверх, навигация — одноразовым событием.
Подробно:
API ─► ProfileDto ─[map]─► Profile ─► Repository ─► (UseCase) ─► ViewModel
(Retrofit) (data) (domain) (Room = SSOT) │ ProfileUiState
▼
UI (Composable) ◄── StateFlow (state вниз)
└── intents/navEvent ── вверх
- Data —
Api+Dao; репозиторий пишет сеть в Room и отдаёт наверхFlow<Profile>из БД. Три модели (DTO/domain/UI) изолируют изменения формата. - Domain —
Profileи UseCase, если есть логика; чистый Kotlin, зависимостей на Android нет. - Presentation — ViewModel держит
StateFlow<ProfileUiState>(sealed), одноразовые эффекты — черезChannel; UI — чистая функция от состояния. - Каркас и связка — одна Activity + Navigation с типобезопасными аргументами и deep links; граф собирает Hilt.
⚠️ Частая ошибка: тащить ProfileDto прямо в Composable, экономя на маппинге. Любое переименование поля на бэке ломает UI и тесты; три модели существуют именно затем, чтобы у каждого слоя была одна причина меняться.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.