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

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

SharedPreferences — zapis ustawień

Wszystko, co do tej pory zapamiętywała aplikacja, ginęło po jej zamknięciu: pola klasy przy zniszczeniu ekranu, Bundle po wyjściu przyciskiem Wstecz. Imię użytkownika, wybrane ustawienia, wynik z wczoraj — to trzeba zapisać w pamięci telefonu. Najprostszy sposób to SharedPreferences: mały plik z parami „klucz → wartość”. Zbudujesz licznik wypitej wody, który pamięta imię, cel i rekord, a o północy sam zaczyna nowy dzień.

Kotlin XML SharedPreferences getSharedPreferences edit / apply Device Explorer 2 × 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Ocenisz, które dane trzymać w polu, w Bundle, a które w SharedPreferences.
  • Otworzysz plik ustawień przez getSharedPreferences.
  • Zapiszesz wartości przez edit(), put… i apply().
  • Odczytasz je z wartościami domyślnymi i usuniesz (remove, clear).
  • Podejrzysz zapisany plik w Device Explorer.
TEORIA

Gdzie trzymać dane

MiejsceJak długo żyjeDo czego
pole klasyDo zniszczenia ekranu (np. obrotu).Bieżąca praca ekranu.
Bundle (lekcja 36)Przez obrót i krótki pobyt w tle. Ginie po Wstecz.Chwilowy stan: wynik, numer pytania.
SharedPreferencesDo odinstalowania aplikacji lub wyczyszczenia jej danych.Ustawienia i małe wartości: imię, motyw, rekord, data.
baza danych (Room)Jak wyżej.Dużo podobnych rekordów: notatki, historia, lista zakupów.

SharedPreferences nadaje się do kilku–kilkudziesięciu pojedynczych wartości. Lista stu notatek to już zadanie dla bazy danych — tej w kursie nie omawiamy.

TEORIA

Otwarcie pliku ustawień

w onCreate
private lateinit var prefs: SharedPreferences      // pole klasy

// w onCreate:
prefs = getSharedPreferences("woda", MODE_PRIVATE)
FragmentZnaczenie
"woda"Nazwa pliku. Jeśli go nie ma, Android utworzy go przy pierwszym zapisie. Różne nazwy = różne pliki.
MODE_PRIVATEPlik dostępny tylko dla twojej aplikacji. Innych trybów się już nie używa.

Wywołuj tę metodę w onCreate (albo później), a nie przy deklaracji pola — w chwili tworzenia pól aktywność nie jest jeszcze gotowa do pracy z plikami.

TEORIA

Zapis: edit i apply

Zapis przypomina wypełnianie formularza: bierzesz formularz zmian (edit()), wpisujesz wartości (put…) i oddajesz go (apply()). Dopiero apply() zapisuje zmiany.

zapis kilku wartości
prefs.edit()
    .putString("imie", "Ola")
    .putInt("cel", 8)
    .putBoolean("powiadomienia", true)
    .apply()                         // zapisz (w tle, nie blokuje ekranu)
MetodaCo robi
apply()Zapisuje w tle — aplikacja nie czeka. Używaj jej.
commit()Zapisuje od razu i czeka na koniec zapisu (zwraca true/false). Może przyciąć ekran — tylko gdy naprawdę musisz znać wynik.

Krótszy zapis z biblioteki KTX

Android Studio może podkreślić edit()…apply() i zaproponować krótszą wersję z biblioteki androidx.core (jest już w projekcie):

prefs.edit {                     // import androidx.core.content.edit
    putString("imie", "Ola")
    putInt("cel", 8)
}                                // apply() wykona się samo

Obie wersje robią to samo. W lekcji używamy pełnej, żeby było widać każdy krok.

TEORIA

Odczyt i usuwanie

odczyt — zawsze z wartością domyślną
val imie = prefs.getString("imie", "") ?: ""      // pierwsze uruchomienie → ""
val cel = prefs.getInt("cel", 8)                    // brak klucza → 8
val powiadomienia = prefs.getBoolean("powiadomienia", true)

Drugi argument to wartość na pierwsze uruchomienie, gdy pliku jeszcze nie ma. Tak jak w intencjach (lekcja 31) getString zwraca String?, więc dopisujemy ?: "".

