Многопоточность в Android: от основ до Kotlin Coroutines

Курс охватывает эволюцию асинхронного программирования в Android, обучая созданию отзывчивых приложений. Вы изучите работу с потоками, классические инструменты SDK и современные подходы с использованием Coroutines и Flow.

1. Введение в многопоточность: Main Thread, ошибки ANR и базовые примитивы синхронизации

Введение в многопоточность: Main Thread, ошибки ANR и базовые примитивы синхронизации

Добро пожаловать в курс «Многопоточность в Android: от основ до Kotlin Coroutines». Мы начинаем наше путешествие с фундаментальных понятий, без которых невозможно построить отзывчивое и стабильное приложение. Даже если вы уже используете Coroutines или RxJava, понимание того, что происходит «под капотом» на уровне операционной системы и виртуальной машины Java, критически важно для отладки сложных проблем.

Процессы и потоки: в чем разница?

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

Процесс — это экземпляр программы во время выполнения. У него есть свое собственное адресное пространство памяти, которое изолировано от других процессов. Если одно приложение «падает», оно обычно не тянет за собой другие.

Поток (Thread) — это наименьшая единица выполнения внутри процесса. Процесс может содержать один или несколько потоков. Ключевая особенность потоков в том, что они разделяют общую память процесса. Это делает взаимодействие между ними быстрым, но порождает главную проблему многопоточности — конкуренцию за ресурсы.

!Схема процесса, содержащего несколько потоков с общей и локальной памятью

Main Thread: Сердце Android-приложения

Когда вы запускаете Android-приложение, система создает новый процесс Linux и один поток выполнения. Этот поток называется Main Thread (главный поток) или UI Thread (поток пользовательского интерфейса).

Почему он так важен?

В Android (как и в большинстве UI-фреймворков) работа с графическим интерфейсом не является потокобезопасной. Это означает, что все манипуляции с View (нажатия кнопок, отрисовка текста, анимации) должны происходить строго в главном потоке.

