Занурення в Async-Await в Android

У попередньої статті я зробив побіжний огляд 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 thread io
}
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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.