rzaripov.kz

Многопоточность в Kotlin для Android-приложений

Я Равиль Зарипов, разработчик из Алматы. В Android-проектах я применяю Kotlin и корутины, чтобы сетевые запросы, операции с базами данных и тяжёлые вычисления не задерживали интерфейс. Это особенно важно на устройствах среднего класса: лишняя работа главного потока быстро превращается в задержки касаний и прокрутки.

Между многопоточностью на уровне операционной системы и конкурентностью в Kotlin есть важная разница. Поток надёжно изолирует выполнение, но создавать его для каждого действия дорого. Корутина приостанавливает работу без блокировки потока, а диспетчер определяет, где её продолжить. На практике это позволяет выбирать подходящую среду для сети, вычислений и обновления экрана.

Потоки операционной системы и корутины

Класс Thread запускает отдельный поток ОС и подходит для длительного изолированного процесса. У него собственный стек вызовов, а завершение и ошибки требуют отдельной обработки. Корутина использует обычный стек вызовов Kotlin, приостанавливается в точках suspend и продолжает выполнение без создания нового потока.

Несколько корутин могут работать последовательно в одном потоке. Для параллельных вычислений диспетчер переключает потоки, а для сетевого ожидания освобождает текущий поток. Поэтому число корутин не равно числу потоков: сто тысяч ожидающих сетевых ответов задач не создадут сто тысяч потоков ОС. Сравнение по типу нагрузки помогает выбрать механизм осознанно.

Механизм Среда выполнения Подходящая задача Основное ограничение
Thread Собственный поток ОС Изолированный длительный процесс Управление жизненным циклом и переключениями
Dispatchers.Main Главный поток Android Обновление интерфейса Нельзя выполнять тяжёлые операции
Dispatchers.IO Пул для блокирующего ввода-вывода Сеть, файлы, базы данных Большое число блокирующих вызовов исчерпывает пул
Dispatchers.Default Пул вычислительных задач Обработка данных, шифрование, сжатие Параллелизм ограничен числом ядер

В Android код, управляющий интерфейсом, обычно работает через Dispatchers.Main. Тяжёлые расчёты переносят в Dispatchers.Default, а блокирующие операции с файлами, сетью и базами — в Dispatchers.IO. withContext возвращает управление исходному контексту после завершения блока, поэтому его удобно использовать вокруг отдельного этапа работы.

Выбор среды выполнения задач

Dispatchers.IO предназначен для блокирующего ввода-вывода, но сам по себе не ускоряет код. Если внутри него выполняется тяжёлое преобразование данных, вычислительную часть лучше перенести в Dispatchers.Default. Смешивание задач без анализа иногда приводит к тому, что пул ввода-вывода занят вычислениями, а вычислительный пул простаивает.

Одновременных запросов к базе полезно ограничивать. Например, limitedParallelism позволяет создать отдельную группу с небольшим пределом параллелизма, если используемая версия библиотеки предоставляет такой API. Фиксированное число следует выбирать после замеров: слишком большой лимит создаст давление на память и сеть, слишком маленький замедлит очередь.

Подход к ограничению ресурсов хорошо виден в материале о кустарниках для небольшого сада: каждому растению нужны подходящие условия, а число посадок зависит от площади и доступных ресурсов. В приложении аналогом служат размер очереди, лимит соединений и бюджет времени.

Структурированная конкурентность

Структурированная конкурентность связывает сроки фоновой работы с жизненным циклом вызывающего кода. coroutineScope ждёт завершения дочерних корутин и отменяет их при ошибке. supervisorScope применяют, когда независимые задачи должны переживать сбой соседней: неудача одного ребёнка не отменяет остальных.

async удобен для получения результата: несколько независимых запросов запускают параллельно, а await получает их значения. launch выбирают для событий и фоновых действий без ожидаемого значения. Если задачи связаны зависимостью, выполнять их параллельно нельзя: второй запрос может начаться раньше готовности данных первого.

Отдельный CoroutineScope не следует создавать в Activity или Fragment без ясного владельца и способа отмены. В Android удобнее viewModelScope для логики экрана и lifecycleScope для работы, привязанной к жизненному циклу компонента. GlobalScope лишён такой связи и способен продолжить задачу после ухода пользователя со страницы.

Общие изменяемые данные требуют синхронизации. Если состояние доступно нескольким корутинам, применяют Mutex или другие подходящие примитивы, а для отдельных счётчиков используют атомарные операции. Передача неизменяемых данных уменьшает риск гонок и упрощает воспроизведение ошибки.

