Kurs Android (20)

Autor: Damian Chodorek • Opublikowany: 3 września 2016 • Ostatnia aktualizacja: 16 września 2016 • Kategoria: android, kursy

Obiektowa baza danych Realm.

W poprzedniej lekcji dowiedziałeś się w jaki sposób przechowywać dane w aplikacji. Przypomnę tylko, że dwa standardowe sposoby, to SharedPreferences oraz SQLite. Dzięki klasie SharedPreferences, możesz przechowywać proste wartości typu klucz : wartość. Drugi sposób, który przeznaczony jest do przechowywania większych i bardziej złożonych zbiorów danych, to baza danych SQLite, natywnie wspierana przez system.

Dzisiejsza lekcja jest kontynuacją poprzedniej. Jeśli jeszcze się z nią nie zapoznałeś, to warto ją odwiedzić. W poprzedniej lekcji dowiedziałeś się, że prędzej czy później, wyniki które zwróci baza danych SQLite, należy zmapować do obiektów Java. Dziś pokażę Ci rozwiązanie znacznie prostsze – bibliotekę Realm.

Baza danych Realm

Realm to obiektowa baza danych, która powstała z myślą o urządzeniach mobilnych. Jej głównym celem jest więc szybkość oraz ograniczenie zużycia pamięci. SQLite jest bazą relacyjną, co oznacza, że opiera się na relacjach reprezentowanych przez tablice. Dlatego też wyróżniamy w niej takie pojęcia jak tabela, wiersz i kolumna. Realm jest bazą obiektową, co oznacza, że obsługujemy ją wyłącznie przy użyciu obiektów języka Java. Nie ma więc konieczności mapowania obiektu na postać nadającą się do zapisu i odczytu do/z bazy danych, ponieważ dzieje się to domyślnie.

Realm jest często wykorzystywaną bazą danych. Głównie ze względu na prostotę jej użycia i szybkość działania. Baza danych jest na tyle szybka, że operacje odczytu, nawet dużego zbioru danych, możemy robić synchronicznie, czyli w głównym wątku. Poważnym ograniczeniem bazy jest jednak brak możliwości przekazywania obiektów bazodanowych pomiędzy wątkami. Nie możemy więc np. odczytać zbioru danych w jednym wątku (w tle), a następnie wyniku operacji przekazać do głównego wątku, aby dane zostały wyświetlone na ekranie. Nastąpi wtedy wyjątek. Aby to obejść, należy skopiować obiekt realmowy na swój własny POJO, z którym możemy zrobić już wszystko.

Wystarczy teorii, czas na przykład. Utwórz nowy projekt com.damianchodorek.kurs.android20.realmapp. Poniżej layout głównej aktywności activity_main.xml.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:tools="http://schemas.android.com/tools"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical"
              android:paddingBottom="@dimen/activity_vertical_margin"
              android:paddingLeft="@dimen/activity_horizontal_margin"
              android:paddingRight="@dimen/activity_horizontal_margin"
              android:paddingTop="@dimen/activity_vertical_margin"
              tools:context="android20.kurs.damianchodorek.om.realmapp.MainActivity">

    <EditText
        android:id="@+id/new_note_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="0"/>

    <Button
        android:id="@+id/add_new_note_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0"
        android:onClick="addNewNote"
        android:text="Dodaj"/>

    <Button
        android:id="@+id/delete_notes_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0"
        android:onClick="deleteAll"
        android:text="Usuń wszystkie"/>

    <Button
        android:id="@+id/filter_notes"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0"
        android:onClick="filterNotes"
        android:text="Filtruj"/>

    <Button
        android:id="@+id/show_realm_notes"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="0"
        android:onClick="showRealmNotes"
        android:text="Pokaż Realm"/>

    <ListView
        android:id="@+id/note_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
</LinearLayout>

Aplikacja jest brzydko zaprojektowana, ale łatwo przy jej pomocy pokazać przykładowe wykorzystanie Realma. Pierwszy przycisk doda notatkę do bazy danych i odświeży listę notatek. Drugi usunie wszystkie notatki z bazy danych. Trzeci przefiltruje notatki, zostawiając na ekranie tylko te, które w treści zawierają frazę filtered. Ostatni przycisk wyświetli na ekranie obiekty realmowe (szczegóły w dalszej części).