usuwanie
prefs.edit().remove("imie").apply()    // jeden klucz
prefs.edit().clear().apply()           // wszystko z tego pliku
prefs.contains("imie")                 // czy klucz istnieje
TEORIA

Jak wygląda plik

To zwykły plik XML w prywatnym folderze aplikacji. Na emulatorze możesz go zobaczyć: View → Tool Windows → Device Explorer → data/data/pl.schoolit.woda/shared_prefs/woda.xml (dwuklik otwiera go w Android Studio).

shared_prefs/woda.xml — zawartość po kilku kliknięciach
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="imie">Ola</string>
    <int name="cel" value="8" />
    <int name="szklanki" value="5" />
    <int name="rekord" value="9" />
    <string name="data">2026-10-12</string>
</map>

To nie jest sejf

Plik nie jest szyfrowany. Na telefonie z dostępem administratora (root) można go przeczytać. Nie zapisuj tu haseł ani danych, które muszą być tajne.

PRZYKŁAD

Licznik wody

Aplikacja liczy szklanki wypite dzisiaj. Pamięta imię, dzienny cel (suwak 4–12), liczbę szklanek, rekord i datę. Po zamknięciu i ponownym otwarciu wszystko jest na miejscu. Gdy data zapisana w pliku jest inna niż dzisiejsza, licznik zaczyna od zera, ale rekord zostaje. Usuń dane czyści cały plik.

14:20
Cześć, Ola! 💧
Ola
Zapisz
5 / 8
−
+
Dzienny cel: 8
Rekord: 9
Usuń dane
poniedziałek, 14:20
→zamknięcie,
ponowne
otwarcie
19:45
Cześć, Ola! 💧
Ola
Zapisz
5 / 8
−
+
Dzienny cel: 8
Rekord: 9
Usuń dane
ten sam dzień — dane wczytane z pliku
7:10
Cześć, Ola! 💧
Ola
Zapisz
0 / 8
−
+
Dzienny cel: 8
Rekord: 9
Usuń dane
Nowy dzień — licznik od zera
wtorek rano — nowy dzień, rekord został

Przygotowanie projektu

  1. Nowy projekt Empty Views Activity, nazwa Woda, Kotlin, API 24.
  2. Podmień activity_main.xml i MainActivity.kt.
res/layout/activity_main.xml — pełny plik
<?xml version="1.0" encoding="utf-8"?>
<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:id="@+id/tvPowitanie"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="24sp"
            android:textStyle="bold" />

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

            <EditText
                android:id="@+id/etImie"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:hint="Twoje imię"
                android:inputType="textPersonName|textCapWords" />

            <Button
                android:id="@+id/btnZapiszImie"
                style="@style/Widget.Material3.Button.OutlinedButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Zapisz" />
        </LinearLayout>

        <TextView
            android:id="@+id/tvSzklanki"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:gravity="center"
            android:textSize="56sp"
            android:textStyle="bold" />

        <ProgressBar
            android:id="@+id/pbPostep"
            style="?android:attr/progressBarStyleHorizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleY="2" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp">

            <Button
                android:id="@+id/btnMinus"
                style="@style/Widget.Material3.Button.OutlinedButton"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:layout_marginEnd="8dp"
                android:text="−"
                android:textSize="22sp" />

            <Button
                android:id="@+id/btnPlus"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="+"
                android:textSize="22sp" />
        </LinearLayout>

        <TextView
            android:id="@+id/tvCel"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp" />

        <!-- 0…8 na suwaku = cel 4…12 (lekcja 14) -->
        <SeekBar
            android:id="@+id/sbCel"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:max="8" />

        <TextView
            android:id="@+id/tvRekord"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp" />

        <Button
            android:id="@+id/btnUsunDane"
            style="@style/Widget.Material3.Button.TextButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="Usuń dane"
            android:textColor="#B3261E" />
    </LinearLayout>

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

import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

class MainActivity : AppCompatActivity() {

    // ---------- kontrolki ----------
    private lateinit var glownyUklad: View
    private lateinit var tvPowitanie: TextView
    private lateinit var etImie: EditText
    private lateinit var btnZapiszImie: Button
    private lateinit var tvSzklanki: TextView
    private lateinit var pbPostep: ProgressBar
    private lateinit var btnMinus: Button
    private lateinit var btnPlus: Button
    private lateinit var tvCel: TextView
    private lateinit var sbCel: SeekBar
    private lateinit var tvRekord: TextView
    private lateinit var btnUsunDane: Button

