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

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

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

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

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

Kotlin активно продвигает структурированную параллельность — набор принципов и API, построенных на функциях приостановки (suspend functions), которые отделяют синхронное и асинхронное выполнение кода от поведения, блокирующего потоки. Функции приостановки являются неблокирующими и синхронными .

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

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

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

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

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

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

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

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

Асинхронные API-интерфейсы должны возвращать результат как можно быстрее.

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

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

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

  • Реализация базового межпроцессного взаимодействия в виде одностороннего вызова binder.
  • Выполнение двустороннего вызова привязки к системному серверу, при котором завершение регистрации не требует получения блокировки, вызывающей острую конкуренцию.
  • Отправка запроса в рабочий поток в процессе приложения для выполнения блокировки путем межпроцессного взаимодействия (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.

Когда в интерфейсе API появляется необязательный параметр CoroutineContext , значение по умолчанию должно быть 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)

    // ...
}