Наставник по Kotlin для Android-разработки
Наставник по Kotlin для Android: разбирает ваш код, объясняет идиомы языка и coroutines, сопоставляет с Java и даёт упражнения для закрепления.
Пример запуска
Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.
Значения переменных
- Код или вопрос
- Рефакторю старый экран профиля пользователя с Java на Kotlin. Логика загрузки данных вызывает у меня вопросы — особенно то, как я запускаю корутины и обрабатываю null. Вот текущий вариант ViewModel и репозитория: ```kotlin class ProfileViewModel( private val repository: ProfileRepository ) : ViewModel() { var user: User? = null var isLoading: Boolean = false var errorMessage: String? = null fun loadProfile(userId: String) { isLoading = true GlobalScope.launch(Dispatchers.Main) { try { val result = repository.fetchUser(userId) result?.let { user = it it.avatarUrl?.let { url -> url.let { safeUrl -> preloadAvatar(safeUrl) } } } isLoading = false } catch (e: Exception) { errorMessage = e.message isLoading = false } } } private fun preloadAvatar(url: String) { GlobalScope.launch(Dispatchers.IO) { repository.cacheImage(url) } } fun getUserName(): String { return user!!.name } } class ProfileRepository(private val api: ProfileApi) { suspend fun fetchUser(id: String): User? { return try { api.getUser(id).execute().body() } catch (e: Exception) { null } } suspend fun cacheImage(url: String) { withContext(Dispatchers.IO) { // сохраняем битмап на диск } } } data class User(val id: String, val name: String, val avatarUrl: String?) ``` Экран на Compose дергает loadProfile() из LaunchedEffect при первом отображении. Меня смущает GlobalScope (кто-то в команде сказал, что это плохо, но не объяснил почему), обилие вложенных let и то, что getUserName() иногда падает с NPE в проде на слабых устройствах, когда пользователь быстро уходит с экрана. Хочу понять, как это переписать идиоматично и без утечек корутин.
- Опыт
- 2 года писал Android на Java, на Kotlin перешёл около 4 месяцев назад; сам ревьюю PR джунов, но в корутинах и structured concurrency путаюсь
- Тема фокуса
- Structured concurrency и привязка корутин к жизненному циклу ViewModel/экрана, отказ от GlobalScope и nullable-цепочек
- Стек проекта
- Kotlin, Jetpack Compose, Retrofit + Kotlin Coroutines, Hilt, minSdk 24, targetSdk 34
Этот промпт — диалог: ниже первый ход модели, после которого она ждёт вашего ответа.
Разбор ProfileViewModel: structured concurrency, null-safety и состояние экрана
Как я буду вести разбор
По коду видно, что Kotlin-синтаксис вы уже освоили, но модель мышления пока джавовая. Это нормально для четырёх месяцев. Три характерных признака:
- Публичные
var-поля. Это Java-бин с геттерами и сеттерами, просто короче записанный. !!. Это привычка Java «здесь точно не null». Разница в том, что Kotlin заставил вас написать это утверждение явно, и оно оказалось ложным.- Вложенные
let. Это рефлексOptional.ifPresent(...)внутриifPresent(...), перенесённый на nullable-типы.
Поэтому буду объяснять через сопоставление с Java. Сделаю так, как вы просили: один смысловой блок, затем вопрос, и я жду вашего ответа.
Что уже сделано хорошо
- Зависимость приходит через конструктор (
private val repository). Это готово под Hilt и под тесты с фейковым репозиторием. User— data class, иavatarUrl: String?честно описывает, что аватара может не быть. Вы выразили «может отсутствовать» в типе, а не в javadoc. Это главная идея null-safety.- Репозиторий отдаёт
suspend-функции.cacheImageсам переключается наDispatchers.IOчерезwithContext. Это правильная конвенция main-safety: вызывающий код не должен знать, на каком потоке работает репозиторий. - Вы заметили запах сами и пришли с вопросом «почему», а не «как заглушить».
План разбора (от критичного к второстепенному)
- GlobalScope и structured concurrency. Утечки, неотменяемая работа, падения из «осиротевших» корутин. (этот ответ)
- Откуда NPE и как моделировать состояние экрана. Проглоченные ошибки,
!!, не наблюдаемые Composevar-поля,sealed interface UiState+StateFlow. - Scope functions и nullable-цепочки. Где
letуместен, а где лучше обычныйifсо smart cast. - Сторона Compose.
LaunchedEffectпротив загрузки во ViewModel,collectAsStateWithLifecycle,repeatOnLifecycle. - Упражнения.
Спойлер про прод-краш. NPE, скорее всего, вызван не самим
GlobalScope, а тем, что репозиторий превращает любую ошибку вnull. Ещё подозрителен блокирующийexecute()внутриsuspend-функции. Подробно разберу во втором блоке, но сначала нужна модель из первого, без неё починка будет рецептом, а не пониманием.
Блок 1. Почему GlobalScope — антипаттерн и чем его заменить
Модель: structured concurrency
Главная идея корутин в Kotlin: у каждой корутины есть родитель, и она не может пережить свой scope. Из этого следуют три правила:
- Время жизни ограничено. Когда scope отменяется, отменяются все его корутины и их дети, рекурсивно.
- Родитель ждёт детей. Корутина не считается завершённой, пока не завершились все запущенные внутри неё
launch/async. - Ошибки идут вверх. Необработанное исключение в дочерней корутине отменяет родителя. Родитель либо обрабатывает его, либо передаёт дальше.
Отмена идёт вниз, ошибки идут вверх, никто не «висит в воздухе».
Сопоставление с Java
GlobalScope.launch { ... } — это почти то же самое, что вы в Java делали так:
// Java: тот же антипаттерн, только привычнее
new Thread(() -> {
User u = api.getUser(id).execute().body();
viewModel.user = u; // ссылка на ViewModel удерживается потоком
}).start();
Или вспомните AsyncTask как нестатический внутренний класс Activity. Вы наверняка ловили с ним утечки: задача держит ссылку на Activity, пользователь уже ушёл, а задача всё ещё работает и потом пишет в мёртвый объект.
GlobalScope — это scope всего процесса без родителя и без отмены. Корутина в нём не привязана ни к чему. Structured concurrency придумана именно для того, чтобы такого класса багов не было. Поэтому в kotlinx.coroutines 1.5+ GlobalScope помечен аннотацией @DelicateCoroutinesApi: компилятор предупреждает, что вы выходите из этой модели.
Что конкретно происходит в вашем коде
Сценарий: пользователь открыл профиль и сразу ушёл назад.
ProfileViewModelполучаетonCleared()и должна быть собрана GC.- Корутина в
GlobalScopeоб этом не знает. Сетевой запрос продолжается. - Лямбда корутины захватила
this, то есть ViewModel. Поэтому ViewModel, репозиторий и всё, на что они ссылаются, живут до конца запроса. На медленной сети это десятки секунд. - Запрос завершился, и корутина пишет
user = ...в объект, который никому не нужен. preloadAvatarзапускает ещё одну корутину вGlobalScope. Она не дочерняя по отношению к первой: это отдельный корень.- Самое опасное. Если
cacheImageбросит исключение (диск полон,IOException), вашtry/catchвloadProfileего не поймает. Это другая корутина, у неё свой стек. Необработанное исключение в корневомlaunchуходит в uncaught exception handler потока, а на Android это падение приложения. Причём уже после того, как пользователь ушёл с экрана.
И ещё одна деталь: вы запускали загрузку на Dispatchers.Main, а preloadAvatar на Dispatchers.IO. Второе избыточно: cacheImage сам делает withContext(Dispatchers.IO).
До / после
До — корутины без родителя:
fun loadProfile(userId: String) {
isLoading = true
GlobalScope.launch(Dispatchers.Main) {
// ...
preloadAvatar(safeUrl) // запускает ещё один GlobalScope.launch
}
}
После — корутины привязаны к жизни ViewModel. Поля состояния пока оставляю как есть: их переделываем во втором блоке, сейчас фокус только на scope.
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ProfileRepository
) : ViewModel() {
var user: User? = null // временно, заменим на StateFlow в блоке 2
var isLoading: Boolean = false
var errorMessage: String? = null
fun loadProfile(userId: String) {
viewModelScope.launch { // диспетчер по умолчанию — Main.immediate
isLoading = true
try {
val loaded = repository.fetchUser(userId)
user = loaded
loaded?.avatarUrl?.let { url ->
launch { repository.cacheImage(url) } // ДОЧЕРНЯЯ корутина
}
} catch (e: Exception) {
// осторожно: этот catch ловит и CancellationException, разберём в блоке 2
errorMessage = e.message
} finally {
isLoading = false // вместо дублирования в двух ветках
}
}
}
}
Что изменилось по модели:
viewModelScopeотменяется вonCleared(). Ушёл пользователь, значит сетевой запрос отменён, ссылка на ViewModel отпущена, записи в мёртвый объект нет.- Внутренний
launch— дочерний. Он отменится вместе с родителем, а его исключение пойдёт вверх по дереву, а не «в никуда». - Диспетчер не указан, и это сознательно.
viewModelScopeиспользуетDispatchers.Main.immediate. ViewModel работает с состоянием на главном потоке, а переключение на IO — ответственность репозитория черезwithContext. Если вlaunchво ViewModel пишетсяDispatchers.IO, обычно это признак, что репозиторий не main-safe.
Про версии.
viewModelScope— это extension изandroidx.lifecycle. Исторически он жил в артефактеlifecycle-viewmodel-ktx, в новых версиях Lifecycle KTX-расширения перенесли в основные артефакты. Точный артефакт для вашей версии проверьте в release notes Lifecycle. Что внутриviewModelScopeиспользуетсяSupervisorJob, я уверен. Детали реализации можно посмотреть в исходникахViewModel.kt.
«А если работа должна пережить экран?»
Это честный вопрос. Кэширование аватара, отправка аналитики, запись в БД иногда должны доделаться, даже если пользователь ушёл. Правильный ответ всё равно не GlobalScope, а явный scope уровня приложения, который вы контролируете и можете подменить в тестах:
// Scope уровня приложения через Hilt
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutineScopesModule {
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
Если работа должна пережить смерть процесса (например, гарантированная загрузка файла), это уже задача для WorkManager, а не для корутин.
Коротко: launch против async
launch— «запусти и не жди результата». ВозвращаетJob, через который можно отменить или дождаться завершения. Подходит для загрузки профиля и кэширования.async— «запусти и потом отдай результат». ВозвращаетDeferred<T>, результат берут черезawait(). Нужен, когда надо параллельно получить несколько значений:
// async для параллельной декомпозиции
suspend fun loadScreen(id: String): Pair<User?, Settings> = coroutineScope {
val user = async { repository.fetchUser(id) }
val settings = async { repository.fetchSettings(id) }
user.await() to settings.await() // если одна упадёт, вторая будет отменена
}
(fetchSettings здесь выдуман для примера, в вашем репозитории его нет.)
Частая ошибка после Java: async { ... }.await() сразу же, подряд. Это просто медленный launch с лишней обёрткой, параллелизма там нет.
Правило блока
| Ситуация | Что использовать |
|---|---|
| Работа нужна, пока жив экран / ViewModel | viewModelScope |
| Сбор Flow в UI | lifecycle-aware сбор (блок 4) |
| Работа должна пережить экран, но не процесс | инжектируемый @ApplicationScope |
| Работа должна пережить процесс | WorkManager |
GlobalScope |
почти никогда в прод-коде Android. Если видите его на ревью у джуна, это повод спросить: «кто отменит эту корутину?» |
Последний вопрос, «кто отменит эту корутину?», — хорошая проверка на ревью для любого launch.
❓ Вопрос для проверки понимания
В моём варианте «после» кэширование аватара запускается как дочерний launch внутри корутины загрузки. Допустим, cacheImage бросает IOException, а ловить его в дочерней корутине мы не стали.
Что произойдёт: (а) с корутиной загрузки профиля и (б) с приложением в целом? Подсказка: вспомните, что ошибки в structured concurrency идут вверх, и подумайте, что меняет SupervisorJob внутри viewModelScope, а чего он не меняет.
Жду ваш ответ. После него перейдём ко второму блоку: откуда на самом деле берётся NPE в проде и как описать состояние экрана через sealed interface, чтобы !! стал просто не нужен.
Похожие промпты
Ревью pull request по diff
Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.
Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».