Руководства по асинхронному и неблокируемому API Android

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

Асинхронные API — один из способов достижения неблокируемого поведения. Асинхронные API допускают определённую форму продолжения или обратного вызова, которая уведомляется о завершении операции или о других событиях в ходе её выполнения.

Существует две основные причины написания асинхронного API:

  • Одновременное выполнение нескольких операций, при этом N-я операция должна быть инициирована до завершения N-1-й операции.
  • Избегать блокировки вызывающего потока до завершения операции.

Kotlin активно продвигает структурированный параллелизм — ряд принципов и API, основанных на функциях приостановки, которые разделяют синхронное и асинхронное выполнение кода и блокировку потоков. Функции приостановки являются неблокируемыми и синхронными .

Приостановить функции:

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

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

Ожидания разработчиков от асинхронных API

Если не указано иное, следующие ожидания написаны с точки зрения API, не требующих приостановки.

API, которые принимают обратные вызовы, обычно асинхронны.

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

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

Асинхронные API должны возвращаться как можно быстрее

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

Многие операции и сигналы жизненного цикла могут быть вызваны платформой или библиотеками по требованию, и ожидать от разработчика наличия глобальных знаний обо всех потенциальных точках вызова для своего кода неразумно. Например, Fragment может быть добавлен в FragmentManager в синхронной транзакции в ответ на View и макет View, когда содержимое приложения должно быть заполнено для заполнения доступного пространства (например, RecyclerView ). LifecycleObserver отвечающий на обратный вызов жизненного цикла onStart этого фрагмента, может обоснованно выполнить здесь однократные операции запуска, и это может быть критически важным для создания кадра анимации без рывков. Разработчик всегда должен быть уверен, что вызов любого асинхронного API в ответ на подобные обратные вызовы жизненного цикла не станет причиной рывков кадра.

Это подразумевает, что работа, выполняемая асинхронным API перед возвратом, должна быть максимально лёгкой: необходимо создать запись запроса и связанного с ним обратного вызова и зарегистрировать её в механизме выполнения, который выполняет эту работу в наибольшей степени. Если регистрация асинхронной операции требует межпроцессного взаимодействия (IPC), реализация API должна принять все необходимые меры для удовлетворения этого ожидания разработчика. Это может включать в себя одно или несколько из следующих действий:

  • Реализация базового IPC как одностороннего вызова связующего звена
  • Выполнение двустороннего вызова привязки к системному серверу, при котором завершение регистрации не требует захвата высококонкурентной блокировки.
  • Отправка запроса в рабочий поток в процессе приложения для выполнения блокирующей регистрации через IPC

Асинхронные API должны возвращать void и выдавать исключения только для недопустимых аргументов.

Асинхронные API должны передавать все результаты запрошенной операции в предоставленный обратный вызов. Это позволяет разработчику реализовать единый путь кода для обработки успешного выполнения и ошибок.

Асинхронные API могут проверять аргументы на значение null и выдавать исключение NullPointerException , либо проверять, находятся ли предоставленные аргументы в допустимом диапазоне, и выдавать исключение IllegalArgumentException . Например, функция, принимающая число float в ​​диапазоне от 0 до 1f , может проверить, находится ли параметр в этом диапазоне, и выдать исключение IllegalArgumentException если он выходит за его пределы, или короткая String может быть проверена на соответствие допустимому формату, например, содержащему только буквы и цифры. (Помните, что системный сервер никогда не должен доверять процессу приложения! Любая системная служба должна дублировать эти проверки в самой системной службе.)

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

  • Конечный сбой запрошенной операции
  • Исключения безопасности при отсутствии авторизации или разрешений, необходимых для завершения операции
  • Превышена квота на выполнение операции
  • Процесс приложения недостаточно активен для выполнения операции.
  • Необходимое оборудование отключено.
  • Сбои сети
  • Тайм-ауты
  • Смерть Binder или недоступность удаленного процесса

Асинхронные API должны предоставлять механизм отмены

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

Жесткие ссылки на обратные вызовы, предоставляемые вызывающей стороной, должны быть удалены.

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