    // ---------- pamięć trwała ----------
    private lateinit var prefs: SharedPreferences

    // ---------- stan ----------
    private val CEL_MIN = 4
    private var imie = ""
    private var cel = 8
    private var szklanki = 0
    private var rekord = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initUI()
        prefs = getSharedPreferences("woda", MODE_PRIVATE)
        wczytaj()
        ustawZdarzenia()
        pokazStan()
    }

    private fun initUI() {
        glownyUklad = findViewById(R.id.main)
        tvPowitanie = findViewById(R.id.tvPowitanie)
        etImie = findViewById(R.id.etImie)
        btnZapiszImie = findViewById(R.id.btnZapiszImie)
        tvSzklanki = findViewById(R.id.tvSzklanki)
        pbPostep = findViewById(R.id.pbPostep)
        btnMinus = findViewById(R.id.btnMinus)
        btnPlus = findViewById(R.id.btnPlus)
        tvCel = findViewById(R.id.tvCel)
        sbCel = findViewById(R.id.sbCel)
        tvRekord = findViewById(R.id.tvRekord)
        btnUsunDane = findViewById(R.id.btnUsunDane)
    }

    // ---------- plik → pola ----------
    private fun wczytaj() {
        imie = prefs.getString("imie", "") ?: ""
        cel = prefs.getInt("cel", 8)
        rekord = prefs.getInt("rekord", 0)

        val zapisanaData = prefs.getString("data", "")
        if (zapisanaData == dzisiaj()) {
            szklanki = prefs.getInt("szklanki", 0)
        } else {
            szklanki = 0                                   // nowy dzień (albo pierwsze uruchomienie)
            if (zapisanaData != "") {
                Snackbar.make(glownyUklad, "Nowy dzień — licznik od zera", Snackbar.LENGTH_LONG).show()
            }
            zapisz()
        }

        // pola formularza ustawiamy RAZ, przy starcie
        etImie.setText(imie)
        sbCel.progress = cel - CEL_MIN
    }

    // ---------- pola → plik ----------
    private fun zapisz() {
        prefs.edit()
            .putString("imie", imie)
            .putInt("cel", cel)
            .putInt("szklanki", szklanki)
            .putInt("rekord", rekord)
            .putString("data", dzisiaj())
            .apply()
    }

    private fun ustawZdarzenia() {
        btnZapiszImie.setOnClickListener {
            imie = etImie.text.toString().trim()
            zapisz()
            pokazStan()
        }
        btnPlus.setOnClickListener { dodajSzklanke() }
        btnMinus.setOnClickListener {
            if (szklanki > 0) szklanki--
            zapisz()
            pokazStan()
        }
        sbCel.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                cel = progress + CEL_MIN
                pokazStan()
            }
            override fun onStartTrackingTouch(seekBar: SeekBar?) {}
            override fun onStopTrackingTouch(seekBar: SeekBar?) {
                zapisz()                                   // zapis po puszczeniu suwaka, nie przy każdym ruchu
            }
        })
        btnUsunDane.setOnClickListener { zapytajOUsuniecie() }
    }

    // ---------- akcje ----------
    private fun dodajSzklanke() {
        szklanki++
        if (szklanki > rekord) rekord = szklanki
        zapisz()
        pokazStan()
        if (szklanki == cel) {
            Snackbar.make(glownyUklad, "Cel na dziś osiągnięty! 🎉", Snackbar.LENGTH_SHORT).show()
        }
    }

    private fun zapytajOUsuniecie() {
        AlertDialog.Builder(this)
            .setTitle("Usunąć wszystkie dane?")
            .setMessage("Imię, cel, licznik i rekord zostaną skasowane.")
            .setPositiveButton("Usuń") { _, _ ->
                prefs.edit().clear().apply()               // cały plik „woda”
                imie = ""; cel = 8; szklanki = 0; rekord = 0
                etImie.text.clear()
                sbCel.progress = cel - CEL_MIN
                pokazStan()
            }
            .setNegativeButton("Anuluj", null)
            .show()
    }

    // ---------- pomocnicze ----------
    private fun dzisiaj(): String =
        SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())

    // ---------- stan → ekran ----------
    private fun pokazStan() {
        tvPowitanie.text = if (imie.isEmpty()) "Cześć! Wpisz swoje imię." else "Cześć, $imie! 💧"
        tvSzklanki.text = "$szklanki / $cel"
        pbPostep.max = cel
        pbPostep.progress = minOf(szklanki, cel)
        tvCel.text = "Dzienny cel: $cel"
        tvRekord.text = "Rekord: $rekord"
        btnMinus.isEnabled = szklanki > 0
    }
}

