Pull to refresh

Погружение в Async-Await в Android

Reading time 5 min
Views 22K
Original author: Niek Haarman

В предыдущей статье я сделал беглый обзор 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.

Tags:
Hubs:
+17
Comments 11
Comments Comments 11

Articles