Pravila kuhinje


U prvom delu smo videli kako nam launch i async omogućavaju da obavljamo više zadataka istovremeno (poput kuvara koji delegira porudžbine). Kuhinja je prometna i hrana izlazi brže. Međutim, profesionalnoj kuhinji je potrebno više od samih kuvara koji rade paralelno. Potrebni su joj struktura, upravljanje i protokoli za situacije kada nešto krene po zlu.

Razmotrite sledeće scenarije:

  • Gost otkaže porudžbinu na pola pripreme. Da li nastavljamo da mu spremamo jelo?
  • Kvari se ključni deo opreme (poput rerne). Da li se cela kuhinja zatvara?
  • Određeni zadaci moraju da se obavljaju na određenim stanicama (seckanje naspram serviranja).

Upravo ove probleme rešavaju CoroutineScope, Dispatchers i princip Strukturirane konkurentnosti (Structured Concurrency). Oni predstavljaju sistem upravljanja koji haotičan skup zadataka pretvara u profesionalnu i otpornu operaciju.

Kuhinjske stanice: Dispatchers


Do sada su se naše korutine izvršavale na nekom pozadinskom tredu (thread), ali nismo kontrolisali na kom. Dispatchers su poput posebnih stanica u kuhinji. Oni govore korutini na kom tredu treba da se izvršava.

Kotlin nam nudi tri primarna dispatchera:

  • Dispatchers.Main: Deo za posluživanje (gde se hrana servira i služi gostima). Ovo je UI tred (na Androidu). Koristite ga za bilo koji zadatak koji komunicira sa korisničkim interfejsom. Optimizovan je za veoma kratke i brze operacije. Nikada ne blokirajte ovaj tred!
  • Dispatchers.IO: Skladište. Namenjen je za I/O-intenzivne zadatke, kao što su mrežni pozivi, čitanje iz baze podataka ili pristup fajlovima. Održava veliki broj tredova dizajniranih za zadatke koji većinu vremena provode u čekanju.
  • Dispatchers.Default: Glavna pripremna stanica. Namenjen je za CPU-intenzivne zadatke, poput sortiranja ogromne liste, složenih izračunavanja ili parsiranja velikih JSON objekata. Veličina njegovog skupa tredova (thread pool) odgovara broju jezgara procesora.

Da bismo prebacili zadatak sa jedne stanice na drugu, koristimo funkciju withContext.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking {
    println("Glavni kuvar: 'Donesite mi podatke o korisniku!'")

    launch(Dispatchers.Main) {
        // Počinje na Main tredu
        println("UI kuvar: 'Prikazujem indikator učitavanja.' " +
                "[Tred: ${Thread.currentThread().name}]")

        // Prebacivanje na IO dispatcher za preuzimanje podataka
        val userData = withContext(Dispatchers.IO) {
            println("Kuvar za podatke: 'Preuzimam podatke...' " +
                    "[Tred: ${Thread.currentThread().name}]")
            delay(1000)
            "Podaci o korisniku preuzeti"
        }

        // withContext se automatski vraća na Main tred da ažurira UI
        println("UI kuvar: 'Sakrivam indikator, podaci: $userData' " +
                "[Tred: ${Thread.currentThread().name}]")
    }
}

Izlaz:

1
2
3
4
5
Glavni kuvar: 'Donesite mi podatke o korisniku!' (Počinje na Main tredu)
UI kuvar: 'Prikazujem indikator učitavanja.' [Tred: main]
Kuvar za podatke: 'Preuzimam podatke...' [Tred: DefaultDispatcher-worker-1]
(prolazi 1 sekunda)
UI kuvar: 'Sakrivam indikator, podaci: Podaci o korisniku preuzeti' [Tred: main]

withContext je suspend funkcija koja nam omogućava da pređemo na drugi kontekst za određeni blok koda. Kada se taj kod završi, vraća se nazad. Ovo je fundamentalni obrazac za asinhrono programiranje sa korutinama.

Dispatchers

Glavni kuvar: CoroutineScope i strukturirana konkurentnost


Ako su Dispatchers stanice, ko je zadužen za sve kuvare? To je posao CoroutineScope-a. Scope je poput glavnog kuvara za grupu korutina. On upravlja njihovim celokupnim životnim ciklusom.

Ovo nas dovodi do principa Strukturirane konkurentnosti (Structured Concurrency). Nove korutine se mogu pokrenuti isključivo unutar scope-a. Scope definiše njihov životni vek. Kada se životni vek scope-a završi, sve korutine unutar njega se automatski otkazuju. Ovo sprečava curenje korutina (npr. da nastave sa preuzimanjem podataka za ekran koji više nije vidljiv).

Scope-ovi koje obezbeđuje framework

Na Androidu dobijate gotove scope-ove vezane za životne cikluse komponenti, koje bi trebalo skoro uvek da koristite:

  • viewModelScope: Vezan za ViewModel. Otkazuje sve korutine kada se ViewModel uništi. Ovo je default izbor na Androidu.
  • lifecycleScope: Vezan za Lifecycle aktivnosti ili fragmenta. Koristan je za zadatke koji treba da se usklade sa određenim događajima u životnom ciklusu.

Upozorenje: Izbegavajte GlobalScope GlobalScope je kao odmetnuti kuvar koji radi samostalno i nikada ne ide kući kada se kuhinja zatvori. Korutine pokrenute u njemu nisu vezane ni za jedan posao i lako mogu dovesti do curenja memorije i nepotrebnog trošenja resursa. Skoro da ne postoji dobar razlog za njegovu upotrebu u aplikacionom kodu.

