Przejdź do treści
School IT / Przestrzeń do nauki

school-it.pl // android + kotlin // rozdział 9 // lekcja 36

Cykl życia aktywności

Obróć telefon w aplikacji z lekcji 35 — rozmiar czcionki wraca do początkowego. To nie błąd w kodzie, tylko zasada Androida: przy obrocie ekran jest niszczony i tworzony od nowa. Aktywność przechodzi przez etapy życia — powstaje, pokazuje się, chowa, ginie — a Android przy każdym z nich wywołuje metodę, którą możesz nadpisać. Zobaczysz te etapy na żywo w dzienniku i nauczysz się zachowywać dane przy obrocie.

Kotlin XML onCreate…onDestroy Log.d / Logcat onSaveInstanceState Bundle 2 × 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Poznasz metody cyklu życia: onCreate, onStart, onResume, onPause, onStop, onRestart, onDestroy.
  • Wypiszesz komunikaty do Logcat przez Log.d i przefiltrujesz je.
  • Zrozumiesz, dlaczego obrót ekranu kasuje zmienne.
  • Zapiszesz stan w onSaveInstanceState i odczytasz go w onCreate.
  • Rozróżnisz, co zapamiętują same kontrolki, a co musisz zapisać ty.
TEORIA

Etapy życia ekranu

Aktywność przypomina aktora w teatrze: przychodzi do garderoby (created), wchodzi za kulisy (started — widać go, ale jeszcze nie gra), gra na scenie (resumed), schodzi za kulisy, wraca do garderoby i w końcu wychodzi z teatru. Przy każdym przejściu Android wywołuje jedną metodę:

onCreate
raz na obiekt
setContentView, initUI
→
onStart
ekran staje się
widoczny
→
onResume
ekran na wierzchu,
reaguje na dotyk
→
onPause
traci pierwszeństwo
(np. otwiera się
inny ekran)
→
onStop
ekran niewidoczny
→
onDestroy
obiekt ekranu
znika

po onStop ekran może wrócić: onRestart → onStart → onResume

MetodaKiedyDo czego się przydaje
onCreateRaz, gdy powstaje obiekt ekranu.Układ, kontrolki, zdarzenia, odczyt danych.
onStartEkran za chwilę będzie widoczny.Odświeżenie danych, które mogły się zmienić.
onResumeEkran na wierzchu, użytkownik może go dotykać.Wznowienie animacji, odliczania, muzyki.
onPauseEkran traci pierwszeństwo — zaraz zniknie albo częściowo przykryje go inna aktywność. Okno AlertDialog twojej aplikacji nie wywołuje onPause.Zatrzymanie odliczania, muzyki.
onStopEkranu już nie widać.Zapis danych na stałe (lekcja 37).
onRestartZatrzymany ekran wraca na pierwszy plan.Rzadko potrzebna.
onDestroyObiekt ekranu jest niszczony.Sprzątanie. Nie licz, że zawsze się wykona.

Nadpisując metodę cyklu życia, zawsze wywołuj wersję rodzica (super.onStart() itd.) — Android wykonuje tam własną pracę. Bez tego aplikacja zgłosi błąd SuperNotCalledException.

TEORIA

Co się dzieje, gdy…

SytuacjaWywołane metody
uruchomienie aplikacjionCreate → onStart → onResume
przycisk / gest Ekran głównyonPause → onStop (+ onSaveInstanceState)
powrót do aplikacjionRestart → onStart → onResume
otwarcie drugiego ekranu (lekcja 30)pierwszy: onPause → onStop; po powrocie: onRestart → onStart → onResume
Wstecz / finish()onPause → onStop → onDestroy
obrót ekranuonPause → onStop → onSaveInstanceState → onDestroy, potem nowy obiekt: onCreate → onStart → onResume
TEORIA

Podglądanie w Logcat

Toast przy każdej metodzie zasłoniłby ekran. Programiści wypisują komunikaty do Logcat — okna na dole Android Studio, gdzie trafiają też błędy aplikacji:

komunikat w Logcat
import android.util.Log

override fun onStart() {
    super.onStart()
    Log.d("CYKL", "onStart")        // znacznik (tag), treść
}