Aplikacja będzie wyświetlać listę notatek, a więc potrzebujemy jeszcze layoutu pojedynczej notatki note_layout.xml.

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:id="@+id/note_layout"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"/>

Aby zaimportować Realma do projektu, należy dodać odpowiednie zależności w pliku app/build.gradle, który u mnie wygląda następująco:

apply plugin: 'com.android.application'
apply plugin: 'realm-android'


buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath "io.realm:realm-gradle-plugin:1.2.0"
    }
}

android {
    compileSdkVersion 24
    buildToolsVersion "24.0.1"

    defaultConfig {
        applicationId "damianchodorek.com.kurs.android20.realmapp"
        minSdkVersion 16
        targetSdkVersion 24
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:24.2.0'
}

Do automatycznie wygenerowanego kodu dodałem więc:

apply plugin: 'realm-android'

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath "io.realm:realm-gradle-plugin:1.2.0"
    }
}

Czas na zorganizowanie kodu. Stwórz pakiet data, a w nim pakiety db, pojo, realm. Zacznijmy od naszego modelu danych. Składa się on z dwóch klas Note oraz NoteRealm. Pierwsza klasa, to zwykły javowy obiekt POJO, który reprezentuje notatkę. Drugi obiekt to jego realmowy odpowiednik. Wygląda dokładnie tak samo jak oryginał, z wyjątkiem tego, iż dziedziczy po klasie RealmObject, aby mógł być zapisany do bazy danych Realm. Wszystkie obiekty, które chcemy zapisywać/odczytywać, muszą dziedziczyć po klasie RealmObject oraz muszą posiadać publiczne gettery i settery. Poniżej definicja klas.

// klasa Note w pakiecie data/pojo
public class Note {
    private Integer id;
    private String noteText;

    public String getNoteText() {
        return noteText;
    }

    public void setNoteText(String noteText) {
        this.noteText = noteText;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return noteText;
    }
}
// klasa NoteRealm w pakiecie data/realm
public class NoteRealm extends RealmObject {
    @PrimaryKey
    private int id;
    private String noteText;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getNoteText() {
        return noteText;
    }

    public void setNoteText(String noteText) {
        this.noteText = noteText;
    }
}

Klasa realmowa posiada również adnotację @PrimaryKey, która oznacza, że dane pole klasy będzie kluczem głównym i Realm będzie traktował je wyjątkowo. Ta adnotacja nie jest wymagana, ale warto ją stosować. Wtedy Realm sam dba przykładowo o to, aby pole było unikalne. Próba wstawienia drugiego obiektu, o takim samym id, spowoduje wyjątek.

Nie ma wymogu, aby stosować kopiowanie obiektów realmowych na POJO i vice versa. Nic nie stoi na przeszkodzie, aby korzystać tylko z jednej klasy, np. NoteRealm (co również pokażę w tym przykładzie). Sam jednak uważam, że warto wprowadzić model danych niezależny od bazy danych czy biblioteki tak, aby nie odczuć zmian, jeśli będziemy musieli z niej zrezygnować. Dodatkowo jak wspomniałem wcześniej – obiekty, które dziedziczą po RealmObject nie mogą być przekazywane pomiędzy wątkami. Nie możemy np. przy użyciu AsyncTask odczytać listy notatek w tle i wyświetlić na ekranie (czyli na wątku głównym), ponieważ zostanie wyrzucony wyjątek.

Stworzyłem jeszcze dodatkową klasę NoteMapper, której zadaniem jest zmapowanie obiektu realmowego na POJO.

// klasa w pakiecie data/db/NoteMapper
public class NoteMapper {
    Note fromRealm(NoteRealm noteRealm) {
        Note note = new Note();
        note.setId(noteRealm.getId());
        note.setNoteText(noteRealm.getNoteText());
        return note;
    }
}

W poprzedniej lekcji opisałem wzorzec projektowy Data Access Object (DAO), który jest obiektem dostępu do danych. Różne części aplikacji (np. aktywności, fragmenty) powinny go stosować w celu odczytu/modyfikacji danych. Nie powinny natomiast korzystać bezpośrednio z Realma, ponieważ jeśli zmieni się API, albo Ty zrezygnujesz z tej technologii, to będziesz musiał dokonywać zmian w całej aplikacji. Jeśli zastosujesz wspomniany wzorzec, to zmian będziesz musiał dokonać tylko w plikach DAO.

