Bezhlavé fragmenty a úlohy na pozadí

V tutoriáli s aplikáciou Presentr sme ukazovali použitie úloh na pozadí pomocou AsyncTaskov, ktoré sa dajú použiť vcelku ľahko. Problém však nastáva vo chvíli, keď Android odstrelí aktivitu, ktorá túto úlohu na pozadí spustila.

Našťastie, existuje spôsob, vďaka ktorému bude aj AsyncTask dobehnutý, aj aktivita aktualizovaná. Na to sa však používa ďalší z obľúbených oficiálnych hackov: headless fragments. Tu však nejde o fragmenty bez hlavy (a päty), ale o situáciu, keď fragment nepotrebuje používateľské rozhranie.

Headless fragment slúži ako truhlica, do ktorej si môže aktivita odložiť stav. Ak sa zmení konfigurácia systému, aktivita sa samozrejme odstrelí a vytvorí nanovo, ale vďaka stavu v truhlici sa môže nový objekt aktivity „zregenerovať“ do rovnakého stavu ako jeho predchodca.

Filozofia je veľmi podobná bundlom a ich využívaniu v onSaveInstanceState(), ale headless fragment má významnú výhodu: kým do bundlu môžete na zimu zavariť len základné typy, serializovateľné a parcelizovateľné objekty, bezhlavý fragment dokáže v sebe niesť referenciu na ľubovoľné dáta.

Treba dať pozor na to, aby v sebe fragment neniesol referenciu na objekt, ktorý je priamo či nepriamo prepojený na Activity, či Context. V opačnom prípade máte memory leak jak sviň! Vo fragmente si teda ukladať do inštančných premenných referencie na widgety, adaptéry, drawable, či iné objekty, ktoré v sebe nesú kontext.

Dôležité je, že fragment je vždy spojený s nejakou aktivitou, koniec-koncov, prvá metóda jeho životného cyklu sa volá onAttach() s parametrom typu Activity.

Ukladanie stavu do fragmentu

Ukladanie stavu do fragmentu si ukážme na hlúpej, ale didaktickej ukážke. Predstavme si hru, ktorá má Naozaj Zložitý Stav(tm), ktorý je ťažké serializovať, nieto parcelizovať.

Stav nech je reprezentovaný triedou:

public class ReallyComplexActivityState {
    // Stav nepotrebujeme
}

Vytvorme si headless fragment, do ktorého budeme ukladať stav. Používateľské rozhranie nepotrebujeme, preto fragment vytvoríme v Studiu ako jednoduchú triedu:

public class DataStoreFragment extends Fragment {
    private Object object;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);
    }

    public Object getObject() {
        return object;
    }

    public void setObject(Object object) {
        this.object = object;
    }
}

Fragment klasicky dedí od Fragment a poskytuje dve metódy na uloženie a získanie stavu.

Najdôležitejšie je zapnutie režimu retain instance, v ktorom dokáže fragment prežiť zmeny konfigurácie a tým si udržať dáta.

V režime retain instance (udrž inštanciu) sa zmení postupnosť volaní životného cyklu fragmentu pri reštarte aktivity, ktorá sa stará o fragment:

  • metóda onDestroy() sa nezavolá.
  • metóda onDetach() sa zavolá, pretože ničená aktivita sa odpojí od fragmentu
  • metóda onCreate() sa nezavolá; fragment sa totiž nevytvára nanovo (drží si inštanciu)
  • metódy onAttach() a onActivityCreated() sa zavolajú, keďže aktivita sa vytvára nanovo a následne sa napojí na fragment.

Režim udržania stavu nefunguje pri fragmentoch, ktoré boli uložené na back stack!

Správa fragmentu v aktivite

Poďme teraz obslúžiť správu fragmentu v aktivite. Plán práce bude nasledovný:

  • v onCreate() aktivity získame inštanciu fragmentu a obnovíme z neho stav
  • v onDestroy() aktivity získame inštanciu fragmentu a uložíme do nej stav.

Získanie inštancie fragmentu

V onCreate() aktivity si poznačíme do inštančnej premennej objekt DataStoreFragment. Ak fragment ešte neexistuje, vytvoríme ho a v rámci transakcie ho pridáme do správcu fragmentov. Samozrejme, môže sa stať, že fragment je už v réžii správcu fragmentov: v takom prípade fragmentu vytiahneme zo správcu a poznačíme si ho.

