Lekcja 17. Bazy danych SQLite w aplikacji mobilnej — wprowadzenie
Trudny / egzaminacyjnyPo co się tego uczymy?
Pliki TXT i JSON z poprzedniej lekcji świetnie nadają się do prostych danych, ale gdy aplikacja ma przechowywać setki czy tysiące rekordów, sortować je, wyszukiwać i filtrować — zapisywanie całej listy jako jeden plik JSON za każdym razem robi się nieefektywne i niewygodne. Do tego służy prawdziwa baza danych — a w aplikacjach mobilnych standardem jest SQLite, dokładnie ta sama technologia bazodanowa, którą poznałeś już w dziale "Bazy danych i SQL", tylko teraz osadzona bezpośrednio wewnątrz aplikacji.
Teoria
Dlaczego akurat SQLite w aplikacjach mobilnych? Cała baza danych to JEDEN plik (rozszerzenie .db3 albo .db) — nie trzeba instalować ani konfigurować żadnego oddzielnego serwera bazodanowego. SQLite działa identycznie na Androidzie, iOS, Windows i macOS, działa w pełni OFFLINE (bez połączenia z internetem) i obsługuje normalne zapytania SQL (SELECT, INSERT, UPDATE, DELETE) — dokładnie te same polecenia, których nauczyłeś się w dziale baz danych. Na egzaminie INF.04, jeśli w zadaniu pojawia się baza danych w aplikacji mobilnej, to niemal zawsze jest to właśnie SQLite.
Przygotowanie projektu. SQLite w MAUI obsługuje się przez bibliotekę (pakiet NuGet) sqlite-net-pcl — instalujesz ją przez PPM na projekt → Manage NuGet Packages… → wyszukaj i zainstaluj. Zalecana struktura folderów (analogiczna do poprzednich lekcji): folder Models na klasy reprezentujące tabele, folder Data na klasę obsługującą połączenie z bazą i zapytania. Sam plik bazy danych zapisujemy w tym samym bezpiecznym miejscu co pliki z poprzedniej lekcji: FileSystem.AppDataDirectory.
Model danych = tabela. Każda klasa modelu, którą chcesz zapisywać w SQLite, oznaczasz atrybutami z biblioteki SQLite:
| Atrybut | Zastosowanie |
|---|---|
[PrimaryKey, AutoIncrement] |
pole Id jest kluczem głównym, a baza sama nadaje kolejne numery (identycznie jak AUTO_INCREMENT w SQL) |
[MaxLength(100)] |
ogranicza maksymalną długość tekstu w danej kolumnie |
[NotNull] |
kolumna nie może być pusta (odpowiednik NOT NULL w SQL) |
[Ignore] |
właściwość klasy C#, która ma NIE być zapisywana jako kolumna w tabeli |
Klasa obsługi bazy (serwis). Zamiast pisać zapytania SQL ręcznie w każdej stronie aplikacji, tworzy się jedną klasę (np. NotatkaDatabase) skupiającą WSZYSTKIE operacje na danej tabeli — dokładnie ta sama zasada oddzielenia logiki od UI z wcześniejszych lekcji. Klasa ta trzyma połączenie SQLiteAsyncConnection i udostępnia metody odpowiadające podstawowym operacjom SQL:
| Metoda biblioteki | Odpowiednik SQL |
|---|---|
CreateTableAsync<T>() |
CREATE TABLE (tworzy tabelę, jeśli jeszcze nie istnieje) |
Table<T>().ToListAsync() |
SELECT * FROM tabela |
InsertAsync(obiekt) |
INSERT INTO tabela ... |
UpdateAsync(obiekt) |
UPDATE tabela SET ... WHERE Id = ... |
DeleteAsync(obiekt) |
DELETE FROM tabela WHERE Id = ... |
Wszystkie te metody są ASYNCHRONICZNE (zwracają Task/Task<T> i wymagają await) — operacje na bazie danych, podobnie jak na plikach, mogą chwilę trwać i nie powinny blokować interfejsu użytkownika.
Globalny dostęp do bazy. Bazę danych zwykle inicjalizuje się RAZ, przy starcie aplikacji, w konstruktorze klasy App (plik App.xaml.cs), i udostępnia jako statyczną właściwość (App.Database) — dzięki temu KAŻDA strona aplikacji ma dostęp do tego samego połączenia z bazą, bez potrzeby tworzenia nowego przy każdym wejściu na stronę.
Schemat
App.xaml.cs (start aplikacji)
│
│ dbPath = Path.Combine(AppDataDirectory, "notatki.db3")
│ Database = new NotatkaDatabase(dbPath)
▼
NotatkaDatabase (Data/) Notatka (Models/)
┌────────────────────────────┐ ┌──────────────────────┐
│ SQLiteAsyncConnection │ operuje │ [PrimaryKey, │
│ GetNotatkiAsync() (SELECT)│──na────────►│ AutoIncrement] Id │
│ ZapiszNotatkeAsync() (INSERT│ obiektach │ [MaxLength(100)] Tytul│
│ UsunNotatkeAsync() (DELETE)│ tej klasy │ Tresc │
└──────────────┬─────────────┘ └──────────────────────┘
│ App.Database.XXX()
▼
NotatkiPage.xaml.cs — Dodaj_Click / WczytajNotatki / Usun_Click
│
▼
CollectionView — wyświetla listę notatek z bazy
Przykład z życia
Aplikacja notatnika przechowująca setki notatek nie mogłaby efektywnie działać na jednym pliku JSON odczytywanym i zapisywanym w całości przy każdej zmianie — SQLite pozwala dodawać, usuwać i wyszukiwać pojedyncze notatki bez przepisywania całego zbioru danych za każdym razem, dokładnie tak jak działają prawdziwe aplikacje typu Notatnik Google czy Evernote (choć oczywiście w znacznie bardziej rozbudowanej wersji).
NotatkiPage.xaml
<!-- NotatkiPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="BazyDemo.NotatkiPage"
Title="Notatki">
<VerticalStackLayout Padding="16" Spacing="12">
<Entry x:Name="TytulEntry" Placeholder="Tytuł notatki" />
<Editor x:Name="TrescEntry" Placeholder="Treść notatki"
AutoSize="TextChanges" HeightRequest="120" Margin="0,4,0,0" />
<Button Text="Dodaj notatkę" Clicked="Dodaj_Click" Margin="0,8,0,0" />
<CollectionView x:Name="ListaNotatek" HeightRequest="300"
SelectionMode="Single" Margin="0,12,0,0">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame BorderColor="Gray" Padding="8" Margin="0,6" HasShadow="False">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding Tytul}" FontAttributes="Bold" FontSize="16" />
<Label Text="{Binding Tresc}" FontSize="14" LineBreakMode="WordWrap" />
</VerticalStackLayout>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button Text="Usuń zaznaczoną" Clicked="Usun_Click" Margin="0,8,0,0" />
</VerticalStackLayout>
</ContentPage>
Models/Notatka.cs + Data/NotatkaDatabase.cs + App.xaml.cs + NotatkiPage.xaml.cs
// Models/Notatka.cs - model = tabela w SQLite
using SQLite;
namespace BazyDemo.Models;
public class Notatka
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(100)]
public string Tytul { get; set; } = "";
public string Tresc { get; set; } = "";
}
// ---------------------------------------------------------------------
// Data/NotatkaDatabase.cs - obsługa bazy (CRUD)
using SQLite;
using BazyDemo.Models;
namespace BazyDemo.Data;
public class NotatkaDatabase
{
private readonly SQLiteAsyncConnection _database;
public NotatkaDatabase(string dbPath)
{
_database = new SQLiteAsyncConnection(dbPath);
_database.CreateTableAsync<Notatka>().Wait();
}
public Task<List<Notatka>> GetNotatkiAsync() => _database.Table<Notatka>().ToListAsync();
public Task<int> ZapiszNotatkeAsync(Notatka notatka) => _database.InsertAsync(notatka);
public Task<int> UsunNotatkeAsync(Notatka notatka) => _database.DeleteAsync(notatka);
}
// ---------------------------------------------------------------------
// App.xaml.cs - inicjalizacja bazy przy starcie aplikacji
using BazyDemo.Data;
namespace BazyDemo;
public partial class App : Application
{
public static NotatkaDatabase Database { get; private set; }
public App()
{
InitializeComponent();
string dbPath = Path.Combine(FileSystem.AppDataDirectory, "notatki.db3");
Database = new NotatkaDatabase(dbPath);
MainPage = new AppShell();
}
}
// ---------------------------------------------------------------------
// NotatkiPage.xaml.cs
using BazyDemo.Models;
namespace BazyDemo;
public partial class NotatkiPage : ContentPage
{
public NotatkiPage()
{
InitializeComponent();
WczytajNotatki();
}
private async void WczytajNotatki()
{
ListaNotatek.ItemsSource = await App.Database.GetNotatkiAsync();
}
private async void Dodaj_Click(object sender, EventArgs e)
{
if (!string.IsNullOrWhiteSpace(TytulEntry.Text))
{
var nowa = new Notatka { Tytul = TytulEntry.Text, Tresc = TrescEntry.Text };
await App.Database.ZapiszNotatkeAsync(nowa);
TytulEntry.Text = "";
TrescEntry.Text = "";
WczytajNotatki();
}
else
{
await DisplayAlert("Błąd", "Podaj tytuł notatki", "OK");
}
}
private async void Usun_Click(object sender, EventArgs e)
{
if (ListaNotatek.SelectedItem is Notatka wybrana)
{
await App.Database.UsunNotatkeAsync(wybrana);
WczytajNotatki();
}
else
{
await DisplayAlert("Info", "Najpierw zaznacz notatkę", "OK");
}
}
}
Komentarz i wyjaśnienie kodu
Zwróć uwagę na podział odpowiedzialności między CZTERY pliki: Notatka.cs (co to za dane), NotatkaDatabase.cs (jak się z nimi komunikować — CRUD), App.xaml.cs (kiedy i gdzie baza jest tworzona, jedna instancja dla całej aplikacji) i NotatkiPage.xaml.cs (jak dane są pokazywane i modyfikowane przez użytkownika).
WczytajNotatki() jest wywoływana DWUKROTNIE w tym przykładzie: raz w konstruktorze strony (żeby lista była wypełniona od razu po otwarciu), i ponownie po każdej zmianie danych (dodaniu, usunięciu) — to gwarantuje, że CollectionView zawsze pokazuje AKTUALNY stan bazy danych, a nie "starą" wersję listy z pamięci.
ListaNotatek.SelectedItem is Notatka wybrana to wzorzec dopasowania (pattern matching) — sprawdza jednocześnie, czy coś jest zaznaczone (nie jest null) ORAZ rzutuje to na typ Notatka, przypisując do zmiennej wybrana w jednym kroku. Dzięki SelectionMode="Single" w XAML, CollectionView pozwala zaznaczyć dokładnie jedną pozycję na liście (kliknięciem), a SelectedItem zwraca właśnie ten wybrany obiekt.
Ćwiczenie samodzielne
Utwórz nowy projekt MAUI, zainstaluj pakiet sqlite-net-pcl, i odtwórz powyższy przykład notatnika. Sprawdź na emulatorze: dodaj kilka notatek, zamknij aplikację CAŁKOWICIE, uruchom ponownie i upewnij się, że notatki nadal tam są (w przeciwieństwie do zwykłej listy w pamięci, dane w SQLite przetrwają restart).
Zadania do pracy własnej
Dodaj do modelu
NotatkapoleDataUtworzeniatypuDateTime, ustawiane automatycznie naDateTime.Nowprzy tworzeniu nowej notatki, i wyświetl tę datę w szablonie listy.Dodaj do
NotatkaDatabasemetodęAktualizujNotatkeAsync(Notatka notatka)korzystającą z_database.UpdateAsync(notatka), i wykorzystaj ją do zaimplementowania edycji istniejącej notatki (kliknięcie notatki na liście wypełnia polaEntry/Editorjej danymi, a przycisk zmienia się na "Zapisz zmiany").Zbuduj aplikację "Lista kontaktów" z modelem
Kontakt(Imię, Nazwisko, Telefon, Email) i pełnym CRUD-em przez SQLite: dodawanie, wyświetlanie posortowanej alfabetycznie listy (podpowiedź: LINQOrderByna wyniku zGetNotatkiAsync-podobnej metody), edycję i usuwanie, oraz pole wyszukiwania filtrujące listę po imieniu lub nazwisku (analogicznie do filtrowania z lekcji o CollectionView).
Typowe błędy
Tworzenie NOWEGO połączenia SQLiteAsyncConnection w każdej stronie zamiast korzystania z jednej, współdzielonej instancji przez App.Database — prowadzi do niepotrzebnego zużycia zasobów i potencjalnych konfliktów przy jednoczesnym dostępie do tego samego pliku bazy.
Zapominanie o CreateTableAsync<T>() przy starcie — bez tego wywołania tabela nigdy nie zostanie utworzona, a każda próba operacji na niej zakończy się błędem "no such table"; metoda jest bezpieczna do wywoływania za każdym razem (nie nadpisuje istniejącej tabeli, jeśli już istnieje).
Brak odświeżenia listy (WczytajNotatki()) po operacji zmieniającej dane — InsertAsync/DeleteAsync zmieniają bazę danych, ale NIE odświeżają automatycznie ItemsSource na ekranie; trzeba to zrobić ręcznie, ponownie pobierając listę.
Mylenie [PrimaryKey, AutoIncrement] z ręcznym ustawianiem Id — jeśli pole jest oznaczone jako auto-inkrementowane, nie należy samodzielnie przypisywać mu wartości przed InsertAsync (baza sama nada kolejny numer); ręczne ustawienie może prowadzić do konfliktów kluczy.
Nawiązanie do egzaminu zawodowego
To bezpośrednia realizacja wymagania INF.04.6.2 "baza danych w aplikacji mobilnej" oraz naturalne połączenie z całym działem INF.03.4 (bazy danych i SQL) — te same pojęcia (tabela, klucz główny, SELECT/INSERT/DELETE) występują tu w nowym kontekście: nie osobnego programu bazodanowego, tylko wbudowanej w aplikację mobilną biblioteki. Kolejna lekcja rozwinie ten temat o praktyczny projekt z dwiema powiązanymi tabelami (produkty i koszyk).