Co dzieje się po kolei

  • W onCreate otwieramy plik woda i wywołujemy wczytaj(): plik → pola klasy. Przy pierwszym uruchomieniu wszystkie odczyty zwracają wartości domyślne.
  • Data zapisana jest jako napis "2026-10-12". Jeśli różni się od dzisiejszej, licznik szklanek wraca do zera, a nowa data od razu trafia do pliku. Rekord i cel zostają.
  • Każda zmiana (szklanka, imię, puszczenie suwaka) to: zmiana pola → zapisz() → pokazStan(). Plik zawsze ma aktualne dane, więc nieważne, jak aplikacja zostanie zamknięta.
  • onProgressChanged tylko pokazuje nowy cel; zapis jest w onStopTrackingTouch — nie ma sensu zapisywać pliku kilkadziesiąt razy na sekundę.
  • Usuń dane: clear() czyści plik, a potem przywracamy domyślne wartości pól i formularza.

Sprawdź trzy rzeczy

  1. Dodaj 3 szklanki, zamknij aplikację Wstecz i usuń ją z listy ostatnich aplikacji. Uruchom ponownie — 3 szklanki są na miejscu. (W lekcji 36 Bundle by tego nie przetrwał.)
  2. Otwórz w Device Explorer plik shared_prefs/woda.xml i porównaj go z tym, co widzisz na ekranie. Potem w Ustawieniach emulatora (System → Data i godzina) wyłącz automatyczną datę i ustaw jutrzejszą. Po ponownym uruchomieniu aplikacji zobaczysz „Nowy dzień”.
  3. Zakomentuj .apply() w zapisz(). Kod się kompiluje, ale po ponownym uruchomieniu wszystko jest wyzerowane — bez apply() nic nie zostało zapisane.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementTypCo robi
getSharedPreferences("nazwa", MODE_PRIVATE)SharedPreferencesOtwiera (lub tworzy) prywatny plik ustawień.
prefs.edit()EditorRozpoczyna zmiany.
putString / putInt / putBoolean / putFloat / putLongmetodyWpisuje wartość pod kluczem.
apply() / commit()metodyZapisuje zmiany: w tle / natychmiast z czekaniem.
getString("k", "") / getInt("k", 0) / getBoolean("k", false)metodyOdczyt z wartością domyślną.
remove("k"), clear()metody EditorUsuwa klucz / wszystkie klucze (też trzeba apply()).
prefs.contains("k")BooleanCzy klucz jest zapisany.
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())StringDzisiejsza data jako napis.
minOf(a, b)funkcjaMniejsza z dwóch wartości.
Device ExplorerAndroid StudioPodgląd plików emulatora, m.in. shared_prefs.
CZĘSTE BŁĘDY

Zanim utkniesz

Zapis / objawProblem
po ponownym uruchomieniu wszystko wyzerowaneBrak apply() na końcu zapisu.
prefs.edit().putInt("a", 1), potem prefs.edit().apply()Każde edit() tworzy nowy formularz zmian. Wpisy i apply() muszą być na tym samym — w jednym łańcuchu.
odczyt zwraca zawsze wartość domyślnąInna nazwa pliku ("woda" / "Woda") albo inny klucz przy zapisie i odczycie.
awaria ClassCastException przy odczyciePod kluczem zapisano inny typ (np. putString("cel", "8"), a odczyt getInt("cel", 8)). Ten sam klucz — zawsze ten sam typ.
private val prefs = getSharedPreferences(…) jako pole → awaria przy starcieAktywność nie jest jeszcze gotowa. Otwieraj w onCreate (pole lateinit var).
suwak przy starcie nadpisuje zapisany celListener ustawiony przed sbCel.progress = …. Najpierw wczytaj i ustaw kontrolki, potem ustawZdarzenia().
zapis listy 200 notatek jako jednego napisuDa się, ale to nie jest zadanie dla SharedPreferences. Duże zbiory danych — baza danych.
ZADANIA

