В предыдущей статье я сделал беглый обзор async-await в Android. Теперь пришло время погрузиться немного глубже в грядущий функционал kotlin версии 1.1.
Для чего вообще async-await?
Когда мы сталкиваемся с длительными операциями, такими как сетевые запросы или транзакции в базу данных, то надо быть уверенным, что запуск происходит в фоновом потоке. Если же забыть об этом, то можно получить блокировку UI потока еще до того, как задача закончится. А во время блокировки UI пользователь не сможет взаимодействовать с приложением.
К сожалению, когда мы запускаем задачу в фоне, то не можем использовать результат тут же. Для этого нам потребуется некая разновидность callback'а. Когда callback будет вызван с результатом, только тогда мы сможем продолжить, например запустить еще один сетевой запрос.
Простой пример того, как люди приходят к "callback hell": несколько вложенных callback'ов, все ждут вызова когда длогоиграющая операция закончится.
fun retrieveIssues() {
githubApi.retrieveUser() { user ->
githubApi.repositoriesFor(user) { repositories ->
githubApi.issueFor(repositories.first()) { issues ->
handler.post {
textView.text = "You have issues!"
}
}
}
}
}
Этот кусок кода представляет три сетевых запроса, где в конце отправляется сообщение в главный поток, чтобы обновить некий TextView.
Исправляем с помощью async-await
С помощью async-await можно привести этот код к более императивному стилю с той же функциональностью. Вместо отправки callback'а можно вызвать "замораживающий" метод await, который позволит использовать результат так же, словно он был вычислен в синхронном коде:
fun retrieveIssues() = asyncUI {
val user = await(githubApi.retrieveUser())
val repositories = await(githubApi.repositoriesFor(user))
val issues = await(githubApi.issueFor(repositories.first()))
textView.text = "You have issues!"
}
Этот код все еще делает три сетевых запроса и обновляет TextView в главном потоке, и не блокирует UI!
Погоди… Что?
Если мы будет использовать библиотеку AsyncAwait-Android, то получим несколько методов, два из которых async и await.
Метод async позволяет использовать await и изменяет способ получения результата. При входе в метод, каждая строка будет выполнена синхронно пока не достигнет точки "заморозки"(вызова метода await). По факту, это все, что делает async — позволяет не перемещать код в фоновый поток.
Метод await позволяет делать вещи асинхронно. Он принимает "awaitable" в качестве параметра, где "awaitable" — какая-то асинхронная операция. Когда вызывается await, он регистрируется в "awaitable", чтобы получить уведомление, когда операция закончится, и вернуть результат в метод asyncUI. Когда "awaitable" завершится, он выполнит оставшуюся часть метода, при этом передав туда результат.
Магия
Все это похоже на магию, но тут нет никакого волшебства. На самом деле компилятор котлина трансформирует coroutine (то, что находится в рамках async) в стейт-машину(конечный автомат). Каждое состояние которого — это часть кода из coroutine, где точка "заморозки"(вызов await) означает конец состояния. Когда код, переданный в await, завершается, выполнение переходит к следующему состоянию, и так далее.
Рассмотрим простую версию кода, представленного ранее. Мы можем посмотреть, какие создаются состояния, для этого отметим каждый вызов await:
fun retrieveIssues() = async {
println("Retrieving user")
val user = await(githubApi.retrieveUser())
println("$user retrieved")
val repositories = await(githubApi.repositoriesFor(user))
println("${repositories.size} repositories")
}
Эта coroutin'a имеет три состояния:
- Начальное состояние, до вызова await
- После первого вызова await
- После воторого вызова await
Этот код будет скомпилирован в такую стейт-машин(псевдо-байт-код):
class <anonymous_for_state_machine> {
// The current state of the machine
int label = 0
// Local variables for the coroutine
User user = null
List<Repository> repositories = null
void resume (Object data) {
if (label == 0) goto L0
if (label == 1) goto L1
if (label == 2) goto L2
L0:
println("Retrieving user")
// Prepare for await call
label = 1
await(githubApi.retrieveUser(), this)
// 'this' is passed as a continuation
return
L1:
user = (User) data
println("$user retrieved")
label = 2
await(githubApi.repositoriesFor(user), this)
return
L2:
repositories = (List<Repository>) data
println("${repositories.size} repositories")
label = -1
return
}
}
После захода в стейт-машину будут выполнены label ==0 и первый блок кода. Когда будет достигнут await, label обновится, и стейт-машина перейдет к выполнению кода, переданного в await. После этого выполнение продолжится с точки resume.
После завершения задачи, отправленной в await, будет вызван метод стейт-машины resume(data) для выполнения следующй части. И так будет продолжаться, пока не будет достигнуто последнее состояние.
Обработка исключений
В случае завершения "awaitable" с ошибкой, стейт-машина получит уведомление об этом. На самом деле метод resume принимает дополнительный Throwable параметр, и, когда выполняется новое состояние, этот параметр проверяется на равенство null. Если параметр null, то Throwable пробрасывается наружу.
Поэтому можно использовать оператор try/catch как обычно:
fun foo() = async {
try {
await(doSomething())
await(doSomethingThatThrows())
} catch(t: Throwable) {
t.printStackTrace()
}
}
Многопоточность
Метод await не гарантирует запуск awaitable в фоновом потоке, а просто регистрирует слушателя, которые реагирует на завершение awaitable. Поэтому awaitable должен сам заботиться о том, в каком потоке запускать выполнение.
Например, мы отправили retrofit.Call<Т> в await, вызовем метод enqueue() и зарегистрируем слушателя. Retrofit сам позаботится, чтобы сетевой запрос был запущен в фоновом потоке.
suspend fun <R> await(
call: Call<R>,
machine: Continuation<Response<R>>
) {
call.enqueue(
{ response ->
machine.resume(response)
},
{ throwable ->
machine.resumeWithException(throwable)
}
)
}
Для удобства существует один вариант метода await, который принимает функцию () –> R и запускает её в другом потоке:
fun foo() = async<String> {
await { "Hello, world!" }
}
async, async<Т> и asyncUI
Существует три варианта метода async
- async: ничего не возвращает (как Unit или void)
- async<Т>: возвращает значение типа T
- asyncUI: ничего не возвращает
При использовании async<Т>, необходимо вернуть значение типа T. Сам же метод async<Т> возвращает значение типа Task<Т>, которое, как вы наверно догадались, можно отправить в метод await:
fun foo() = async {
val text = await(bar())
println(text)
}
fun bar() = async<String> {
"Hello world!"
}
Более того, метод asyncUI гарантирует, что продолжение(код между await) будет происходит в главном потоке. Если же использовать async или async<Т>, то продолжение будет происходить в том же потоке, в котором был вызван callback:
fun foo() = async {
// Runs on calling thread
await(someIoTask()) // someIoTask() runs on an io thread
// Continues on the io thread
}
fun bar() = asyncUI {
// Runs on main thread
await(someIoTask()) // someIoTask() runs on an io thread
// Continues on the main thread
}
В заключении
Как вы могли заметить, coroutin'ы предоставляют интересные возможности и могут улучшить читаемость кода, если ими пользоваться правильно. Сейчас они доступны в kotlin версии 1.1-M02, а возможности async-await, описанные в этой стате, вы можете использовать с помощью моей библиотеки на github.
x2bool
В Kotlin очень чистая реализация async/await, смотрится намного приятнее чем тот же TPL в C#. И для платформы Android это еще одна киллер-фича.
smaugfm
Можете поподробнее обьяснить чистоту реализации в Kotlin в отличии от TPL?
QtRoS
Я полагаю, что x2bool имел ввиду визуальную простоту — в шарпе все выглядит гораздо менее понятным с первого взгляда. Хотя пример в статье может быть слишком упрощенным.
Azoh
В чем принципиальное отличие?
QtRoS
Я подумал про разницу этого:
и
x2bool
Реализация не завязана на конкретный тип, такой, как Task. Ну, и то, что async/await это не специальные ключевые слова, а всего лишь функции.
creker
Конкретный тип это как раз хорошо, потому что TPL появилось раньше и из async/await получился мощный инструмент, который базируется уже и так на мощном знакомом API, а не добавлено вообще сбоку непонятно к чему. При желании можно расширять поведение async/await и строить его на своих примитивах как раз благодаря тому, что он написан на том же .Net.
А ключевые слова это вкусовщина.
synmcj
Поздравляю с киллер-фичей. Глядишь, через пару лет под Android станет наконец-то приятно писать.
andreich
да и сейчас вроде ничего так