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ń.
Czego się dziś nauczysz
- Ocenisz, które dane trzymać w polu, w
Bundle, a które wSharedPreferences. - Otworzysz plik ustawień przez
getSharedPreferences. - Zapiszesz wartości przez
edit(),put…iapply(). - Odczytasz je z wartościami domyślnymi i usuniesz (
remove,clear). - Podejrzysz zapisany plik w Device Explorer.
Gdzie trzymać dane
| Miejsce | Jak długo żyje | Do czego |
|---|---|---|
| pole klasy | Do 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. |
SharedPreferences | Do 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.
Otwarcie pliku ustawień
private lateinit var prefs: SharedPreferences // pole klasy
// w onCreate:
prefs = getSharedPreferences("woda", MODE_PRIVATE)
| Fragment | Znaczenie |
|---|---|
"woda" | Nazwa pliku. Jeśli go nie ma, Android utworzy go przy pierwszym zapisie. Różne nazwy = różne pliki. |
MODE_PRIVATE | Plik 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.
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.
prefs.edit()
.putString("imie", "Ola")
.putInt("cel", 8)
.putBoolean("powiadomienia", true)
.apply() // zapisz (w tle, nie blokuje ekranu)
| Metoda | Co 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.
Odczyt i usuwanie
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 ?: "".
prefs.edit().remove("imie").apply() // jeden klucz
prefs.edit().clear().apply() // wszystko z tego pliku
prefs.contains("imie") // czy klucz istnieje
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).
<?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.
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.
ponowne
otwarcie
Przygotowanie projektu
- Nowy projekt Empty Views Activity, nazwa
Woda, Kotlin, API 24. - Podmień
activity_main.xmliMainActivity.kt.
<?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>
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
onCreateotwieramy plikwodai wywołujemywczytaj(): 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. onProgressChangedtylko pokazuje nowy cel; zapis jest wonStopTrackingTouch— 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
- Dodaj 3 szklanki, zamknij aplikację Wstecz i usuń ją z listy ostatnich aplikacji. Uruchom ponownie — 3 szklanki są na miejscu. (W lekcji 36
Bundleby tego nie przetrwał.) - Otwórz w Device Explorer plik
shared_prefs/woda.xmli 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ń”. - Zakomentuj
.apply()wzapisz(). Kod się kompiluje, ale po ponownym uruchomieniu wszystko jest wyzerowane — bezapply()nic nie zostało zapisane.
Zestawienie elementów
| Element | Typ | Co robi |
|---|---|---|
getSharedPreferences("nazwa", MODE_PRIVATE) | SharedPreferences | Otwiera (lub tworzy) prywatny plik ustawień. |
prefs.edit() | Editor | Rozpoczyna zmiany. |
putString / putInt / putBoolean / putFloat / putLong | metody | Wpisuje wartość pod kluczem. |
apply() / commit() | metody | Zapisuje zmiany: w tle / natychmiast z czekaniem. |
getString("k", "") / getInt("k", 0) / getBoolean("k", false) | metody | Odczyt z wartością domyślną. |
remove("k"), clear() | metody Editor | Usuwa klucz / wszystkie klucze (też trzeba apply()). |
prefs.contains("k") | Boolean | Czy klucz jest zapisany. |
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) | String | Dzisiejsza data jako napis. |
minOf(a, b) | funkcja | Mniejsza z dwóch wartości. |
| Device Explorer | Android Studio | Podgląd plików emulatora, m.in. shared_prefs. |
Zanim utkniesz
| Zapis / objaw | Problem |
|---|---|
| po ponownym uruchomieniu wszystko wyzerowane | Brak 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 odczycie | Pod 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 starcie | Aktywność nie jest jeszcze gotowa. Otwieraj w onCreate (pole lateinit var). |
| suwak przy starcie nadpisuje zapisany cel | Listener ustawiony przed sbCel.progress = …. Najpierw wczytaj i ustaw kontrolki, potem ustawZdarzenia(). |
| zapis listy 200 notatek jako jednego napisu | Da się, ale to nie jest zadanie dla SharedPreferences. Duże zbiory danych — baza danych. |
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.
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: odczytgetInt("uruchomienia", 0), +1, zapis. Pamiętaj, żeonCreatewywołuje się też po obrocie — to w tym zadaniu dozwolone.
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.
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).
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() }.
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 zapisujetruei wołafinish().
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.
Co trzeba zapamiętać
SharedPreferences= mały prywatny plik „klucz → wartość”; przetrwa zamknięcie aplikacji i restart telefonu.- Otwarcie:
getSharedPreferences("nazwa", MODE_PRIVATE)wonCreate. - Zapis:
edit().put…().apply()— bezapply()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.