W poprzedniej lekcji nasza klasa NotesDao, korzystała z bazy danych SQLite. Zmodyfikujemy ją tak, aby korzystała z Realma. Deklaracje metod publicznych pozostaną bez zmian, więc aktywność, która korzysta z tego obiektu, nie zauważy, że zmieniła się baza danych (i o to właśnie chodzi we wzorcu DAO). Oczywiście, na potrzeby lekcji, dodamy kilka metod. Poniżej kod klasy data/db/NoteDao.

public class NoteDao {
    private Realm realm;
    private RealmConfiguration realmConfig;

    public NoteDao(Context context) {
        // inicjalizacja realma
        realmConfig = new RealmConfiguration.Builder(context).build();
        realm = Realm.getInstance(realmConfig);
    }

    // zamknięcie realma
    public void close() {
        realm.close();
    }

    // wstawienie nowej notatki do bazy danych
    public void insertNote(final Note note) {
        // operacje zapisu muszą odbywać się w transakcji
        realm.beginTransaction();

        // tworzymy nowy obiekt przy pomocy metody createObject()
        NoteRealm noteRealm = realm.createObject(NoteRealm.class);
        noteRealm.setId(generateId());
        noteRealm.setNoteText(note.getNoteText());

        // commitTransaction() zapisuje stan obiektów realmowych do bazy danych
        // jeśli więc stworzyliśmy nowy lub usunęliśmy stary, to w tym momencie
        // te operacje zostaną odwzorowane w bazie
        realm.commitTransaction();
    }

    // pobranie notatki na podstawie jej id
    public Note getNoteById(final int id) {
        // aby pobrać obiekt danej klasy korzystamy z metody where()
        // dodatkowe warunki zapytania definiujemy przy pomocy metod takich jak equalTo()
        // findFirst() lub findAll() to metody, które wykonują zdefiniowane zapytanie
        NoteRealm noteRealm = realm.where(NoteRealm.class).equalTo("id", id).findFirst();
        return new NoteMapper().fromRealm(noteRealm);
    }

    // aktualizacja notatki w bazie
    public void updateNote(final Note note) {
        NoteRealm noteRealm = realm.where(NoteRealm.class).equalTo("id", note.getId()).findFirst();
        realm.beginTransaction();
        noteRealm.setNoteText(note.getNoteText());
        realm.commitTransaction();
    }

    // usunięcie notatki z bazy
    public void deleteNoteById(final long id) {
        realm.executeTransaction(new Realm.Transaction() {
            @Override
            public void execute(Realm realm) {
                realm.where(NoteRealm.class).equalTo("id", id).findFirst().deleteFromRealm();
            }
        });
    }

    // pobranie wszystkich notatek
    public List<Note> getAllNotes() {
        List<Note> notes = new ArrayList<>();
        NoteMapper mapper = new NoteMapper();

        RealmResults<NoteRealm> all = realm.where(NoteRealm.class).findAll();

        for (NoteRealm noteRealm : all) {
            notes.add(mapper.fromRealm(noteRealm));
        }

        return notes;
    }

    // pobranie wszystkich notatek, które zawierają w treści dany tekst
    public List<Note> getNotesLike(String text) {
        List<Note> notes = new ArrayList<>();
        NoteMapper mapper = new NoteMapper();

        RealmResults<NoteRealm> all = realm.where(NoteRealm.class)
                .contains("noteText", text)
                .findAll();

        for (NoteRealm noteRealm : all) {
            notes.add(mapper.fromRealm(noteRealm));
        }

        return notes;
    }

    public List<NoteRealm> getRawNotes(){
        return realm.where(NoteRealm.class).findAll();
    }

    // usunięcie wszystkich notatek
    public void deleteAllNotes() {
        realm.executeTransaction(new Realm.Transaction() {
            @Override
            public void execute(Realm realm) {
                realm.where(NoteRealm.class)
                        .findAll()
                        .deleteAllFromRealm();
            }
        });
    }