Vytvorme si na to pomocnú metódu:

private void initDataStoreFragment() {
    FragmentManager fragmentManager = getFragmentManager();
    this.dataStoreFragment
            = (DataStoreFragment) fragmentManager.findFragmentByTag(DATA_STORE_FRAGMENT_TAG);
    if(this.dataStoreFragment == null) {
        this.dataStoreFragment = new DataStoreFragment();
        fragmentManager.beginTransaction()
                .add(this.dataStoreFragment, DATA_STORE_FRAGMENT_TAG)
                .commit();
    }
}

Každý fragment je v správcovi fragmentov jednoznačne identifikovaný cez tag, čo je ľubovoľný reťazec, v našom prípade reprezentovaný konštantou. Pomocou tagu pridávame fragment do správcu, aby sme ho neskôr z neho mohli vytiahnuť.

public static final String DATA_STORE_FRAGMENT_TAG = "dataStoreFragment";

Obnovenie stavu

Na obnovenie stavu použime tiež pomocnú metódu:

private void restoreStateFromDataStoreFragment() {
    this.reallyComplexActivityState = (ReallyComplexActivityState) this.dataStoreFragment.getObject();
    if(this.reallyComplexActivityState == null) {
        this.reallyComplexActivityState = new ReallyComplexActivityState();
    }
    setTitle(Long.toString(this.reallyComplexActivityState.hashCode()));
}

V nej vytiahneme stav objektu a hashkód (lebo nič iné nemáme...) zobrazíme v hlavičke aktivity, čím si predvedieme udržiavanie stavu po reštarte.

Stav uložíme do inštančnej premennej:

private ReallyComplexActivityState reallyComplexActivityState;

Obnova stavu

Obnova stavu aktivity sa zrealizuje v onCreate(), kde zavoláme obe pomocné metódy:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    initDataStoreFragment();
    restoreStateFromDataStoreFragment();
}

Ukladanie stavu

Uloženie stavu pri ničení aktivity je jednoduché: stačí zobrať objekt a šupnúť ho do dátového fragmentu vo chvíli, keď sa aktivita ničí:

@Override
protected void onDestroy() {
    this.dataStoreFragment.setObject(this.reallyComplexActivityState);
    super.onDestroy();
}

Demo?

Ak spustíme aplikáciu, môžeme ju otáčať do ľubovole, a hlavička aktivity sa nebude meniť, čo dokazuje fakt, že stav aktivity sa naozaj uloží do fragmentu.

Celý príklad môžeme nájsť na GitHube.

Ukladanie stavu do fragmentu

Nápad s ukladaním stavu môžeme ešte posunúť o krok ďalej: v rámci headless fragmentu môžeme spúšťať AsyncTasky, ktoré už nebudú závislé od životného cyklu aktivity. Vyriešime tým problém s aktualizáciou widgetov neplatnej aktivite, pretože budeme aktualizovať tú aktivitu, ktorá je vo chvíli dobehnutia úlohy prepojená s fragmentom.

Na to, aby to fungovalo, využijeme dve známe veci: predovšetkým nápad z predošlej sekcie a trik s komunikačným interfejsom známy z tutoriálu o Markdownri.

Demonštrujme si to na hlúpom príklade: v úlohe na pozadí nech beží odpočítavač bomby, ktorý aktualizuje textové políčko v aktivite.

Ukážme si najprv architektúru celého nápadu:

Asynchrónne úlohy a headless fragmenty: náčrteček

Vytvoríme si fragment, ktorý v sebe bude niesť inštanciu asynchrónnej úlohy riešiacej odpočítavanie na pozadí.

public class CountdownFragment extends Fragment {

    private class CountdownAsyncTask extends AsyncTask<Integer, Integer, Void> {
        @Override
        protected Void doInBackground(Integer... params) {
            int countStart = 0;
            if(params.length > 0) {
                countStart = params[params.length - 1];
            }
            for(int i = countStart; i >= 0; i--) {
                publishProgress(i);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    break;
                }
            }
            return null;
        }
    }
}