W oknie Logcat wpisz w polu filtra tag:CYKL — zobaczysz tylko swoje komunikaty, bez setek systemowych. Log.d to poziom debug; są też Log.i (informacja), Log.w (ostrzeżenie) i Log.e (błąd).

TEORIA

Obrót ekranu

Po obrocie ekran ma inne wymiary i może potrzebować innego układu. Android rozwiązuje to najprościej: niszczy stary obiekt aktywności i tworzy nowy. Nowy obiekt ma nowe pola — z wartościami początkowymi z deklaracji (private var wynik = 0).

CoPo obrocie
pola klasy (var wynik, listy)znikają — wracają do wartości początkowych
tekst ustawiony z kodu w TextViewznika
tekst wpisany w EditText z android:idzostaje — kontrolka zapamiętuje go sama
zaznaczenie CheckBox / Switch / RadioButton z idzostaje
pozycja przewinięcia ScrollView z idzostaje

Ta sama sytuacja zdarza się nie tylko przy obrocie: zmiana języka telefonu, trybu ciemnego czy rozmiaru okna na tablecie też tworzy ekran od nowa. Dlatego nie wystarczy „zablokować obrotu” — trzeba umieć zachować stan.

TEORIA

Zapis stanu: onSaveInstanceState

Zanim stary obiekt zginie, Android daje mu Bundle — „paczkę na drogę”, podobną do extras intencji. Wkładasz do niej wartości, a nowy obiekt dostaje tę paczkę w onCreate jako savedInstanceState:

zapis i odczyt stanu
// stary obiekt — tuż przed zniszczeniem
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putInt("wynik", wynik)
}

// nowy obiekt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    initUI()
    if (savedInstanceState != null) {                    // null = pierwsze uruchomienie
        wynik = savedInstanceState.getInt("wynik", 0)
    }
    pokazWynik()
}
stary obiekt
wynik = 3
onSaveInstanceState:
putInt(„wynik”, 3)
→
Bundle
wynik → 3
→
nowy obiekt
wynik = 0 (deklaracja)
onCreate: getInt → 3

Do czego Bundle, a do czego co innego

Bundle jest na chwilowy stan ekranu: wynik meczu, numer pytania, zaznaczenie. Ma mały limit rozmiaru i znika, gdy użytkownik zamknie aplikację Wstecz. Danych, które mają przetrwać zamknięcie aplikacji, nie zapisuje się tutaj — do tego służy SharedPreferences (lekcja 37). A wygodniejszy sposób na przetrwanie obrotu poznasz w rozdziale o MVVM: ViewModel (lekcja 40).

PRZYKŁAD

Tablica wyników z dziennikiem

Wynik meczu dwóch drużyn z przyciskami +1 i Zeruj. Każda metoda cyklu życia dopisuje się do dziennika na ekranie i do Logcat. Przełącznik Zapamiętuj wynik przy obrocie decyduje, czy onSaveInstanceState zapisze wynik — możesz porównać oba przypadki.

18:00
Mecz
Gospodarze
3
+1
Goście
1
+1
Zeruj
Zapamiętuj wynik przy obrocie
Dziennik tego obiektu ekranu
onCreate — pierwsze uruchomienie
onStart
onResume
onPause
onStop
onSaveInstanceState
onRestart
onStart
onResume
wyjście na ekran główny i powrót — ten sam obiekt
→obrót
(zapis włączony)
18:01
Mecz
Gospodarze
3
+1
Goście
1
+1
Zeruj
Zapamiętuj wynik przy obrocie
Dziennik tego obiektu ekranu
onCreate — odtworzony z Bundle
onStart
onResume
nowy obiekt — wynik z Bundle
18:02
Mecz
Gospodarze
0
+1
Goście
0
+1
Zeruj
Zapamiętuj wynik przy obrocie
Dziennik tego obiektu ekranu
onCreate — odtworzony z Bundle
onStart
onResume
zapis wyłączony — po obrocie 0 : 0, ale przełącznik pamięta swój stan