Job vs SupervisorJob: Kako se kuvar nosi sa neuspehom

CoroutineScope je definisan svojim CoroutineContext-om, koji mora da sadrži Job. Job predstavlja životni ciklus samog scope-a i način na koji se on nosi sa neuspesima.

Podrazumevani Job ima strogu politiku “svi za jednog, jedan za sve”. Ako bilo koja podređena korutina ne uspe i baci izuzetak, ona momentalno otkazuje svoj nadređeni Job i sve njegove srodne korutine. To je kao glavni kuvar koji evakuiše celu sekciju ako jedan kuvar izazove požar.

Cancel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fun main() = runBlocking {
    val chefScope = CoroutineScope(Job())

    chefScope.launch {
        delay(200)
        println("Kuvar A: 'Serviram salatu.'")
    }
    chefScope.launch {
        delay(100)
        println("Kuvar B: 'O ne, zagoreo mi je tost!'")
        throw Exception("Tost gori!")
    }

    delay(500)
    println("Menadžer kuhinje: 'Sekcija strogog kuvara je sada tiha.'")
}

Izlaz:

1
2
3
Kuvar B: 'O ne, zagoreo mi je tost!'
(Izuzetak je bačen, scope je otkazan)
Menadžer kuhinje: 'Sekcija strogog kuvara je sada tiha.'

Primetite da Kuvar A nikada nije završio posao. Neuspeh Kuvara B otkazao je ceo scope.

Ali šta ako želite da jedan neuspeh ne utiče na druge zadatke? Za to koristite SupervisorJob. On omogućava podređenim korutinama da ne uspeju nezavisno, bez obaranja celog scope-a. Ovo je blaži glavni kuvar koji se bavi greškom jednog kuvara, dok ostalima govori da nastave sa radom.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fun main() = runBlocking {
    val chefScope = CoroutineScope(SupervisorJob())

    chefScope.launch {
        delay(200)
        println("Kuvar A: 'Serviram salatu.'")
    }
    chefScope.launch {
        try {
            delay(100)
            println("Kuvar B: 'O ne, zagoreo mi je tost!'")
            throw Exception("Tost gori!")
        } catch (e: Exception) {
            println("Kuvar B (menadžeru): '${e.message}'")
        }
    }

    delay(500)
    println("Menadžer kuhinje: 'Sekcija blažeg kuvara i dalje radi.'")
}

Izlaz:

1
2
3
4
Kuvar B: 'O ne, zagoreo mi je tost!'
Kuvar B (menadžeru): 'Tost gori!'
Kuvar A: 'Serviram salatu.'
Menadžer kuhinje: 'Sekcija blažeg kuvara i dalje radi.'

viewModelScope podrazumevano koristi SupervisorJob, zbog čega jedan neuspeli mrežni poziv u ViewModel-u ne mora nužno zaustaviti drugi. To ga čini izuzetno otpornim za zadatke vezane za korisnički interfejs.

Reagovanje u hitnim slučajevima: Obrada izuzetaka


Šta se dešava kada zadatak ne uspe zbog greške? U svetu korutina, izuzeci se propagiraju naviše kroz hijerarhiju. Neuhvaćeni izuzetak će otkazati svoj nadređeni scope (osim ako se ne radi o SupervisorJob-u). Ovo je sigurnosna mera poznata kao “fail-fast” (brzo otkazivanje).

Da biste elegantno obradili greške bez rušenja scope-a, koristite try-catch blok (obično oko .await() poziva).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    scope.launch {
        println("Kuvar: 'Idem po onaj biftek iz zamrzivača.'")
        
        val deferredSteak = async<String> {
            delay(500)
            throw Exception("O ne, zamrzivač je pokvaren!")
        }
        
        try {
            val steak = deferredSteak.await()
            println("Kuvar: 'Imam biftek: $steak'")
        } catch (e: Exception) {
            println("Kuvar: 'Greška! Reći ću gostu da nemamo biftek.'")
        }
    }
    
    delay(1000)
}

Izlaz:

1
2
Kuvar: 'Idem po onaj biftek iz zamrzivača.'
Kuvar: 'Greška! O ne, zamrzivač je pokvaren!. Reći ću gostu da nemamo biftek.'

Hvatanjem izuzetka, rešili smo problem lokalno i omogućili da se operacija elegantno završi.

Zaključak

Naša kuhinja je sada organizovana. Imamo:

  • Dispatchers: Posebne stanice koje osiguravaju da se posao obavlja na pravom mestu (Main, IO, Default).
  • CoroutineScope: Glavni kuvar koji upravlja životnim ciklusom svih zadataka. On osigurava da ništa ne procuri.
  • Job vs SupervisorJob: Različiti stilovi upravljanja za rukovanje neuspesima.
  • Otkazivanje i izuzeci: Jasni protokoli za situacije kada je porudžbina otkazana ili nešto krene po zlu.

Šta nas čeka u trećem delu?

Do sada su naši kuvari pripremali pojedinačne porudžbine. Gost traži jednu stvar, a mi sa await čekamo jedan rezultat. Šta se dešava kada nam je potreban neprekidan tok jela za degustacioni meni, ili švedski sto koji zahteva stalno dopunjavanje?

U trećem delu ćemo predstaviti ključni koncept za obradu tokova podataka: Flow. Naučićemo kako da emitujemo, transformišemo i prikupljamo nizove vrednosti tokom vremena, podižući sposobnosti naše kuhinje na sledeći nivo.