Trieda CountdownAsyncTask nesmie byť statická: o chvíľu totiž bude musieť vidieť vonkajší fragment, aby cez neho vedela pristúpiť k objektu aktivity, v ktorej sa fragment nachádza.

Úloha prekrýva podľa zvyklostí metódu doInBackground(), kde v cykle zaspí na sekundu a zníži počítadlo. Oproti predošlým príkladom je tu však novinka: monitorovanie progresu prác.

Volaním metódy publishProgress() môže úloha preposielať z vlákna bežiaceho na pozadí do hlavného vlákna informácie o priebehu prác. Jej protipólom je onProgressUpdate() (bežiaca v hlavnom vlákne), v ktorej je možné bezpečne aktualizovať používateľské rozhranie.

Inicializácia fragmentu

Inicializácia fragmentu nie je prekvapujúca: dôležité je nezabudnúť na setRetainInstance(), aby sa uchoval stav!

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setRetainInstance(true);
}

Spustenie odpočítavania

Na spustenie odpočítavania dodajme do fragmentu vlastnú metódu startCounting(). V nej zistíme, či úloha už náhodou nebeží a to pomocou overenia stavu metódou getStatus(), ktorý poskytuje trieda AsyncTask: ak náhodou úloha už beží, jednoducho ju necháme tak.

V opačnom prípade vytvoríme inštanciu úlohy, poznačíme si ju do inštančnej premennej a spustíme ju, pričom do parametra dáme počiatočný stav odpočítavania:

public void startCounting(int startNumber) {
    if(countdownAsyncTask != null && countdownAsyncTask.getStatus() == AsyncTask.Status.RUNNING) {
        // task is already running
        return;
    }
    countdownAsyncTask = new CountdownAsyncTask();
    countdownAsyncTask.execute(startNumber);
}

Prirodzene, inštančná premenná je nasledovná:

private CountdownAsyncTask countdownAsyncTask;

Odchytávanie progresu pomocou poslucháča

AsyncTask už môže radostne odpočítavať, akurát, že bude smutný... nik ho totiž nebude počúvať. Musíme zabezpečiť, aby úloha dokázala preposielať informácie o stave do aktivity, a ako na to? Použijeme zmienenú fintu s poslucháčom.

  1. Vytvoríme interfejs pre poslucháča progresu.
  2. Necháme aktivitu implementovať interfejs poslucháča a prekryjeme metódu, kde aktualizujeme používateľské rozhranie.
  3. V AsyncTasku budeme preposielať údaje do poslucháča, teda do aktivity.

Vytvorenie poslucháča

Poslucháč na zmeny stavu bude jednoduchý: stačí na to jediná metóda:

public interface OnCountdownListener {
    public void onCountdown(int currentNumber);
}

Preposielanie údajov do poslucháča

Poďme teraz implementovať metódu onProgressUpdate(), v ktorej spracujeme informácie o priebehu prác.

Metóda onProgressUpdate() má premenlivý počet parametrov reprezentovaný poľom values. Implementácia asynchrónnych úloh totiž môže zoskupiť parametre z viacerých po sebe idúcich volaní publishProgress() a poslať ich ako pole parametrov do onProgressUpdate().

Nespoliehajte sa na to, že jedno volanie publishProgress() povedie k okamžitému volaniu onProgressUpdate()!

Nás zaujíma vždy len posledný prvok poľa, teda posledný stav počítadla.

V našej implementácii vytiahneme poslednú hodnotu počítadla a notifikujeme poslucháča. Pomocou volania getActivity() pristúpime k aktivite, na ktorú je napojený náš fragment, pretypujeme ju na poslucháča a zavoláme jej metódu notifikácie progresu. Ak aktivita náhodou nie je dostupná, alebo nereprezentuje poslucháča, jednoducho nespravíme nič.

private class CountdownAsyncTask extends AsyncTask<Integer, Integer, Void> {
    ...

    @Override
    protected void onProgressUpdate(Integer... values) {
        int lastProgressValue = values[values.length - 1];

        Activity activity = getActivity();
        if(activity == null) {
            return;
        }
        if(!(activity instanceof OnCountdownListener)) {
            return;
        }
        OnCountdownListener onCountdownListener = (OnCountdownListener) activity;

        onCountdownListener.onCountdown(lastProgressValue);
    }
}