Przygotowanie projektu

  1. Nowy projekt Empty Views Activity, nazwa Mecz, Kotlin, API 24.
  2. Podmień activity_main.xml i MainActivity.kt.
  3. Na emulatorze włącz automatyczny obrót: w szybkich ustawieniach (przeciągnij z góry ekranu) kafelek Autoobracanie. Obracasz przyciskami na pasku obok emulatora.
res/layout/activity_main.xml — pełny plik
<?xml version="1.0" encoding="utf-8"?>
<!-- ScrollView: w poziomie ekran jest niski, dziennik musi się przewijać -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Mecz"
            android:textSize="24sp"
            android:textStyle="bold" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <LinearLayout
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:gravity="center_horizontal"
                android:orientation="vertical">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="Gospodarze" />

                <TextView
                    android:id="@+id/tvGospodarze"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="56sp"
                    android:textStyle="bold" />

                <Button
                    android:id="@+id/btnGospodarze"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginEnd="4dp"
                    android:text="+1" />
            </LinearLayout>

            <LinearLayout
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:gravity="center_horizontal"
                android:orientation="vertical">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="Goście" />

                <TextView
                    android:id="@+id/tvGoscie"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textSize="56sp"
                    android:textStyle="bold" />

                <Button
                    android:id="@+id/btnGoscie"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="4dp"
                    android:text="+1" />
            </LinearLayout>
        </LinearLayout>

        <Button
            android:id="@+id/btnZeruj"
            style="@style/Widget.Material3.Button.OutlinedButton"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Zeruj" />

        <com.google.android.material.materialswitch.MaterialSwitch
            android:id="@+id/swZapamietuj"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:checked="true"
            android:text="Zapamiętuj wynik przy obrocie" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:text="Dziennik tego obiektu ekranu"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/tvDziennik"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fontFamily="monospace"
            android:textSize="13sp" />
    </LinearLayout>

</ScrollView>
MainActivity.kt — pełny plik
package pl.schoolit.mecz

import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.materialswitch.MaterialSwitch

class MainActivity : AppCompatActivity() {

    // ---------- kontrolki ----------
    private lateinit var tvGospodarze: TextView
    private lateinit var tvGoscie: TextView
    private lateinit var btnGospodarze: Button
    private lateinit var btnGoscie: Button
    private lateinit var btnZeruj: Button
    private lateinit var swZapamietuj: MaterialSwitch
    private lateinit var tvDziennik: TextView

    // ---------- stan (po obrocie wraca do tych wartości!) ----------
    private var gospodarze = 0
    private var goscie = 0
    private val dziennik = mutableListOf<String>()

    // ---------- cykl życia ----------
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initUI()

        if (savedInstanceState == null) {
            zapiszWDzienniku("onCreate — pierwsze uruchomienie")
        } else {
            // nowy obiekt po obrocie: odczyt z paczki (gdy klucza brak → 0)
            gospodarze = savedInstanceState.getInt("gospodarze", 0)
            goscie = savedInstanceState.getInt("goscie", 0)
            zapiszWDzienniku("onCreate — odtworzony z Bundle")
        }

        ustawZdarzenia()
        pokazWynik()
    }

    override fun onStart() {
        super.onStart()
        zapiszWDzienniku("onStart")
    }

    override fun onResume() {
        super.onResume()
        zapiszWDzienniku("onResume")
    }

    override fun onPause() {
        super.onPause()
        zapiszWDzienniku("onPause")
    }

    override fun onStop() {
        super.onStop()
        zapiszWDzienniku("onStop")
    }

    override fun onRestart() {
        super.onRestart()
        zapiszWDzienniku("onRestart")
    }

    override fun onDestroy() {
        zapiszWDzienniku("onDestroy")         // widać już tylko w Logcat
        super.onDestroy()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        if (swZapamietuj.isChecked) {
            outState.putInt("gospodarze", gospodarze)
            outState.putInt("goscie", goscie)
        }
        zapiszWDzienniku("onSaveInstanceState")
    }

    // ---------- przygotowanie ----------
    private fun initUI() {
        tvGospodarze = findViewById(R.id.tvGospodarze)
        tvGoscie = findViewById(R.id.tvGoscie)
        btnGospodarze = findViewById(R.id.btnGospodarze)
        btnGoscie = findViewById(R.id.btnGoscie)
        btnZeruj = findViewById(R.id.btnZeruj)
        swZapamietuj = findViewById(R.id.swZapamietuj)
        tvDziennik = findViewById(R.id.tvDziennik)
    }

    private fun ustawZdarzenia() {
        btnGospodarze.setOnClickListener { gospodarze++; pokazWynik() }
        btnGoscie.setOnClickListener { goscie++; pokazWynik() }
        btnZeruj.setOnClickListener { gospodarze = 0; goscie = 0; pokazWynik() }
    }

    // ---------- pomocnicze ----------
    private fun zapiszWDzienniku(zdarzenie: String) {
        Log.d("CYKL", zdarzenie)                            // Logcat, filtr: tag:CYKL
        dziennik.add(zdarzenie)
        tvDziennik.text = dziennik.joinToString("\n")       // ekran
    }

    private fun pokazWynik() {
        tvGospodarze.text = gospodarze.toString()
        tvGoscie.text = goscie.toString()
    }
}

