Корутины Kotlin для Android-команд, которые уходят от колбэков
Корутины Kotlin для Android помогают командам заменить вложенные колбэки понятной фоновой работой, безопасной отменой и тестами, которые реже падают.

Почему код на колбэках со временем становится тяжёлым
Цепочка колбэков поначалу выглядит безобидно. Вы получаете данные, сохраняете их, потом обновляете экран. Через несколько месяцев реальный поток уже спрятан внутри onSuccess, onError, веток повторной попытки, флагов загрузки и проверок на null, поэтому основная логика больше не читается одной понятной строкой.
Именно тогда команды обычно начинают думать о корутинах Kotlin для Android. Проблема не только в стиле. Код на колбэках делает простую работу разрозненной, потому что каждый шаг прячется внутри другого шага.
Владение быстро становится размытым. Запрос может начаться в репозитории, пройти через вспомогательный метод, а закончиться в fragment или activity. Когда кто-то спрашивает: «Кто отменит это, если пользователь уйдёт?» — часто ответ звучит как «зависит», а это ещё один способ сказать, что никто по-настоящему этим не владеет.
Поворот экрана очень наглядно показывает эту проблему. Пользователь открывает экран, запускается фоновый вызов, потом телефон поворачивается. Старый экран уже исчез, но старый колбэк всё ещё может держать ссылку на view, adapter или listener. В итоге работа продолжает идти для экрана, который пользователь даже не видит.
Иногда это приводит к падению. Чаще — к мелким багам, которые съедают часы: индикатор загрузки не выключается, старые данные мелькают на секунду или два запроса завершаются не в том порядке, и на экране остаётся устаревшее состояние.
Обработка ошибок тоже расползается по неудобным местам. Ошибка сети может обрабатываться в одном файле, ошибка парсинга — в другом, а запасное сообщение — в слое UI. Когда пользователь говорит: «Иногда эта страница зависает», вам, возможно, придётся читать четыре файла, чтобы найти один пропущенный путь ошибки.
Тесты становятся грязными по той же причине. Код на колбэках зависит от времени, перескоков между потоками и порядка завершения работы. Один тест проходит на вашем ноутбуке, а потом падает в тест-раннере, потому что проверка сработала чуть раньше.
Команды часто чинят это через sleep и более длинные таймауты. Обычно это делает тесты медленнее, а не лучше. Если тесту нужно ждать и надеяться, код уже подсказывает, что его трудно предсказать.
Что меняется, когда вы используете корутины
suspend-функция — это просто функция, которая может приостановить работу, не блокируя поток. Она ждёт сетевой вызов, чтение из базы или таймер, а потом продолжает с той же строки. Маленькое изменение, а читаться приложение начинает совсем по-другому.
В коде с колбэками логика часто разбросана по нескольким местам. Вы запускаете работу в одном методе, обрабатываете успех в другом, разбираетесь с ошибками в третьем и всё время держите состояние в голове. Корутины позволяют записать тот же поток сверху вниз.
Простой пример — логин. Вы можете вызвать login(), сохранить токен, загрузить профиль и обновить экран в одном блоке кода. Порядок легко увидеть, а ранние return снова начинают выглядеть нормально.
Вот здесь и важна структурированная конкурентность. Корутина должна принадлежать чему-то реальному, а не висеть сама по себе. В Android это обычно значит:
viewModelScope— для работы, привязанной к состоянию экранаlifecycleScope— для работы, привязанной к видимому UI-владельцу- scope приложения или сервиса — для работы, которая должна пережить один экран
Родительские и дочерние jobs делают эту структуру по-настоящему сильной. Если ViewModel запускает родительскую корутину и внутри неё запускает дочерние корутины, эти дочерние задачи принадлежат родителю. Когда ViewModel исчезает, Android отменяет родительскую job, и дочерние тоже останавливаются.
Это решает распространённую проблему колбэков: старая работа продолжает идти после того, как пользователь ушёл с экрана. С корутинами Kotlin для Android отмена становится частью обычного потока управления, а не мыслью «потом разберёмся».
У исключений тоже появляется понятный путь. Если дочерняя корутина падает, ошибка поднимается к родителю, если только вы не изолируете её через supervisorScope или SupervisorJob. Вам больше не нужны разбросанные по коду error callbacks только ради того, чтобы понять, где именно произошёл сбой.
Это не магия. Вам всё равно нужно выбрать правильный scope и решить, какие ошибки должны останавливать соседнюю работу. Но правила видны в коде, и уже этого достаточно, чтобы фоновую работу было намного легче понимать.
Переведите одну цепочку колбэков шаг за шагом
Начните с одного потока, у которого есть понятное начало и конец. Подойдёт запрос логина, обновление профиля или действие «сохранить черновик». Пропустите сложный экран с пятью запросами и тремя таймерами. Одной кнопки, одного фонового вызова и одного результата достаточно.
Для многих команд корутины Kotlin для Android «щелкают» именно тогда, когда они переводят одну надоедливую цепочку колбэков и сразу видят, что код стал короче.
Если слой данных всё ещё отдаёт колбэки, сначала оберните только один API через suspendCancellableCoroutine. Остальную часть приложения пока не меняйте.
suspend fun loadProfile(userId: String): Profile =
suspendCancellableCoroutine { cont ->
val call = api.loadProfile(userId, object : Callback<Profile> {
override fun onSuccess(result: Profile) {
if (cont.isActive) cont.resume(result)
}
override fun onError(error: Throwable) {
if (cont.isActive) cont.resumeWithException(error)
}
})
cont.invokeOnCancellation { call.cancel() }
}
Затем вызывайте эту suspend-функцию из viewModelScope. Так у экрана появляется понятный владелец для работы. Когда пользователь уходит с экрана, Android может отменить работу, вместо того чтобы старый колбэк обновлял уже мёртвый UI.
Поместите состояние загрузки, успеха и ошибки в одном месте во ViewModel. Простой UiState часто лучше, чем разбросанные по колбэкам showSpinner(), hideSpinner() и showError(). Экран становится скучным, а это обычно хороший знак.
Хорошая миграция обычно выглядит так:
- Обернуть один API с колбэком
- Вызывать его из
viewModelScope - Перенести состояние загрузки и ошибки во ViewModel
- Добавить или обновить один тест для нового пути
- Удалить старые ветки колбэков
Удаляйте старые ветки сразу, как только новый тест проходит. Если обе версии останутся в коде, люди будут чинить обе, и версия на колбэках так и не умрёт по-настоящему.
Небольшой выигрыш уже достаточен. После того как один поток начинает работать чисто, следующая миграция идёт намного быстрее.
Используйте структурированную конкурентность осознанно
Структурированная конкурентность держит фоновую работу привязанной к понятному владельцу. В Android это обычно экран, ViewModel или одно действие пользователя. Если экран запускает работу, он и должен ею владеть. Когда пользователь уходит, работа тоже должна остановиться.
Для UI-состояния viewModelScope часто подходит лучше всего. Он переживает простые изменения view, но всё равно заканчивается, когда заканчивается ViewModel. Для работы, которая важна только пока view видно, лучше подходит lifecycleScope. Scope должен совпадать с жизнью задачи, а не просто с местом, где вы случайно написали код.
Выберите правильного родителя
Используйте coroutineScope, когда дочерние задачи должны подниматься и падать вместе. Допустим, экрану нужны данные аккаунта и разрешения, прежде чем он сможет отрендериться. Если любой из вызовов падает, вся операция должна остановиться, а другая дочерняя задача — отмениться. Обычно именно так и нужно для загрузки одного экрана.
Используйте supervisorScope, когда части могут падать отдельно. Панель может загружать основной баланс, новостную карточку и промо-баннер. Если запрос для промо не удался, вы всё равно можете захотеть показать баланс. supervisorScope позволяет сохранить полезные части, не превращая одну небольшую ошибку в пустой экран.
Есть и ещё одна важная привычка: переключайтесь на Dispatchers.IO только вокруг кода, который действительно блокирует поток. Старые SDK-вызовы, работа с файлами и часть работы с базой данных относятся туда. А вот обычный suspend-вызов сети часто — нет. Если по умолчанию оборачивать весь use case в IO, код становится сложнее понимать.
Репозитории должны оставаться скучными. Они должны отдавать suspend-функции или Flow и позволять вызывающему коду решать, где будет выполняться работа. Если репозиторий сам запускает случайный launch, у вызывающего кода теряется контроль над отменой, обработкой ошибок и тестами. Так хаос в стиле колбэков возвращается обратно, только уже с синтаксисом корутин.
Обрабатывайте отмену как часть работы
На Android устаревшая фоновая работа часто хуже, чем неудавшаяся. Если пользователь вводит «ca», потом «cat», потом «caterpillar», приложение должно быстро отказаться от старых запросов. Когда команды заменяют колбэки в Android, одна из первых привычек — понять, что отмена это нормальный путь.
Поле поиска — самый простой пример. Каждый новый запрос делает предыдущий менее полезным. Если держать каждый запрос живым, медленный старый ответ всё ещё может обновить экран и показать неверные данные. Отменяйте старую job перед тем, как запускать новую.
private var searchJob: Job? = null
fun onQueryChanged(query: String) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
val results = repository.search(query)
_uiState.value = UiState(results)
}
}
Не считайте CancellationException обычной ошибкой. Многие команды ловят Exception, показывают состояние ошибки и случайно превращают нормальную отмену в ложный сбой. Если нужен try/catch, ловите либо конкретные ошибки, либо сразу пробрасывайте CancellationException дальше.
Долгим циклам нужен дополнительный контроль. Если корутина разбирает большой файл, обрабатывает длинный список или загружает данные по частям, вызывайте ensureActive() внутри цикла. Так корутина получает понятную точку остановки, вместо того чтобы заставлять пользователя ждать работу, которая уже никому не нужна.
Несколько правил помогают сохранять отмену чистой:
- Отменяйте старую работу, когда меняется ввод пользователя, фильтры или состояние экрана.
- Пробрасывайте
CancellationExceptionдальше, а не записывайте её в логи как ошибку приложения. - Вызывайте
ensureActive()в циклах, которые могут работать долго. - Держите очистку в
finallyкороткой и быстрой. - Не показывайте сообщения об ошибке при обычной отмене.
Очистка всё ещё важна, но она должна быть короткой. Закройте файл, спрячьте индикатор загрузки, освободите ресурс. Не запускайте из finally новую работу, если только она вам действительно не нужна.
Такой подход многое меняет. Отменённая корутина не означает, что приложение сломалось. Обычно это значит, что пользователь пошёл дальше, и ваш код это уважил.
Простой пример с Android-экрана
Экран поиска отлично показывает, почему корутины Kotlin для Android удобнее колбэков. Пользователь быстро печатает, меняет решение и ожидает, что список будет успевать за ним. В коде с колбэками старые ответы часто приходят поздно и перезаписывают новые. С корутинами можно считать каждый запрос одной задачей и отменять её, когда приходит следующий запрос.
Спрячьте эту логику во ViewModel. Fragment должен отправлять изменения текста наверх и рисовать состояние, которое возвращается вниз. Он не должен решать, загружается ли экран, пустой он, успешный или сломанный.
class SearchViewModel(
private val repo: SearchRepository,
private val historyRepo: HistoryRepository
) : ViewModel() {
private val query = MutableStateFlow("")
val uiState: StateFlow<SearchUiState> = query
.debounce(250)
.distinctUntilChanged()
.flatMapLatest { text ->
flow {
emit(SearchUiState.Loading(text))
val state = coroutineScope {
val suggestions = async { repo.search(text) }
val recentHistory = async { historyRepo.recent(text) }
val suggestionItems = suggestions.await()
val historyItems = recentHistory.await()
when {
suggestionItems.isEmpty() && historyItems.isEmpty() -> {
SearchUiState.Empty(text)
}
else -> {
SearchUiState.Success(text, suggestionItems, historyItems)
}
}
}
emit(state)
}.catch {
emit(SearchUiState.Error(text))
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState.Idle)
fun onQueryChanged(text: String) {
query.value = text
}
}
flatMapLatest делает основную работу. Когда пользователь вводит «ca», а потом «cat», работа для «ca» останавливается. Поскольку repo.search() и historyRepo.recent() работают внутри одной родительской job, отмена доходит до обеих. Вот структурированная конкурентность в деле, а не просто теория.
Одновременная загрузка подсказок и недавней истории ещё и делает экран быстрее. Если один источник медленный, вы ждёте один раз, а не складываете задержки. На реальном телефоне это может заметно сократить паузу.
Fragment остаётся маленьким. Он слушает uiState, показывает индикатор при загрузке, список при успехе, сообщение о пустом состоянии, когда оба списка пустые, и экран ошибки, когда что-то ломается. Такой раздел действительно стоит сохранять. Когда рендеринг остаётся во Fragment, а правила состояния — во ViewModel, команде проще менять экран без страха.
Проверяйте поведение без ожидания реального времени
Команды часто делают тесты корутин медленнее, чем нужно. Они оставляют реальные задержки, используют реальные потоки, а потом добавляют sleep, чтобы «стабилизировать» тест. Обычно это создаёт нестабильные тесты и скрывает проблемы со временем вместо того, чтобы находить их.
runTest исправляет большую часть этого. Он даёт тестовый scope, scheduler и виртуальное время, так что задержка в 2 секунды в тесте может пройти почти мгновенно. Для тестирования корутин в Android это базовый уровень.
Следующая проблема — жёстко зашитые dispatchers. Если production-код напрямую вызывает Dispatchers.IO или Dispatchers.Main, тест теряет контроль. Вместо этого внедряйте dispatchers и передавайте test dispatcher во время теста.
data class AppDispatchers(
val main: CoroutineDispatcher,
val io: CoroutineDispatcher
)
@Test
fun loadsProfileInOrder() = runTest {
val dispatchers = AppDispatchers(
main = StandardTestDispatcher(testScheduler),
io = StandardTestDispatcher(testScheduler)
)
val states = mutableListOf<UiState>()
val viewModel = ProfileViewModel(repo, dispatchers)
val job = launch { viewModel.state.toList(states) }
viewModel.load()
advanceUntilIdle()
assertEquals(listOf(UiState.Idle, UiState.Loading, UiState.Success("Oleg")), states)
job.cancel()
}
advanceUntilIdle() здесь делает основную работу. Он выполняет поставленную в очередь корутинную работу и двигает виртуальное время вперёд, пока не останется ничего. Вам не нужно ждать реальные часы и гадать, сколько времени нужна фоновая задача.
Порядок UI-состояний заслуживает отдельной проверки. Экран обычно не прыгает сразу в success. Он может перейти из Idle в Loading, а потом в Success, или из Loading в Error. Если тест проверяет только конечное состояние, он может пропустить плохой flicker загрузки или двойное обновление, которое заметят пользователи.
Отмена тоже нуждается в тесте. Этот баг постоянно появляется в поиске, фильтрах и обновлении данных. Пользователь запускает запрос A, потом быстро запускает запрос B. Если запрос A завершается поздно и всё равно обновляет экран, приложение показывает устаревшие данные.
Хороший тест специально создаёт такую гонку:
- запустить один запрос
- вызвать второй запрос до завершения первого
- отменить первый job или заменить его во ViewModel
- выполнить
advanceUntilIdle() - проверить, что появляется только самый новый результат
Именно здесь корутины Kotlin для Android ощущаются намного чище, чем код на колбэках. Вы можете управлять временем, собирать состояние по порядку и доказать, что отменённая работа остаётся отменённой. Если в тесте до экрана всё же добирается устаревший результат, пользователи позже увидят тот же баг.
Ошибки, которые команды делают при переходе
Самая сложная часть перехода на корутины Kotlin для Android — не синтаксис. Сложнее не унести с собой старые привычки, которые ломают саму идею корутин. Команды часто заменяют колбэк на launch и считают дело сделанным. Через несколько недель код всё ещё кажется разрозненным, а понять, кто чем владеет, стало ещё труднее.
GlobalScope — частая ловушка. Он кажется удобным, потому что позволяет запускать работу откуда угодно, но эта работа продолжает идти после того, как экран, ViewModel или use case уже исчезли. Если пользователь ушёл с экрана, запрос всё равно может завершиться и попытаться обновить состояние, которое больше не важно. Так мелкие баги превращаются в случайные падения и лишний расход батареи.
runBlocking в коде приложения вызывает другой тип проблем. Он блокирует текущий поток, пока работа не завершится. На Android это часто означает зависший экран или тест, который кажется нормальным, пока реальные пользователи не столкнутся с медленной сетью. У runBlocking есть своё место в нескольких тестах и вспомогательных шагах миграции, но в обычном UI- или data-flow его быть не должно.
Ещё одна ошибка часто появляется в репозиториях. Репозиторий обычно должен отдавать suspend-функцию или Flow, а затем позволять вызывающему коду решать, когда запускать корутину. Когда репозитории без понятной причины сами запускают корутины, отмена быстро становится грязной. ViewModel не может остановить работу чисто, потому что она ей никогда не принадлежала.
Команды ещё и слишком часто используют async. Если вам нужен один результат от одной фоновой задачи, обычно проще обычный withContext или прямой suspend-вызов. async имеет смысл тогда, когда вам действительно нужна параллельная работа и вы планируете await оба результата.
Самая медленная ошибка — полумиграция на месяцы. Один слой использует корутины, следующий всё ещё на колбэках, и приложение начинает жить сразу в двух стилях. Логин может начаться как suspend-вызов, потом прыгнуть в SDK на колбэках, а потом снова вернуться в корутину. Такой glue-код расползается повсюду.
Здесь помогает более простой принцип: выберите один путь через фичу, переведите его целиком и дайте одному слою владеть scope корутин. Это снимает путаницу быстрее, чем разбрасывать вызовы корутин по старому коду на колбэках.
Быстрая проверка перед merge
Ошибка с корутиной часто выглядит безобидно на ревью. Экран загружается, happy path работает, и все идут дальше. Потом пользователь поворачивает телефон, уходит с экрана или теряет сеть, а приложение всё ещё делает работу, которую никто не просил.
Быстрый просмотр кода ловит большую часть таких проблем. Для корутин Kotlin для Android я бы не стал делать merge, пока каждый async job не отвечает на пять простых вопросов:
- Кто владеет этой корутиной? Её должен запускать
viewModelScope,lifecycleScopeили явно названный scope. Если корутина стартует из случайного хелпера и её жизнью никто не управляет, это тревожный сигнал. - Есть ли блокирующая работа вне main thread? Вызовы базы данных, доступ к файлам и сеть должны выполняться на
Dispatchers.IOили на другом контролируемом dispatcher. - Что происходит, когда работу отменяют? Нажатие Back, смена экрана или отмена родительской job должны останавливать задачу чисто, и тест должен это подтверждать.
- Откуда берётся UI-состояние? Выберите один источник, обычно
StateFlowво ViewModel, и обновляйте экран из него, а не проталкивайте состояние из нескольких мест. - Не проскочил ли кто-то обратно на колбэки? Смешивать старые колбэки с той же фичей — значит делать код сложнее для чтения и тестирования.
Один маленький пример: экран поиска запускает запрос, когда пользователь печатает. Если корутина живёт во ViewModel, вызывает API не на main thread, обновляет одно uiState и отменяет старый поиск, когда приходит новый запрос, поведение остаётся понятным. Если одна ветка всё ещё использует колбэк от старого SDK, у вас уже две ментальные модели в одной фиче.
Мне нравится одно простое правило для merge: если ревьюер не может меньше чем за минуту указать владельца, dispatcher, путь отмены и источник UI-состояния, коду нужен ещё один проход. Эта минута экономит много исправлений позже.
Следующие шаги для вашей команды
Выберите один экран, который раздражает каждый день, и переведите только этот поток на этой неделе. Экрана логина, поиска или загрузки файла уже достаточно. Маленькие победы важнее большого плана миграции, который лежит в документе и никогда не доходит до продакшена.
Обычно командам помогают лучшие результаты, если они договариваются о нескольких правилах до того, как тронут больше кода. Держите правила короткими, чтобы люди помнили их в обычной работе.
- Решите, какой scope владеет каждым слоем. Например, ViewModel запускают UI-работу, а нижние слои отдают
suspend-функции или flows вместо того, чтобы запускать собственные долгие задачи. - Решите, как dispatchers попадают в приложение. Многие команды внедряют их вместо жёсткой записи, и это заметно упрощает тесты.
- Считайте отмену нормальным поведением. Если пользователь ушёл с экрана, код должен чисто остановить работу и не обновлять UI позже.
- Используйте один общий шаблон тестов ViewModel, чтобы каждый новый тест выглядел знакомо и запускался быстро.
Code review должен рано ловить проблемы с корутинами. Когда кто-то открывает pull request, проверьте несколько простых вещей: кто владеет scope, где происходит отмена и может ли код завершиться после того, как экран уже исчез. Если ответ неясен, код, скорее всего, всё ещё прячет привычки от колбэков под синтаксисом корутин.
Для тестирования не придумывайте новый стиль для каждой фичи. Выберите один setup для ViewModel, один подход к test dispatcher и один способ проверять состояния загрузки, успеха и ошибки. Такая одинаковость экономит реальное время уже после третьего или четвёртого перенесённого экрана.
Если вашей команде нужна помощь не только для одного рефактора, внешний взгляд может ускорить процесс. Oleg на oleg.is работает со стартапами и малыми и средними командами как Fractional CTO, а его опыт включает Android-архитектуру, production-системы и практичные AI-first подходы к инженерии. Такая поддержка особенно полезна, когда переход на корутины — часть более крупной уборки, а не просто смена синтаксиса.
Через неделю у вас должен быть один перенесённый поток, одна страница с правилами для команды и один повторяемый шаблон тестов. Этого уже достаточно, чтобы следующий экран было легче делать.