Aplikacija se zamrzla? Kako to rešiti?


Ako ste ikada pravili aplikaciju sa korisničkim interfejsom, sigurno ste se našli u ovoj situaciji: pokrenete poziv preko mreže i odjednom, cela aplikacija se zamrzne. Dugmići ne reaguju, animacije se zaustavljaju, a korisnici postaju frustrirani. Ovo je klasičan problem dugotrajnih zadataka na glavnoj (main) niti. Upravo to rešavaju Kotlin korutine.

Daju nam moćan način da pišemo asinhroni kod koji izgleda i ponaša se kao jednostavan, sekvencijalan, sinhroni kod. Nema više callback hell-a!

Da biste ovo zaista razumeli, zamislite kuvara u kuhinji. Kuvar može da radi samo jednu po jednu stvar, baš kao i main UI nit aplikacije.

Evo recepta:

  1. Seče povrće (CPU zadatak).
  2. Stavlja ga u mikrotalasnu na 2 minuta (I/O zadatak).
  3. Priprema salatu dok čeka (CPU zadatak).
  4. Servira jelo.

Blokirajući pristup (Neefikasna kuhinja)

Bez korutina, kuvar radi ovako:

  1. Seče povrće.
  2. Stavlja ga u mikrotalasnu, pritiska start, i onda… stoji i gleda u tajmer pune dva minuta. Ništa drugo se ne dešava.
  3. Kada mikrotalasna konačno zapišti, kuvar se vraća i počinje da pravi salatu.

Rezultat? Izuzetno neefikasna kuhinja. Kuvar je u potpunosti blokiran. U stvarnoj aplikaciji, to znači zamrznut UI i frustriranog korisnika (i recenziju sa jednom zvezdicom koja samo što nije napisana 🙂).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun main() {
    // Sve se ovo izvršava na glavnoj niti
    println("Kuvar počinje da kuva.")
    println("1. Seče povrće.")

    // Pokretanje mikrotalasne (glavna nit je BLOKIRANA!)
    println("2. Stavljanje hrane u mikrotalasnu.")
    Thread.sleep(2000)
    println("Mikrotalasna je završila.")

    println("3. Priprema salate.")
    println("4. Serviranje jela.")
    println("Kuvar je završio sa kuvanjem.")
}

Izlaz:

1
2
3
4
5
6
7
8
Kuvar počinje da kuva.
1. Seče povrće.
2. Stavljanje hrane u mikrotalasnu.
(...pauza od 2 sekunde gde se ništa ne dešava...)
Mikrotalasna je završila.
3. Priprema salate.
4. Serviranje jela.
Kuvar je završio sa kuvanjem.

Ta pauza od 2 sekunde je pogubna za korisnika. To moramo popraviti.

Suspendujući pristup (Korutinska kuhinja)

Kako korutine ovo postižu? Tajna je u ključnoj reči suspend.

suspend funkcija je posebna. Ona govori Kotlin kompajleru: „Hej, ova funkcija može potrajati. Slobodno je pauziraj ovde i pusti nit da radi nešto drugo. Javiću ti kada budem spreman da nastavim.“

Novi, efikasni kuvar radi ovako:

  1. Seče povrće.
  2. Stavlja ga u mikrotalasnu, pritiska start i odmah odlazi da radi druge stvari. Ovo je tačka suspenzije. Kuvar je slobodan!
  3. Priprema salatu.
  4. Kada mikrotalasna zapišti, kuvar dobija obaveštenje i vraća se po hranu.

Ali, ne možete tek tako pozvati suspend funkciju kad god želite. Morate je pokrenuti unutar korutine. Tu na scenu stupaju Coroutine Builders. Oni su naša ulazna tačka u ovaj novi, neblokirajući svet.

Fire and Forget sa launch


Počnimo sa najjednostavnijim slučajem: treba da pokrenemo mikrotalasnu u pozadini. Ne treba nam odmah povratni rezultat. Samo želimo da ispalimo zadatak.

Za ovo koristimo launch bilder. Zamislite kao da kažete kuvaru: „Idi uradi ovo. Ne treba mi ništa od tebe odmah, samo završi posao.“

Brzo, ali važno upozorenje: Da bismo ovo pokrenuli u main funkciji, koristimo poseban bilder runBlocking. On je dizajniran da premosti blokirajući svet sa suspendujućim svetom korutina. On će blokirati glavnu nit dok se svaka korutina unutar njega ne završi. Ovo je odlično za demonstracije i testove, ali NIKADA ga ne koristite u produkcionom Android kodu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import kotlinx.coroutines.*

