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

Неблокирующие API запрашивают выполнение работы, а затем возвращают управление вызывающему потоку, чтобы он мог выполнить другую работу до завершения запрошенной операции. Эти API полезны в случаях, когда запрошенная работа может быть текущей или может потребовать ожидания завершения ввода-вывода или IPC, доступности системных ресурсов с высокой конкуренцией или ввода данных пользователем, прежде чем работа может быть продолжена. Особенно хорошо спроектированные 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 в любое время, и вызов асинхронного 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 приостановки следующего поведения:

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

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

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

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

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

Вызов функции в функции suspend приводит к ее запуску в CoroutineContext вызывающего. Поскольку функции suspend должны завершить всю связанную работу перед возвратом или выдачей и должны вызывать только параметры обратного вызова на месте, по умолчанию ожидается, что любые такие обратные вызовы также будут запущены в вызывающем 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 для выполнения параллельной работы позволяет вызывающему объекту вызывать операцию в своем собственном контексте, устраняя необходимость в том, чтобы MyClass управлял CoroutineScope . Сериализация обработки запросов становится проще, а состояние часто может существовать как локальные переменные 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` Sentinel. Это позволяет улучшить композицию поведения 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)

    // ...
}