Co dzieje się po kolei

  • Przy starcie savedInstanceState jest null — dziennik pokazuje „pierwsze uruchomienie”, potem onStart i onResume.
  • Wyjście na ekran główny i powrót: ten sam obiekt, więc dziennik rośnie — onPause, onStop, onSaveInstanceState, onRestart, onStart, onResume. Wynik się nie zmienia.
  • Obrót: stary obiekt dostaje onSaveInstanceState (zapisze wynik, jeśli przełącznik włączony) i onDestroy. Nowy obiekt ma pusty dziennik i odczytuje wynik z Bundle.
  • Przełącznik ma id, więc sam pamięta zaznaczenie po obrocie. Wyniki w TextView ustawiane z kodu — nie; dlatego trzeba je zapisać.
  • Pełną historię obu obiektów, razem z onDestroy, widać w Logcat po filtrze tag:CYKL.

Sprawdź trzy rzeczy

  1. Ustaw wynik 3 : 1 i obróć ekran z włączonym przełącznikiem — wynik zostaje. Wyłącz przełącznik i obróć znowu — 0 : 0.
  2. Otwórz Logcat z filtrem tag:CYKL i obróć ekran. Znajdź onDestroy starego obiektu i zaraz po nim onCreate nowego.
  3. Wynik 2 : 2, potem systemowe Wstecz i ponowne uruchomienie z ikony. Wynik 0 : 0 i „pierwsze uruchomienie” — Wstecz zamyka ekran na dobre, Bundle nie jest przekazywany.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementTypCo robi
onCreate / onStart / onResumemetodyEkran powstaje, staje się widoczny, staje się aktywny.
onPause / onStop / onDestroymetodyEkran traci pierwszeństwo, znika z widoku, jest niszczony.
onRestartmetodaZatrzymany ekran wraca.
super.onX()wywołanieObowiązkowe w każdej nadpisanej metodzie cyklu życia.
onSaveInstanceState(outState: Bundle)metodaZapis chwilowego stanu przed zniszczeniem.
savedInstanceState: Bundle?parametrPaczka ze stanem w onCreate; null przy pierwszym uruchomieniu.
bundle.putInt / putString / putBooleanmetodyZapis wartości pod kluczem.
bundle.getInt("k", 0), getString("k")metodyOdczyt wartości.
Log.d("TAG", "treść")metodaKomunikat w Logcat.
filtr tag:CYKLLogcatPokazuje tylko komunikaty z danym znacznikiem.
CZĘSTE BŁĘDY

Zanim utkniesz

Zapis / objawProblem
awaria SuperNotCalledExceptionW nadpisanej metodzie brak super.onStart() (itd.).
po obrocie dane znikająBrak onSaveInstanceState albo odczytu w onCreate.
savedInstanceState.getInt(…) → błąd kompilacjiParametr jest Bundle?. Najpierw if (savedInstanceState != null).
odczytano stan, ale ekran pokazuje 0Odczyt jest po pokazWynik(). Najpierw odczyt, potem odświeżenie ekranu.
w onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) nic nie działaAndroid Studio podpowiada też wersję z dwoma parametrami — to inna metoda. Użyj tej z jednym.
dane zapisane w Bundle nie przetrwały zamknięcia aplikacjiTak ma być — Bundle jest na chwilę. Na stałe: lekcja 37.
android:screenOrientation="portrait" w manifeście „naprawia” problemUkrywa go tylko przy obrocie — tryb ciemny czy zmiana języka nadal tworzą ekran od nowa. Na dużych ekranach Android może tę blokadę pominąć.
ZADANIA