Отмена задач и жизненный цикл

Отмена в Kotlin кооперативная: корутина должна сама проверить isActive или вызвать suspend-функцию, которая отреагирует на отмену. Блокирующий вызов внутри Dispatchers.IO не становится прерываемым лишь от того, что он выполняется в фоне. Долгий цикл поэтому разбивают на небольшие шаги и регулярно проверяют состояние задачи.

При уходе с экрана область lifecycleScope отменяет её корутины, а viewModelScope продолжает работу, пока существует ViewModel. Для сбора потоков данных в интерфейсе применяют repeatOnLifecycle с состоянием RESUMED: сбор приостанавливается, когда экран неактивен, и возобновляется без дублирования подписок.

Ошибки внутри launch не возвращаются вызывающему коду. Их обрабатывают через CoroutineExceptionHandler или явный try/catch, если ошибка ожидаема. CancellationException не следует проглатывать: отмена задачи — нормальный сценарий. Для независимого обновления кэша или аналитики удобно выделять отдельную область с supervisorScope.

Реактивное состояние и потоки данных

Flow описывает последовательность значений и остаётся холодным, пока у него есть подписчик: каждый новый сбор запускает исходный код заново. StateFlow хранит последнее значение и сразу передаёт его новым подписчикам. SharedFlow предназначен для событий, наблюдаемых несколькими получателями, и по умолчанию не хранит предыдущие значения.

Для состояния экрана StateFlow обычно удобнее: у него всегда есть актуальное значение, а повторная подписка не требует отдельного кэша. Если событие нельзя потерять, обычный SharedFlow без буфера подходит не всегда; потребуется настроить extraBufferCapacity или использовать Channel, учитывая время жизни подписчика.

Медленный потребитель не должен тормозить источник данных. buffer разделяет производство и обработку, а conflate оставляет только последнее значение, если промежуточные результаты не важны. Для разделяемого потока применяют shareIn или stateIn, чтобы не пересчитывать один и тот же источник для каждого экрана. Тяжёлое преобразование лучше выполнять в flowOn с подходящим диспетчером.

Тестирование и профилирование

Тесты конкурентного кода требуют контроля времени и диспетчеров. В kotlinx-coroutines-test используют runTest, StandardTestDispatcher и advanceUntilIdle, чтобы не ждать реальные секунды. Для кода, обращающегося к Dispatchers.Main, устанавливают тестовый главный диспетчер. Такой подход делает задержки воспроизводимыми и уменьшает число нестабильных проверок.

На реальном устройстве проверяют не только правильность результата, но и время отклика интерфейса. CPU Profiler показывает, где расходуются циклы, а System Trace помогает увидеть конкуренцию потоков и лишние пробуждения. StrictMode способен указать на дисковый или сетевой вызов в главном потоке. Замеры стоит проводить на медленном смартфоне и с типичным объёмом данных.

Нагрузочная проверка должна включать большой ответ сервера, медленную сеть и повторное открытие экрана. Одновременные запросы нередко создают больше конкуренции, чем требуется, поэтому сначала измеряют базовый сценарий, затем сравнивают варианты. Оценивают также расход батареи, температуру устройства и стабильность после длительного фонового выполнения.

Практические подходы и опыт разработчика

Надёжный Android-код не запускает фоновую работу без владельца, не оставляет необработанные ошибки и не переносит блокирующие вызовы на главный поток. Для каждого экрана можно выбрать viewModelScope, определить отменяемые этапы и хранить наблюдаемое состояние в StateFlow. Эти решения проще проверять, чем набор корутин с разными глобальными и локальными областями.

Мой опыт выходит за пределы Android: я разрабатываю приложения для iOS, Windows, macOS и Linux, используя Delphi, FireMonkey, Kotlin, Swift, PHP и технологии баз данных. Переносимые архитектурные принципы — явные границы задач, отмена, наблюдаемость и разделение представления с логикой — остаются полезными на разных платформах. Материалы о проектах и используемых технологиях собраны в https://rzaripov.kz/catalog.php.

В заметках блога я разбираю решения, которые затем использую в рабочих приложениях, и связываю их с примерами кода. Практическая ценность многопоточности проявляется не в количестве запущенных корутин, а в предсказуемом времени отклика, понятных отказах и отсутствии фоновых операций, продолжающихся после закрытия экрана.