Je vidieť, že trieda CountdownAsyncTask sa musí nachádzať vo fragmente ako nestatická trieda, aby videla jeho metódu getActivity().

Toto volanie je kľúčové pre správne fungovanie: vždy totiž zavolá tú inštanciu aktivity, na ktorú je fragment napojený. Ak sa napríklad medzi šiestou a piatou sekundou aktivita reštartne kvôli zmene stavu, volanie getActivity() sa v šiestej sekunde prejaví na starej inštancii a v piatej sekunde už bude aktualizovať novú čerstvú aktivitu.

Implementácia aktivity

Je čas na používateľské rozhranie! Do layoutu hlavnej aktivity dodajme dva widgety:

<TextView
    android:id="@+id/countdownTextView"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    />

<Button
    android:text="Countdown!"
    android:onClick="startCountdown"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_below="@id/countdownTextView"
    />

Na programovanie si dajme nasledovný plán:

  1. Aktivita nech implementuje interfejs poslucháča OnCountdownListener. Po zmene počítadla prekreslí textové políčko.
  2. V metóde onCreate() pripravíme mašinériu s fragmentom, ktorú sme zažili v predošlej časti. Fragment buď vytvoríme nanovo, alebo ho vytiahneme zo správcu fragmentov, a poznačíme do inštančnej premennej.
  3. Po kliknutí na tlačidlo spustíme odpočítavanie.

Celý kód aktivity vyzerá nasledovne:

public class MainActivity extends ActionBarActivity implements OnCountdownListener {

    public static final String COUNTDOWN_FRAGMENT_TAG = "countdownFragment";

    private TextView countdownTextView;

    private CountdownFragment countdownFragment;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        this.countdownTextView = (TextView) findViewById(R.id.countdownTextView);

        FragmentManager fragmentManager = getFragmentManager();
        countdownFragment = (CountdownFragment) fragmentManager.findFragmentByTag(COUNTDOWN_FRAGMENT_TAG);
        if(countdownFragment == null) {
            countdownFragment = new CountdownFragment();
            fragmentManager.beginTransaction()
                    .add(countdownFragment, COUNTDOWN_FRAGMENT_TAG)
                    .commit();
        }
    }

    public void startCountdown(View view) {
        countdownFragment.startCounting(50);
    }

    @Override
    public void onCountdown(int currentNumber) {
        countdownTextView.setText(Integer.toString(currentNumber));
    }
}

Beživá aplikácia

Ak spustíme aplikáciu, a klikneme na tlačidlo, spustíme odpočítavanie. Rotujme si ako chceme, počítanie bude bežať korektne, pretože AsyncTask je nezávislý od aktivity.

Výsledný kód

Výsledný kód appky, ktorá demonštruje toto správanie, je na GitHube, v repozitári novotnyr/android-asynctask-headlessfragment-demo.

Rozdiel medzi fragmentami a službami

Aký je rozdiel medzi službou a asynchrónnou úlohou vo fragmente? Predovšetkým, fragmenty sú vždy prepojené s konkrétnou aktivitou, ktorá sa o ne stará, a to i v prípade, že nemajú používateľské rozhranie. Fragment nikdy nemôže existovať bez aktivity, ktorá ho vytvára a stará sa oň. Na druhej strane, bezhlavé fragmenty a AsyncTasky sa vytvárajú a omnoho jednoduchšie než služby, nehovoriac o ich vzájomnej komunikácii.

Na druhej strane, služby sú samostatné komponenty, ktoré dokážu komunikovať s ľubovoľným iným komponentom androidovej appky a vôbec nie sú zavislé na aktivite a jej životnom cykle. Navyše, pri uvoľnovaní RAM v prípade jej nedostatku, majú služby vyššiu prioritu než aktivity a úlohy vo fragmentoch.

Pri inom pohľade možno fragmenty chápať predovšetkým ako obal na dáta, ktoré môžu zdielať rozličné aktivity, zatiaľčo služby predstavujú obal na dlhotrvajúce procesy. Rozdiel je niekedy veľmi jemný a správna voľba sa môže líšiť od appky k appke.