Zadania

Każde zadanie wykonaj w nowym projekcie Empty Views Activity. Sprawdzaj działanie, zamykając aplikację i usuwając ją z listy ostatnich. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.

ZAD 1Licznik uruchomień★☆☆

Aplikacja wyświetla „Uruchomiono 7 razy”. Każde uruchomienie zwiększa liczbę o 1. Przy pierwszym — „Witaj po raz pierwszy!”.

pokaż wskazówkę
  • W onCreate: odczyt getInt("uruchomienia", 0), +1, zapis. Pamiętaj, że onCreate wywołuje się też po obrocie — to w tym zadaniu dozwolone.
ZAD 2Zapamiętany tryb nocny★☆☆

Notatnik z lekcji 35 ma pamiętać rozmiar czcionki, tryb nocny i treść notatki także po zamknięciu aplikacji.

pokaż wskazówkę
  • Zapis treści najprościej w onStop (lekcja 36): putString("notatka", etNotatka.text.toString()). Rozmiar: putFloat.
ZAD 3Ekran ustawień★★☆

Ekran główny z napisem powitania. Menu (lekcja 35) → Ustawienia otwiera drugą aktywność z polem imienia, przełącznikiem „Pokazuj datę” i RadioGroup koloru tła. Ustawienia zapisują się od razu po zmianie. Po powrocie ekran główny pokazuje nowe ustawienia.

pokaż wskazówkę
  • Oba ekrany otwierają ten sam plik (ta sama nazwa). Ekran główny odczytuje ustawienia w onStart — wtedy odświeży się także po powrocie z ustawień (lekcja 36).
ZAD 4Najlepszy wynik w grze★★☆

Gra „Kliknij jak najwięcej w 10 sekund” (CountDownTimer, lekcja 15). Po końcu rundy aplikacja porównuje wynik z rekordem z pliku, a przy nowym rekordzie pokazuje AlertDialog „Nowy rekord!”. Rekord widać na starcie.

pokaż wskazówkę
  • W onFinish(): if (klikniecia > rekord) { rekord = klikniecia; prefs.edit().putInt("rekord", rekord).apply() }.
ZAD 5Ekran powitalny tylko raz★★☆

Przy pierwszym uruchomieniu aplikacja pokazuje ekran „Jak korzystać z aplikacji” z przyciskiem Rozumiem. Przy kolejnych uruchomieniach od razu ekran główny.

pokaż wskazówkę
  • Ekran główny w onCreate: if (!prefs.getBoolean("samouczek", false)) startActivity(…). Rozumiem zapisuje true i woła finish().
ZAD 6Lista zakupów, która przetrwa★★★

Przykład z lekcji 26 ma zachowywać listę po zamknięciu aplikacji. Zapisz ją jako jeden napis z produktami oddzielonymi znakiem nowej linii i odtwórz przy starcie. Zapis po każdej zmianie listy.

pokaż wskazówkę
  • Zapis w pokazListe(): putString("zakupy", zakupy.joinToString("\n")). Odczyt przed utworzeniem adaptera: val tekst = prefs.getString("zakupy", "") ?: "", if (tekst.isNotEmpty()) { zakupy.clear(); zakupy.addAll(tekst.split("\n")) }. To dobre rozwiązanie dla krótkiej listy — dla długiej użyłbyś bazy danych.
PODSUMOWANIE

Co trzeba zapamiętać

  • SharedPreferences = mały prywatny plik „klucz → wartość”; przetrwa zamknięcie aplikacji i restart telefonu.
  • Otwarcie: getSharedPreferences("nazwa", MODE_PRIVATE) w onCreate.
  • Zapis: edit().put…().apply() — bez apply() nic się nie zapisze.
  • Odczyt: get…("klucz", domyślna); ten sam klucz zawsze z tym samym typem.
  • Do ustawień i małych wartości — nie do haseł i nie do dużych list.

To koniec rozdziału 9. Ostatni rozdział porządkuje kod: View Binding zamiast findViewById i architektura MVVM.

Dokumentacja: Zapisywanie prostych danych: SharedPreferences.

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