    // wygenerowanie nowego id dla notatki
    private int generateId() {
        return realm.where(NoteRealm.class).max("id").intValue() + 1;
    }
}

Oto kilka wniosków, które powinieneś wyciągnąć po spojrzeniu na powyższy kod.

  1. Aby zacząć korzystać z Realma, należy uzyskać instancję bazy danych przy użyciu metody Realm.getInstance().
  2. Instancję Realma musimy w końcu zamknąć, korzystając z metody realm.close(). Możemy to zrobić zaraz po wykonaniu operacji lub np. podczas niszczenia aktywności.
  3. Modyfikacje bazy danych (tworzenie, modyfikacja i usuwanie obiektów) musi odbyć się w transakcji. Transakcję możemy wykonać na dwa sposoby. Pierwszy jest przy użyciu metody public void executeTransaction(Transaction transaction), do której przekażemy obiekt zawierający instrukcje, które chcemy wykonać na bazie (np. w metodzie deleteAllNotes()). Drugi sposób na transakcję, to rozpoczęcie jej metodą realm.beginTransaction() i zakończenie realm.commitTransaction(). Jeśli chcemy, to Realm wspiera również transakcje asynchroniczne przy pomocy metody executeTransactionAsync(). Warto z niej korzystać jeśli mamy dużo operacji do wykonania i nie chcemy blokować głównego wątku.
  4. Nowe obiekty w Realmie tworzymy przy pomocy metody realm.createObject(). Możemy również stworzyć obiekt korzystając z operatora new; wtedy będziemy musieli skopiować go do Realma, korzystając z metody copyToRealm().
  5. Aby pobrać obiekty danej klasy korzystamy z metody realm.where() np. realm.where(NoteRealm.class), która zwraca obiekt typu RealmQuery. Do zapytania możemy dołożyć kolejne warunki, korzystając z metod np. equalTo(), contains(). Kiedy nasze zapytanie będzie gotowe możemy je wykonać metodą findFirst() lub findAll(), które zwrócą wyniki zapytania. Jeśli findFirst() nie znajdzie żadnych obiektów, które będą pasować do zapytania, to dostaniemy null.
  6. Aby definiować warunki, które muszą spełniać pola klasy podczas wyszukiwania, przekazujemy nazwę danego pola jako wartość tekstową, np. realm.where(NoteRealm.class).equalTo("id", 25) oznacza, że szukamy obiektu klasy NoteRealm, którego pole id posiada wartość 25.
  7. Id każdego nowego obiektu musimy wygenerować sami. Id nie jest obowiązkowe, nie musimy z niego korzystać, choć w zdecydowanej większości przypadków będzie nam ono potrzebne.

Sporo już wyjaśniłem. Czas zastosować zdobytą wiedzę oraz obiekt NoteDao. Poniżej znajduje się kod głównej aktywności, który wyświetla i modyfikuje notatki.

public class MainActivity extends AppCompatActivity {
    private EditText newNoteText;
    private ListView noteList;
    private NoteDao noteDao;

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