Механизм выполнения, выполняющий работу для вызывающего объекта, может остановить эту работу.

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

Особые указания для кэшированных или зависших приложений

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

  1. Процессы и жизненный цикл приложения : процесс приложения-получателя может находиться в кэшированном состоянии.
  2. Заморозка кэшированных приложений : процесс приложения-получателя может быть заморожен.

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

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

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

В обзоре:

  • Вам следует рассмотреть возможность приостановки отправки обратных вызовов приложения, пока процесс приложения кэшируется.
  • Вам ОБЯЗАТЕЛЬНО необходимо приостановить отправку обратных вызовов приложения, пока процесс приложения заморожен.

Отслеживание состояния

Чтобы отслеживать, когда приложения переходят в состояние кэширования или выходят из него:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

Чтобы отслеживать, когда приложения заморожены или разморожены:

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

Стратегии возобновления диспетчеризации обратных вызовов приложения

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

Например:

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

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

Например:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

callback.foo() вызывается только если процесс не заморожен.

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

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

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

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

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

Состояние может быть выражено более сложными данными. Рассмотрим гипотетический API для уведомлений приложений о сетевых интерфейсах:

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

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

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

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

Рекомендации по документации для разработчиков

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

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

Ожидания разработчиков относительно приостановки API

Разработчики, знакомые со структурированным параллелизмом Kotlin, ожидают следующего поведения от любого API приостановки:

Функции приостановки должны завершить всю связанную работу перед возвратом или выбросом.

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

Функции приостановки должны вызывать только параметры обратного вызова на месте.

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

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

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

Функции приостановки должны поддерживать отмену заданий kotlinx.coroutines.

Любая предлагаемая функция приостановки должна взаимодействовать с функцией отмены задания, как определено в kotlinx.coroutines . Если вызывающее задание текущей операции отменяется, функция должна как можно скорее возобновить выполнение с исключением CancellationException , чтобы вызывающий объект мог очиститься и продолжить работу как можно скорее. Это автоматически обрабатывается suspendCancellableCoroutine и другими API приостановки, предлагаемыми kotlinx.coroutines . Реализации библиотеки, как правило, не должны использовать suspendCoroutine напрямую, поскольку по умолчанию она не поддерживает такое поведение отмены.

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

Не рекомендуется полностью приостанавливать функцию блокировки для переключения потоков.

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

Функции приостановки, которые принимают необязательный параметр CoroutineContext или Dispatcher только для переключения на этот диспетчер для выполнения блокирующей работы, должны вместо этого предоставлять базовую блокирующую функцию и рекомендовать вызывающим разработчикам использовать собственный вызов withContext для направления работы выбранному диспетчеру.

Классы, запускающие сопрограммы

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

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

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

Предоставление suspend fun для выполнения параллельных задач позволяет вызывающему объекту вызывать операцию в собственном контексте, устраняя необходимость в управлении CoroutineScope MyClass . Сериализация обработки запросов упрощается, а состояние часто может существовать в виде локальных переменных handleRequests , а не в виде свойств класса, которые в противном случае потребовали бы дополнительной синхронизации.

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

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

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

Также может быть предоставлен метод join() , позволяющий пользовательскому коду ожидать завершения любой оставшейся параллельной работы, выполняемой объектом. (Сюда может входить очистка, выполняемая путем отмены операции.)

suspend fun join() {
    myJob.join()
}

Именование терминальных операций

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

Используйте close() , когда текущие операции могут быть завершены, но никакие новые операции не могут быть начаты после возврата вызова close() .

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

Конструкторы классов принимают CoroutineContext, а не CoroutineScope

Когда запрещается запускать объекты напрямую в предоставленную родительскую область, пригодность CoroutineScope в качестве параметра конструктора теряется:

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

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

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

Параметры CoroutineContext по умолчанию — EmptyCoroutineContext

Когда необязательный параметр CoroutineContext появляется в API-интерфейсе, значением по умолчанию должно быть значение Empty`CoroutineContext` . Это позволяет лучше организовать поведение API, поскольку значение Empty`CoroutineContext` от вызывающего объекта обрабатывается так же, как и принятие значения по умолчанию:

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}