// runBlocking kreira scope i blokira glavnu nit
fun main() = runBlocking {
    println("Kuvar počinje da kuva.")
    println("1. Seče povrće.")

    // launch pokreće novi zadatak u pozadini
    launch {
        // Pozivamo našu suspend funkciju.
        podgrejHranuUMikrotalasnoj()
    }

    // Ovo se izvršava ODMAH nakon launch (bez čekanja!)
    println("3. Priprema salate.")
    println("4. Čekam da se sve završi kako bih servirao jelo.")
    // runBlocking ovde čeka da se launch blok završi
}

suspend fun podgrejHranuUMikrotalasnoj() {
    println("2. Stavljanje hrane u mikrotalasnu.")
    // Tačka suspenzije. Pauzira korutinu, ne i nit
    delay(2000)
    println("Mikrotalasna je završila.")
}

Izlaz:

1
2
3
4
5
6
7
Kuvar počinje da kuva.
1. Seče povrće.
3. Priprema salate.
4. Čekam da se sve završi kako bih servirao jelo.
2. Stavljanje hrane u mikrotalasnu.
(...pauza od 2 sekunde tokom koje aplikacija NIJE zamrznuta...)
Mikrotalasna je završila.

Launch

Kuvar odmah počinje sa salatom. Zadatak sa mikrotalasnom se izvršava konkurentno u pozadini. Postigli smo pravu neblokirajuću konkurentnost.

Dobijanje rezultata pomoću async i await


U redu, launch je sjajan za pokretanje pozadinskih poslova. Ali budimo realni, najčešće ne radimo po principu „fire and forget“, već obično preuzimamo podatke. Potreban nam je rezultat.

Zamislimo da je kuvaru potreban poseban sos. On kaže svom pomoćniku da ga napravi. Kuvar može da nastavi sa radom, ali u nekom trenutku će morati da stane i sačeka taj sos pre nego što završi jelo.

Ovo je posao za async. To je još jedan bilder, ali umesto Job-a, on nam vraća nešto što se zove Deferred<T>. To je obećanje da će sadržati našu vrednost u nekom trenutku.

Da bismo dobili vrednost, pozivamo .await(). I tu je ključna stvar: .await() je suspend funkcija. Ako sos nije gotov, kuvar će se tu pauzirati (bez blokiranja niti!) dok ne bude.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Kuvar: Počinjem da pripremam jelo i sos.")

    // Pokrećemo pripremu sosa u pozadini pomoću async
    val deferredSos: Deferred<String> = async {
        pripremiSos()
    }

    // Dok se sos priprema, kuvar radi svoj posao
    println("Kuvar: Pripremam glavno jelo...")
    delay(1000)
    println("Kuvar: Glavno jelo je spremno.")
    
    // Korutina se ovde suspenduje ako sos još nije gotov
    val sos = deferredSos.await()
    println("Kuvar: Dobio sam sos! U pitanju je '$sos'.")
    println("Kuvar: Kombinujem sve i serviram jelo.")
}

suspend fun pripremiSos(): String {
    println("Pomoćnik: Počinjem da pripremam sos...")
    delay(2000)
    println("Pomoćnik: Sos je gotov!")
    return "Paradajz sos"
}

Izlaz:

1
2
3
4
5
6
7
8
9
Kuvar: Počinjem da pripremam jelo i sos.
Kuvar: Pripremam glavno jelo...
Pomoćnik: Počinjem da pripremam sos...
(prolazi 1 sekunda)
Kuvar: Glavno jelo je spremno.
(prolazi još 1 sekunda)
Pomoćnik: Sos je gotov!
Kuvar: Dobio sam sos! U pitanju je 'Paradajz sos'.
Kuvar: Kombinujem sve i serviram jelo.

AsyncAwait

Ovo je lepota svega. Kod se i dalje čita od vrha do dna, kao priča. Nema callback-ova, nema komplikovanih reaktivnih lanaca. Samo pokrenemo zadatak sa async i sačekamo rezultat sa await kada nam zatreba.

Zaključak

To je to što se tiče osnova! Pokrili smo srž korutina:

  • Zašto: Blokiranje niti zamrzava aplikacije; suspend funkcije su rešenje.
  • „Fire and Forget“: Koristite launch kada samo želite da pokrenete pozadinski zadatak.
  • Dobijanje rezultata: Koristite async da pokrenete zadatak koji vraća vrednost, a .await() da dobijete tu vrednost kada budete spremni.

Šta nas čeka u drugom delu?

Naša kuhinja radi, ali deluje pomalo… magično. Gde se pozadinski zadaci zapravo izvršavaju? Šta se dešava ako mušterija otkaže porudžbinu na pola posla? Da li kuvari nastavljaju da kuvaju zauvek, trošeći resurse?

Zaronićemo dublje u mehanizme koji korutine čine tako robusnim. Pričaćemo o CoroutineScope-u, životnom ciklusu Job-ova i Dispatcher-ima da bismo videli kako možemo da upravljamo našim korutinama i kažemo im u kom tačno delu kuhinje da rade. Čitamo se u sledećem delu