Zadania

Każde zadanie wykonaj w nowym projekcie Empty Views Activity. Zawsze sprawdź działanie obrotem ekranu. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.

ZAD 1Licznik odporny na obrót★☆☆

Wróć do licznika z lekcji 19 i dodaj zapis stanu, tak by liczba osób na sali przetrwała obrót.

pokaż wskazówkę
  • outState.putInt("liczba", liczba); w onCreate: liczba = savedInstanceState?.getInt("liczba", 0) ?: 0 — krótszy zapis bez if.
ZAD 2Notatnik z lekcji 35★☆☆

Popraw notatnik tak, by po obrocie zostawał rozmiar czcionki i tryb nocny. Sprawdź, że sam tekst notatki zostaje już teraz — dlaczego?

pokaż wskazówkę
  • putFloat("rozmiar", rozmiar) i putBoolean("noc", trybNocny). Tekst pamięta EditText z id. Zaznaczenie w menu ustawisz w onCreateOptionsMenu: menu.findItem(R.id.akcja_tryb_nocny).isChecked = trybNocny.
ZAD 3Stoper z pauzą★★☆

Stoper odliczający sekundy (CountDownTimer z lekcji 15 albo Handler). Gdy użytkownik wyjdzie z aplikacji, odliczanie się zatrzymuje; po powrocie — wznawia od tej samej wartości.

pokaż wskazówkę
  • Uruchamiaj odliczanie w onResume, zatrzymuj (cancel()) w onPause. Liczbę sekund trzymaj w polu.
ZAD 4Quiz bez oszukiwania★★☆

Quiz z 5 pytaniami (RadioGroup). Po obrocie zostaje numer pytania, liczba punktów i lista udzielonych odpowiedzi.

pokaż wskazówkę
  • Listę liczb zapiszesz przez putIntArray("odp", odpowiedzi.toIntArray()), odczytasz: getIntArray("odp")?.toMutableList().
ZAD 5Lista zakupów po obrocie★★☆

Przykład z lekcji 26 traci produkty po obrocie. Zapisz listę w Bundle i odtwórz ją w onCreate — przed utworzeniem adaptera.

pokaż wskazówkę
  • putStringArrayList("zakupy", ArrayList(zakupy)); odczyt: getStringArrayList("zakupy"), potem zakupy.clear(); zakupy.addAll(…) — lista jest val, więc nie podmieniaj jej.
ZAD 6Dwa ekrany w dzienniku★★★

Rozbuduj przykład o drugą aktywność (lekcja 30), która też zapisuje swoje metody cyklu życia z tagiem CYKL i nazwą ekranu („Main: onPause”, „Druga: onCreate”…). Ustal w Logcat dokładną kolejność przy otwieraniu i zamykaniu drugiego ekranu i zapisz ją w komentarzu w kodzie.

pokaż wskazówkę
  • Spodziewaj się przeplotu: Main: onPause → Druga: onCreate, onStart, onResume → Main: onStop. Przy powrocie: Druga: onPause → Main: onRestart, onStart, onResume → Druga: onStop, onDestroy.
PODSUMOWANIE

Co trzeba zapamiętać

  • Ekran żyje etapami: onCreate → onStart → onResume … onPause → onStop → onDestroy.
  • Obrót (i inne zmiany konfiguracji) niszczy obiekt ekranu i tworzy nowy — pola wracają do wartości początkowych.
  • Chwilowy stan: zapis w onSaveInstanceState, odczyt w onCreate z savedInstanceState.
  • Kontrolki z id same pamiętają wpisany tekst i zaznaczenia; tekst ustawiony z kodu — nie.
  • Do podglądania: Log.d("TAG", …) i filtr w Logcat.

Dokumentacja: Cykl życia aktywności, Zapisywanie stanu interfejsu.

Postęp zapisuje się w tej przeglądarce.