        // inicjalizujemy pola klasy
        newNoteText = (EditText) findViewById(R.id.new_note_text);
        noteList = (ListView) findViewById(R.id.note_list);
        // tworzymy obiekt DAO
        noteDao = new NoteDao(this);
        // wyświetlamy listę notatek
        reloadNotesList();
    }

    @Override
    protected void onDestroy() {
        // zamykamy instancję Realma
        noteDao.close();
        super.onDestroy();
    }

    // dodaje nową notatkę do bazy danych i odświeża listę
    public void addNewNote(View view) {
        Note note = new Note();
        String text = newNoteText.getText().toString();
        if (text.length() > 0) {
            note.setNoteText(text);
        }

        // wywołanie metody insertNote() jest takie jak w poprzedniej lekcji,
        // gdzie korzystaliśmy z SQLite
        noteDao.insertNote(note);
        reloadNotesList();
    }

    // usuwa notatkę z bazy i odświeża listę
    public void removeNote(Note note) {
        noteDao.deleteNoteById(note.getId());
        reloadNotesList();
    }

    // pokazuje listę notatek
    private void reloadNotesList() {
        // pobieramy z bazy danych listę notatek
        List<Note> allNotes = noteDao.getAllNotes();

        // ustawiamy adapter listy
        noteList.setAdapter(new ArrayAdapter<Note>(this, R.layout.note_layout, allNotes) {
            @Override
            public View getView(final int position, View convertView, ViewGroup parent) {
                final View noteView = super.getView(position, convertView, parent);

                // po kliknięciu na notatkę zostanie ona usunięta
                noteView.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        removeNote(getItem(position));
                    }
                });

                return noteView;
            }
        });
    }

    // usuwa wszystkie notatki z bazy
    public void deleteAll(View view) {
        noteDao.deleteAllNotes();
        reloadNotesList();
    }

    // pobiera wyłącznie notatki, które zawierają frazę "filtered"
    // i wyświetla je na ekranie
    public void filterNotes(View view) {
        // pobieramy z bazy danych listę notatek z frazą "filtered"
        List<Note> filteredNotes = noteDao.getNotesLike("filtered");

        // ustawiamy adapter listy
        noteList.setAdapter(new ArrayAdapter<Note>(this, R.layout.note_layout, filteredNotes) {
            @Override
            public View getView(final int position, View convertView, ViewGroup parent) {
                return super.getView(position, convertView, parent);
            }
        });
    }

    // pokazuje listę obiektów typu NoteRealm
    public void showRealmNotes(View view) {
        noteList.setAdapter(new ArrayAdapter<NoteRealm>(this, R.layout.note_layout, noteDao.getRawNotes()) {
            @Override
            public View getView(final int position, View convertView, ViewGroup parent) {
                return super.getView(position, convertView, parent);
            }
        });
    }
}

Zwróć uwagę na ostatnią metodę. Wyświetla ona na liście obiekty, które pochodzą bezpośrednio z bazy danych Realma. Bez mapowania. Pobieramy je metodą noteDao.getRawNotes() i przekazujemy do adaptera. Adapter wykonuje na nich metodę toString(), która pochodzi z klasy RealmObject (po której dziedziczy NoteRealm). Operacja przebiega poprawnie. Jak wspomniałem wcześniej – nic nie stoi na przeszkodzie, aby korzystać bezpośrednio z obiektów realmowych, bez mapowania. Sam nie jestem jednak tego zwolennikiem, zwłaszcza przy większych projektach.

Na koniec dodam jeszcze, że Realm wspiera relacje między obiektami. Możemy do naszej klasy NoteRealm wstawić inny obiekt, który dziedziczy po RealmObject lub nawet listę obiektów. Wszystkie te zależności zostaną zapisane w bazie danych. Należy tylko pamiętać o usunięciu tych obiektów podczas usuwania obiektu nadrzędnego.

Oficjalną stronę Realma na Androida, wraz z tutorialami znajdziesz pod adresem https://realm.io/docs/java/latest/. Zdecydowanie warto poznać tę technologię, zwłaszcza, że jest również dostępna na inne platformy. Myślę, że dla większości jest ona lepszym i łatwiejszym rozwiązaniem niż standardowa baza SQLite.

W przyszłości planuję napisać jeszcze o Firebase – bazie danych od Google, która automatycznie synchronizuje się z chmurą oraz z innymi urządzeniami danego użytkownika. Jest to rozwiązanie preferowane dla osób, które nie chcą zajmować się tworzeniem serwera dla swojej aplikacji, a chcą zapewnić synchronizację danych. Więcej na ten temat przeczytasz pod adresem https://firebase.google.com/docs/database/.

część 21

2 komentarze

  • Aron napisał(a):

    Cześć, Damianie. Świetny blog [: mam nadzieje że zaczniesz coś na niego publikować [:

  • Michał napisał(a):

    Cześć Damianie, chciałbym zapytać jak wielka może być baza Realm?
    Czy opłaca się z niej korzystać przy większym projekcie? Na przykład:
    Zajmuję się pisaniem aplikacji w Android studio, która będzie przechowywała w bazie danych nawet do 30.000 obiektów… Czy opłacalnym jest wykorzystać do tego bazę Realm? Czy zainwestować z bazę online typu MySql i korzystać z niej za pomocą jdbc?

  • Dodaj komentarz

    Twój adres e-mail nie zostanie opublikowany.