Main Thread работает в бесконечном цикле (Event Loop), ожидая событий от пользователя или системы:

  • Пользователь коснулся экрана.
  • Система поместила событие касания в очередь сообщений (Message Queue).
  • Main Thread достал событие, обработал его (вызвал onClick) и перерисовал экран.
  • Если Main Thread занят чем-то другим, он не может обновить интерфейс. Приложение перестает реагировать на действия пользователя.

    ANR: Application Not Responding

    Если вы заблокируете главный поток на длительное время, система покажет диалоговое окно ANR (Application Not Responding). Это худший опыт для пользователя, который часто приводит к удалению приложения.

    Когда возникает ANR?

    Согласно документации Android, ANR возникает в двух основных случаях:

  • Нет реакции на событие ввода (нажатие клавиши, касание экрана) в течение 5 секунд.
  • BroadcastReceiver не завершил выполнение в течение определенного времени (обычно 10-60 секунд, в зависимости от типа).
  • Пример блокировки Main Thread

    Представим, что мы решили скачать файл прямо внутри обработчика нажатия кнопки:

    Пока метод downloadLargeFile() выполняется, Main Thread заблокирован. Он не может перерисовать кнопку (показать эффект нажатия) и не может обработать следующие касания. Интерфейс «замерзает».

    > В современных версиях Android попытка выполнить сетевой запрос в главном потоке вызовет NetworkOnMainThreadException еще до возникновения ANR. Это защитный механизм системы.

    Создание потоков в Java

    Чтобы избежать ANR, тяжелые операции (сеть, база данных, сложные вычисления) нужно выносить в фоновые потоки. В Java (и Kotlin, так как он работает на JVM) базовым классом для этого является Thread.

    Существует два основных способа создания потока:

    1. Наследование от класса Thread

    2. Реализация интерфейса Runnable (Предпочтительный способ)

    Этот способ лучше, так как он отделяет задачу (Runnable) от исполнителя (Thread) и позволяет наследовать класс от чего-то другого.

    Важно: Метод start() запускает новый поток, который вызывает метод run(). Если вы вызовете run() напрямую, код выполнится в текущем потоке, и никакой многопоточности не будет.

    Проблемы общего состояния (Shared State)

    Как только у вас появляется два потока, работающих с одними и теми же данными, возникает риск состояния гонки (Race Condition).

    Рассмотрим классический пример счетчика:

    Если два потока одновременно вызовут increment(), результат может быть непредсказуемым. Почему? Потому что операция count++ не является атомарной (неделимой). Для процессора это три действия:

  • Read: Прочитать текущее значение count из памяти в регистр.
  • Modify: Увеличить значение в регистре на 1.
  • Write: Записать новое значение обратно в память.
  • Представим ситуацию с помощью формулы:

    Где — конечное значение, — начальное значение, а — шаг инкремента.

    Если два потока (А и Б) читают одновременно:

  • Поток А читает 0.
  • Поток Б читает 0.
  • Поток А вычисляет и пишет 1.
  • Поток Б вычисляет и пишет 1.
  • В итоге вместо 2 мы получаем 1. Данные потеряны.

    !Иллюстрация Race Condition при инкременте переменной

    Базовые примитивы синхронизации

    Чтобы решить проблему Race Condition, нам нужно координировать доступ потоков к общим ресурсам. В Java для этого есть встроенные механизмы.

    Ключевое слово synchronized

    synchronized гарантирует, что в один момент времени только один поток может выполнять указанный блок кода или метод. Это достигается с помощью механизма, называемого монитором (или intrinsic lock).

    Теперь, если Поток А вошел в increment(), Поток Б будет ждать (блокироваться), пока А не выйдет из метода.

    Ключевое слово volatile

    Проблема многопоточности — это не только атомарность, но и видимость (visibility). Современные процессоры имеют кэши (L1, L2, L3). Поток может изменить переменную в своем локальном кэше, но не записать её сразу в основную оперативную память (RAM). Другой поток, работающий на другом ядре, может не увидеть этого изменения.

    Ключевое слово volatile говорит компилятору и процессору: «Не кэшируй эту переменную. Всегда читай и пиши её прямо в основную память».

    Важно: volatile гарантирует видимость, но НЕ гарантирует атомарность. Для count++ использование volatile не спасет от Race Condition, так как операция инкремента все равно состоит из трех шагов.

    Deadlock (Взаимная блокировка)

    Использование синхронизации порождает новую опасность — Deadlock.

    Это ситуация, когда Поток 1 держит ресурс А и ждет ресурс Б, а Поток 2 держит ресурс Б и ждет ресурс А. Ни один из них не может продолжить работу. Приложение зависает.

    !Метафора взаимной блокировки (Deadlock)

    Заключение

    Мы разобрали фундамент многопоточности: * Main Thread отвечает за UI, его нельзя блокировать. * ANR — результат блокировки Main Thread. * Thread и Runnable — базовые способы запуска кода параллельно. * Race Condition возникает при одновременном доступе к данным. * synchronized и volatile помогают управлять доступом и видимостью данных.

    Однако, ручное управление потоками и синхронизацией сложно и чревато ошибками. В следующей статье мы рассмотрим инструменты Android, созданные для упрощения этой работы: Handler, Looper и MessageQueue.

    2. Классические механизмы Android: архитектура Handler, Looper, MessageQueue и использование ExecutorService

    Классические механизмы Android: архитектура Handler, Looper, MessageQueue и использование ExecutorService

    В предыдущей статье мы рассмотрели, как создавать потоки с помощью Thread и Runnable, и узнали, почему нельзя блокировать Main Thread. Однако, использование «голых» потоков в Android сопряжено с трудностями: как передать результат вычислений из фонового потока обратно в UI-поток для обновления интерфейса? Как организовать очередь задач, чтобы не создавать тысячи потоков?

    В этой статье мы разберем фундаментальную архитектуру Android, на которой строится вся работа UI — связку Handler, Looper и MessageQueue, а также научимся управлять пулами потоков с помощью ExecutorService.

    Архитектура цикла сообщений (Message Loop)

    Android — это событийно-ориентированная система. Приложение большую часть времени ожидает событий: касаний экрана, прихода сетевых пакетов или системных таймеров. Чтобы обрабатывать эти события последовательно и в одном потоке (Main Thread), используется паттерн Message Loop.

    Этот механизм состоит из трех ключевых компонентов:

  • MessageQueue (Очередь сообщений) — это структура данных, работающая по принципу FIFO (First In, First Out — первым вошел, первым вышел). Она хранит список задач (Runnable) или сообщений (Message), которые нужно выполнить.
  • Looper (Цикл) — это «рабочий», который бегает по кругу. Он берет следующее сообщение из MessageQueue, выполняет его и переходит к следующему. Если очередь пуста, Looper блокирует поток и ждет появления новых сообщений.
  • Handler (Обработчик) — это интерфейс для взаимодействия с очередью. Он позволяет отправлять сообщения в очередь (из любого потока) и обрабатывать их (в потоке, к которому привязан Looper).
  • !Схема циркуляции сообщений: Поток -> Handler -> MessageQueue -> Looper -> Handler

    Как это работает вместе?

    Представьте конвейер на заводе: * MessageQueue — это лента конвейера. * Looper — это механизм, который движет ленту и подает детали рабочему. * Handler — это рабочий, который, с одной стороны, кладет детали на ленту (из других цехов), а с другой — обрабатывает их, когда они доезжают до него.

    Важное правило: У одного потока может быть только один Looper (и одна MessageQueue), но может быть много Handler, привязанных к этому Looper.

    Главный поток под микроскопом

    Когда приложение запускается, система автоматически создает Main Thread и инициализирует в нем Looper. Упрощенно это выглядит так:

    Метод Looper.loop() запускает бесконечный цикл for (;;). Именно поэтому программа не завершается после выполнения метода main, а продолжает работать, ожидая нажатий на экран.

    Использование Handler для связи с UI

    Чаще всего Handler используется для того, чтобы выполнить код в главном потоке из фонового. Для этого мы создаем экземпляр Handler, привязанный к Main Looper.

    Существует два основных способа передачи задач через Handler:

    1. Метод post(Runnable)

    Самый простой способ. Вы передаете кусок кода (Runnable), который будет выполнен в потоке, к которому привязан Handler.

    2. Метод sendMessage(Message)

    Используется, когда нужно передать данные, а не исполняемый код, или когда требуется более сложная логика обработки. Объект Message — это контейнер для данных (числа, строки, объекты).

    > Совет: Всегда используйте handler.obtainMessage() вместо new Message(). Это позволяет системе переиспользовать объекты сообщений из глобального пула, что снижает нагрузку на сборщик мусора (Garbage Collector).

    Проблема утечек памяти (Memory Leaks)

    Классическая ошибка новичка — создание анонимного класса Handler или Runnable внутри Activity.

    В Java нестатические внутренние и анонимные классы неявно хранят ссылку на внешний класс (в данном случае Activity). Если вы отправите сообщение с задержкой (postDelayed) на 10 минут, а пользователь закроет экран через 5 секунд, Handler (и сообщение в очереди) будут удерживать Activity в памяти. Сборщик мусора не сможет её удалить.

    Решение: Использовать статические внутренние классы со слабой ссылкой (WeakReference) на Activity или очищать очередь сообщений в методе onDestroy():

    ExecutorService: Управление потоками

    Создавать новый поток (new Thread()) для каждой задачи — плохая практика. Создание потока — дорогая операция для ОС, потребляющая память и процессорное время. Если создать 1000 потоков, приложение, скорее всего, упадет с ошибкой OutOfMemoryError.

    Для решения этой проблемы используется ExecutorService — механизм пулов потоков.

    Типы пулов потоков

    Класс Executors предоставляет фабричные методы для создания пулов:

  • FixedThreadPool(n) — пул с фиксированным количеством потоков. Если задач больше, чем потоков, они ждут в очереди. Идеально для стабильной нагрузки.
  • CachedThreadPool() — создает новые потоки по мере необходимости, но переиспользует освободившиеся. Подходит для множества коротких задач.
  • SingleThreadExecutor() — пул с одним потоком. Гарантирует, что задачи выполняются последовательно.
  • Пример использования

    Математика выбора размера пула

    Сколько потоков должно быть в пуле? Если слишком мало — процессор простаивает. Если слишком много — система тратит время на переключение контекста (Context Switching).

    Оптимальное количество потоков можно рассчитать по формуле:

    Где: * — оптимальное количество потоков. * — количество доступных ядер процессора (можно получить через Runtime.getRuntime().availableProcessors()). * — целевая загрузка процессора (от 0 до 1, например, 1 для 100% загрузки). * — время ожидания (Wait time), например, ожидание ответа от сервера или диска. * — время вычислений (Compute time), когда процессор реально работает.

    Если задача чисто вычислительная (например, обработка видео), то , и формула упрощается до (добавляем 1 на случай промаха кэша). Если задача связана с сетью (IO-bound), отношение велико, и потоков нужно больше.

    Заключение

    Мы разобрали классические инструменты Android: * Looper крутит бесконечный цикл и разгребает очередь. * Handler позволяет отправлять задачи в этот цикл (особенно в Main Thread). * MessageQueue хранит эти задачи. * ExecutorService помогает эффективно управлять множеством фоновых задач, переиспользуя потоки.

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

    3. Основы Kotlin Coroutines: диспетчеры, CoroutineScope и принципы структурной конкурентности

    Основы Kotlin Coroutines: диспетчеры, CoroutineScope и принципы структурной конкурентности

    В предыдущих статьях мы разобрали, как работает многопоточность на низком уровне (Thread, Runnable) и какие инструменты предлагает Android SDK (Handler, Looper, ExecutorService). Мы выяснили, что создание потоков — дорогая операция, а управление ими через колбэки (callbacks) быстро превращает код в нечитаемое месиво, известное как «Callback Hell».

    Сегодня мы переходим к современному стандарту асинхронного программирования в Android — Kotlin Coroutines. Это не просто библиотека, а парадигма, которая позволяет писать асинхронный код так, будто он выполняется последовательно.

    Что такое корутина?

    Часто корутины называют «легковесными потоками» (lightweight threads). Но это определение, хоть и верное технически, может сбить с толку. Давайте разберемся глубже.

    Поток (Thread) управляется операционной системой. Переключение между потоками (Context Switching) требует сохранения регистров процессора, сброса кэшей и других дорогих операций.

    Корутина (Coroutine) — это блок кода, который может быть приостановлен (suspended) и возобновлен (resumed) позже, не блокируя поток, в котором он выполняется. Корутины управляются не системой, а самой библиотекой Kotlin и виртуальной машиной.

    !Иллюстрация того, как множество корутин могут жить и выполняться внутри одного системного потока, не блокируя его.

    Ключевое отличие: Block vs Suspend

    Это самый важный концепт для понимания:

  • Блокировка (Blocking): Поток останавливается и ждет. Он занимает память, но не выполняет полезной работы. Если это Main Thread — мы получаем ANR.
  • Приостановка (Suspending): Корутина сохраняет свое текущее состояние (локальные переменные, точку выполнения) и «освобождает» поток. Поток может заняться выполнением другой корутины. Когда результат (например, ответ от сервера) готов, корутина возобновляется.
  • Suspend-функции

    В Kotlin магия начинается с ключевого слова suspend. Оно маркирует функцию, которая может приостановить выполнение корутины.

    Правило: suspend функцию можно вызвать только из другой suspend функции или из корутины.

    Диспетчеры (Dispatchers): Где выполняется код?

    Когда мы запускаем корутину, мы должны сказать ей, на каком пуле потоков она должна работать. За это отвечают Диспетчеры.

    В Android разработке мы используем три основных диспетчера:

    1. Dispatchers.Main

    Этот диспетчер привязан к главному потоку (Main Thread). Он использует тот самый Handler(Looper.getMainLooper()), который мы изучали в прошлой статье.

    * Для чего: Обновление UI, обработка нажатий, вызов легковесных функций. * Чего нельзя делать: Сетевые запросы, чтение файлов, сложные вычисления.

    2. Dispatchers.IO

    Оптимизирован для операций ввода-вывода (Input/Output). Использует эластичный пул потоков, который может расширяться при необходимости.

    * Для чего: Работа с сетью (Retrofit), базой данных (Room), чтение/запись файлов. * Особенность: Лимит потоков по умолчанию равен 64 или количеству ядер (если их больше).

    3. Dispatchers.Default

    Используется для вычислительно сложных задач (CPU-intensive). Использует фиксированный пул потоков.

    * Для чего: Обработка больших списков, парсинг JSON, сложные математические расчеты, обработка изображений.

    Количество потоков в Dispatchers.Default рассчитывается по формуле:

    Где — количество потоков в пуле, — функция выбора максимального значения, — минимальное количество потоков, а — количество доступных ядер процессора.

    4. Dispatchers.Unconfined

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

    CoroutineScope: Жизненный цикл корутины

    В отличие от потоков, которые могут «потеряться» и работать вечно (создавая утечки памяти), корутины в Kotlin требуют области видимости (Scope). Вы не можете запустить корутину «в никуда».

    CoroutineScope выполняет две главные задачи:

  • Управляет жизненным циклом корутин.
  • Хранит ссылку на Job (работу) и CoroutineContext.
  • В Android мы обычно используем готовые скоупы:

    * viewModelScope: Привязан к ViewModel. Автоматически отменяет все корутины, когда ViewModel уничтожается (onCleared). * lifecycleScope: Привязан к LifecycleOwner (Activity или Fragment). Отменяется при уничтожении компонента.

    Структурная конкурентность (Structured Concurrency)

    Это философия, на которой построены Kotlin Coroutines. Она решает проблемы, с которыми мы сталкивались при использовании Thread и ExecutorService (потерянные потоки, сложность обработки ошибок).

    Основные принципы структурной конкурентности:

  • Иерархия: Корутины выстраиваются в дерево «родитель-ребенок». Если вы запускаете корутину B внутри корутины A, то B становится дочерней для A.
  • Зависимость жизни: Родительская корутина не может завершиться, пока не завершатся все её дети.
  • Распространение отмены: Если отменить родителя, отменятся все дети. Если отменить ребенка, родитель (по умолчанию) не отменяется.
  • Распространение ошибок: Если в дочерней корутине происходит исключение, оно (по умолчанию) передается родителю и отменяет его, а также всех остальных детей этого родителя.
  • !Схема распространения ошибки и отмены в структурной конкурентности.

    Билдеры корутин: launch и async

    Чтобы создать новую корутину внутри CoroutineScope, используются функции-билдеры.

    1. launch

    Принцип «запустил и забыл» (fire-and-forget). Возвращает объект Job, который можно использовать для ручной отмены.

    * Возвращает: Job * Результат: Не возвращает полезного результата вычислений. * Использование: Обновление UI, запись в логи, аналитика.

    2. async

    Используется, когда нам нужен результат выполнения. Запускает корутину и возвращает объект Deferred (аналог Future в Java или Promise в JS).

    * Возвращает: Deferred<T> * Результат: Чтобы получить результат T, нужно вызвать метод .await(). * Использование: Параллельные сетевые запросы.

    Переключение контекста: withContext

    Часто нам нужно начать работу в одном потоке, а продолжить в другом. Например, скачать файл (IO) и показать его имя (Main). Для этого используется функция withContext.

    withContext — это suspend-функция, которая меняет диспетчер для блока кода, выполняет его, возвращает результат и автоматически возвращается в исходный диспетчер.

    Это гораздо чище и безопаснее, чем использование AsyncTask или вложенных колбэков.

    Job и управление отменой

    Корутины поддерживают кооперативную отмену. Это значит, что если вы вызвали job.cancel(), корутина не остановится мгновенно, если она выполняет активные вычисления.

    Корутина должна проверять, не отменена ли она. Стандартные функции (delay, withContext, await) делают это автоматически. Но если вы пишете свой тяжелый цикл, нужно делать это вручную:

    Заключение

    Kotlin Coroutines предоставляют мощный и гибкий инструмент для работы с многопоточностью.

    * Suspend-функции позволяют писать асинхронный код в синхронном стиле. * Диспетчеры (Main, IO, Default) четко разделяют зоны ответственности потоков. * CoroutineScope и Структурная конкурентность защищают от утечек памяти и потерянных задач.

    В следующей статье мы углубимся в обработку исключений в корутинах (CoroutineExceptionHandler, supervisorScope) и рассмотрим реактивные потоки данных с помощью Kotlin Flow.

    4. Реактивное программирование и потоки данных: глубокое погружение в Kotlin Flow, StateFlow и SharedFlow

    Реактивное программирование и потоки данных: глубокое погружение в Kotlin Flow, StateFlow и SharedFlow

    В предыдущей статье мы познакомились с основами Kotlin Coroutines, научились запускать асинхронные задачи и возвращать результаты с помощью suspend функций. Однако, suspend функция имеет одно существенное ограничение: она возвращает одно значение.

    Но что, если нам нужно получать поток данных? Например, отслеживать прогресс загрузки файла, получать координаты GPS в реальном времени или слушать ввод пользователя в поисковой строке? Здесь на сцену выходит Kotlin Flow.

    От одиночных значений к потокам

    Представьте, что suspend функция — это запрос к серверу: вы отправили запрос, подождали, получили ответ. Это телефонный звонок.

    Flow (Поток) — это подписка на журнал. Вы оформляете подписку один раз, и новые выпуску приходят к вам периодически, пока вы не отмените подписку или журнал не закроется.

    В мире реактивного программирования это называется потоком данных (Data Stream).

    Холодные потоки (Cold Streams)

    Обычный Flow в Kotlin является холодным. Это означает, что код внутри создателя потока (builder) не выполняется до тех пор, пока кто-то не начнет этот поток «собирать» (collect).

    Поток запустится только тогда, когда мы вызовем терминальный оператор, например collect:

    kotlin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)

    lifecycleScope.launch { // Блок приостанавливается, когда Lifecycle опускается ниже состояния STARTED // и перезапускается, когда снова становится STARTED repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> // Безопасное обновление UI render(state) } } } } ```

    Когда пользователь сворачивает приложение, подписка на поток отменяется. Когда возвращается — создается заново. Это экономит батарею и предотвращает ошибки.

    Заключение

    Мы разобрали ключевые концепции реактивного программирования в Kotlin:

    * Cold Flow — это чертеж или рецепт. Данные начинают идти только при подписке. * Hot Flow (StateFlow, SharedFlow) — работают независимо от подписчиков. * StateFlow — идеален для хранения состояния экрана (замена LiveData). * SharedFlow — идеален для разовых событий (навигация, уведомления). * Backpressure — проблема разницы скоростей, решаемая буферизацией или пропуском данных.

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

    5. Управление сложными фоновыми задачами с помощью WorkManager и лучшие практики архитектуры

    Управление сложными фоновыми задачами с помощью WorkManager и лучшие практики архитектуры

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

    Корутины, запущенные в viewModelScope или lifecycleScope, привязаны к жизненному циклу компонентов. Если пользователь закроет приложение (свайпнет его из списка недавних) или система убьёт процесс для освобождения памяти, ваши корутины будут отменены. Загрузка файла прервётся, синхронизация базы данных не завершится.

    Для задач, которые должны быть выполнены гарантированно, даже если приложение закрыто или устройство перезагружено, Google предлагает решение — WorkManager.

    Эволюция фоновой работы в Android

    Раньше разработчики использовали Service, IntentService или AlarmManager для фоновых задач. Но с каждой новой версией Android (начиная с Marshmallow и Doze Mode) система всё агрессивнее ограничивает фоновую активность для экономии заряда батареи. Старые методы стали ненадежными или сложными в поддержке.

    WorkManager — это библиотека из пакета Android Jetpack, которая абстрагирует все сложности. Она сама выбирает оптимальный способ выполнения задачи (JobScheduler, AlarmManager или BroadcastReceiver) в зависимости от версии Android и состояния устройства.

    Когда использовать WorkManager?

    Главное правило: используйте WorkManager для отложенной (deferrable) и гарантированной (guaranteed) работы.

    * Отложенная: Задачу не обязательно выполнять прямо сейчас. Она может подождать 10 минут или час. * Гарантированная: Задача должна выполниться, даже если пользователь перезагрузит телефон (например, отправка логов на сервер, синхронизация контактов, сжатие видео).

    Если вам нужно скачать файл и показать прогресс пользователю прямо сейчас — используйте Coroutines (или Foreground Service, если скачивание очень долгое). Если нужно отправить сообщение в чат — используйте Coroutines. Но если нужно сделать бэкап базы данных в 3 часа ночи, когда телефон на зарядке — это работа для WorkManager.

    !Диаграмма выбора инструмента для фоновой работы в зависимости от требований задачи

    Основные компоненты WorkManager

    Архитектура WorkManager строится на четырех китах:

  • Worker (Рабочий): Класс, где вы пишете код, который нужно выполнить.
  • WorkRequest (Запрос на работу): Конфигурация задачи (одноразовая или периодическая, условия запуска).
  • WorkManager: Системный сервис, который принимает запросы и ставит их в очередь.
  • WorkInfo: Информация о статусе задачи (BLOCKED, RUNNING, SUCCEEDED, FAILED).
  • Интеграция с Coroutines: CoroutineWorker

    Поскольку наш курс посвящен многопоточности и корутинам, нас интересует специальная реализация рабочего — CoroutineWorker. Она позволяет вызывать suspend функции внутри метода doWork().

    Создание Worker

    Обратите внимание на возвращаемые значения: * Result.success() — задача выполнена. * Result.failure() — задача провалена окончательно. * Result.retry() — задача провалена, но нужно попробовать снова (WorkManager сам запланирует повтор).

    Экспоненциальная задержка (Exponential Backoff)

    Когда вы возвращаете Result.retry(), WorkManager не запускает задачу мгновенно, чтобы не перегружать сеть или сервер. Он использует стратегию экспоненциальной задержки.

    Время ожидания следующей попытки рассчитывается по формуле:

    Где: * — время ожидания перед следующей попыткой. * — базовое время задержки (по умолчанию 10 или 30 секунд, задается разработчиком). * — номер попытки (начиная с 1).

    Например, если секунд:

  • Первая попытка провалилась.
  • Вторая попытка через секунд.
  • Третья попытка через секунд.
  • Это позволяет системе эффективно расходовать ресурсы при проблемах с сетью.

    Запуск задачи: WorkRequest и Constraints

    Чтобы запустить наш SyncWorker, нужно создать запрос. Мы можем добавить Constraints (ограничения) — условия, при которых задача может выполняться.

    Теперь система гарантирует, что код внутри SyncWorker выполнится, но только когда телефон будет на зарядке и подключен к Wi-Fi. Если пользователь выдернет шнур зарядки во время выполнения, CoroutineWorker получит сигнал отмены (CancellationException), и задача остановится, чтобы перезапуститься позже.

    Цепочки задач (Chaining Work)

    Одна из самых мощных возможностей WorkManager — построение графов зависимостей. Вы можете выполнять задачи последовательно или параллельно.

    Представьте, что нам нужно:

  • Сжать три изображения (параллельно).
  • Когда все три готовы — заархивировать их в ZIP (последовательно).
  • Отправить ZIP на сервер (последовательно).
  • !Визуализация параллельного и последовательного выполнения задач в WorkManager

    Лучшие практики архитектуры

    Внедрение WorkManager в чистое приложение требует соблюдения архитектурных принципов.

    1. Не пишите бизнес-логику в Worker

    Класс Worker — это просто точка входа, как Activity или Fragment. Он не должен содержать сложной логики. Его задача — получить зависимости и вызвать метод UseCase или Repository.

    Плохо:

    Хорошо:

    2. Наблюдение за состоянием через LiveData или Flow

    UI не должен знать о деталях выполнения, но должен реагировать на статус. WorkManager позволяет подписаться на изменения по ID или тегу задачи.

    3. Уникальная работа (Unique Work)

    Частая ошибка — запускать синхронизацию каждый раз при открытии приложения, создавая дубликаты задач. Используйте enqueueUniqueWork.

    Политика KEEP игнорирует новый запрос, если старый еще не выполнен. Политика REPLACE отменяет старый и запускает новый.

    Заключение

    WorkManager — это фундамент надежного Android-приложения. Он закрывает пробел между мгновенными операциями (Coroutines) и долгоживущими системными процессами.

    Мы разобрали: * Разницу между немедленным и гарантированным выполнением. * Использование CoroutineWorker для интеграции с корутинами. * Механизм Result.retry() и экспоненциальную задержку. * Построение цепочек задач.

    Теперь ваш арсенал многопоточности полон: Thread и Handler для понимания основ, Coroutines и Flow для реактивного UI и сетевых запросов, и WorkManager для надежной фоновой работы. В следующей, заключительной части курса, мы поговорим о том, как тестировать весь этот асинхронный код, чтобы спать спокойно, пока ваши воркеры трудятся в ночи.