Android Room with a View - Kotlin

Celem komponentów architektury jest dostarczanie wskazówek dotyczących architektury aplikacji wraz z bibliotekami do wykonywania typowych zadań, takich jak zarządzanie cyklem życia i utrwalanie danych. Komponenty architektury pomagają strukturyzować aplikację w sposób, który zapewnia jej niezawodność, łatwość testowania i utrzymania przy mniejszej ilości kodu szablonowego. Biblioteki komponentów architektury są częścią Androida Jetpack.

To jest wersja ćwiczeń z programowania w Kotlinie. Wersję w języku programowania Java znajdziesz tutaj.

Jeśli podczas wykonywania tego laboratorium napotkasz jakiekolwiek problemy (błędy w kodzie, błędy gramatyczne, niejasne sformułowania itp.), zgłoś je, klikając link Zgłoś błąd w lewym dolnym rogu laboratorium.

Wymagania wstępne

Musisz znać język Kotlin, koncepcje projektowania obiektowego i podstawy programowania na Androida, w szczególności:

Warto też znać wzorce architektury oprogramowania, które oddzielają dane od interfejsu użytkownika, takie jak MVP lub MVC. Te ćwiczenia z programowania implementują architekturę zdefiniowaną w przewodniku po architekturze aplikacji.

Ten moduł skupia się na komponentach architektury Androida. Nieistotne koncepcje i kod zostały pominięte. Można je po prostu skopiować i wkleić.

Jeśli nie znasz języka Kotlin, możesz skorzystać z wersji tych warsztatów w języku programowania Java, którą znajdziesz tutaj.

Co musisz zrobić

Z tego laboratorium dowiesz się, jak zaprojektować i zbudować aplikację przy użyciu komponentów architektury Room, ViewModel i LiveData. Zobaczysz, jak utworzyć aplikację, która:

  • Implementuje zalecaną architekturę przy użyciu komponentów architektury Androida.
  • Współpracuje z bazą danych, aby pobierać i zapisywać dane, a także wstępnie wypełniać ją niektórymi słowami.
  • Wyświetla wszystkie słowa w RecyclerViewMainActivity.
  • Otwiera drugie działanie, gdy użytkownik kliknie przycisk +. Gdy użytkownik wpisze słowo, dodaje je do bazy danych i listy.

Aplikacja jest prosta, ale wystarczająco złożona, aby można było jej użyć jako szablonu do dalszego rozwijania. Oto podgląd:

Czego potrzebujesz

  • Android Studio 3.0 lub nowsze i umiejętność korzystania z tego narzędzia. Upewnij się, że masz zaktualizowane Android Studio, pakiet SDK i Gradle.
  • urządzenie z Androidem lub emulator.

To ćwiczenie zawiera cały kod potrzebny do utworzenia kompletnej aplikacji.

Korzystanie z komponentów architektury i wdrażanie zalecanej architektury wymaga wykonania wielu czynności. Najważniejsze jest stworzenie modelu mentalnego tego, co się dzieje, zrozumienie, jak poszczególne elementy są ze sobą powiązane i jak przepływają dane. Podczas wykonywania tego ćwiczenia nie kopiuj i nie wklejaj kodu, ale staraj się zrozumieć jego działanie.

Aby wprowadzić terminologię, przedstawiamy krótkie wprowadzenie do komponentów architektury i ich wzajemnego działania. Pamiętaj, że ten przewodnik skupia się na podzbiorze komponentów, a mianowicie LiveData, ViewModel i Room. Każdy komponent jest wyjaśniony w trakcie korzystania z niego.

Ten diagram przedstawia podstawową formę architektury:

Encja: klasa z adnotacjami, która opisuje tabelę bazy danych podczas pracy z biblioteką Room.

Baza danych SQLite: pamięć urządzenia. Biblioteka trwałości danych Room tworzy i utrzymuje tę bazę danych.

DAO: obiekt umożliwiający dostęp do danych. Mapowanie zapytań SQL na funkcje. Gdy używasz obiektu DAO, wywołujesz metody, a Room zajmuje się resztą.

Baza danych Room: upraszcza pracę z bazą danych i służy jako punkt dostępu do bazowej bazy danych SQLite (ukrywa SQLiteOpenHelper)). Baza danych Room używa obiektu DAO do wysyłania zapytań do bazy danych SQLite.

Repozytorium: utworzona przez Ciebie klasa, która służy głównie do zarządzania wieloma źródłami danych.

ViewModel: działa jako centrum komunikacji między repozytorium (dane) a interfejsem. Interfejs użytkownika nie musi się już martwić o pochodzenie danych. Instancje klasy ViewModel przetrwają odtworzenie aktywności lub fragmentu.

LiveData: klasa przechowująca dane, które można obserwować. Zawsze przechowuje/buforuje najnowszą wersję danych i powiadamia obserwatorów o zmianach w danych. LiveData uwzględnia cykl życia. Komponenty interfejsu obserwują tylko odpowiednie dane i nie przerywają ani nie wznawiają obserwacji. LiveData automatycznie zarządza tymi wszystkimi kwestiami, ponieważ podczas obserwacji jest świadoma odpowiednich zmian stanu cyklu życia.

Omówienie architektury RoomWordSample

Poniższy diagram przedstawia wszystkie elementy aplikacji. Każde z pudełek (z wyjątkiem bazy danych SQLite) reprezentuje klasę, którą utworzysz.

  1. Otwórz Android Studio i kliknij Start a new Android Studio project (Rozpocznij nowy projekt Android Studio).
  2. W oknie Create New Project (Utwórz nowy projekt) wybierz Empty Activity (Pusta aktywność) i kliknij Next (Dalej).
  3. Na następnym ekranie nadaj aplikacji nazwę RoomWordSample i kliknij Zakończ.

Następnie musisz dodać biblioteki komponentów do plików Gradle.

  1. W Android Studio kliknij kartę Projects (Projekty) i rozwiń folder Gradle Scripts (Skrypty Gradle).

Otwórz build.gradle (moduł: app).

  1. Zastosuj wtyczkę Kotlin kapt annotation processor, dodając ją po innych wtyczkach zdefiniowanych u góry pliku build.gradle (Module: app).
apply plugin: 'kotlin-kapt'
  1. Dodaj blok packagingOptions w bloku android, aby wykluczyć moduł funkcji atomowych z pakietu i zapobiec wyświetlaniu ostrzeżeń.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. Na końcu bloku dependencies dodaj ten kod.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

// Kotlin components
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  1. W pliku build.gradle (Project: RoomWordsSample) dodaj numery wersji na końcu pliku, jak podano w poniższym kodzie.
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

Dane tej aplikacji to słowa, więc potrzebujesz prostej tabeli do przechowywania tych wartości:

Room umożliwia tworzenie tabel za pomocą encji. Zróbmy to teraz.

  1. Utwórz nowy plik klasy Kotlin o nazwie Word zawierający Word klasę danych.
    Ta klasa będzie opisywać encję (reprezentującą tabelę SQLite) dla Twoich słów. Każda właściwość w klasie reprezentuje kolumnę w tabeli. Room użyje tych właściwości do utworzenia tabeli i utworzenia instancji obiektów z wierszy w bazie danych.

Oto kod:

data class Word(val word: String)

Aby klasa Word była przydatna w bazie danych Room, musisz dodać do niej adnotacje. Adnotacje określają, jak każda część tej klasy jest powiązana z wpisem w bazie danych. Room używa tych informacji do generowania kodu.

Jeśli wpiszesz adnotacje samodzielnie (zamiast wklejać), Android Studio automatycznie zaimportuje klasy adnotacji.

  1. Zaktualizuj klasę Word za pomocą adnotacji, jak pokazano w tym kodzie:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Zobaczmy, co robią te adnotacje:

  • @Entity(tableName = "word_table")
    Każda klasa @Entity reprezentuje tabelę SQLite. Dodaj adnotację do deklaracji klasy, aby wskazać, że jest to encja. Jeśli chcesz, aby nazwa tabeli różniła się od nazwy klasy, możesz ją określić. Nadaje to tabeli nazwę „word_table”.
  • @PrimaryKey
    Każdy podmiot musi mieć klucz podstawowy. Dla uproszczenia każde słowo pełni funkcję klucza podstawowego.
  • @ColumnInfo(name = "word")
    Określa nazwę kolumny w tabeli, jeśli ma się ona różnić od nazwy zmiennej składowej. W ten sposób kolumna zostanie nazwana „word”.
  • Każda właściwość przechowywana w bazie danych musi mieć widoczność publiczną, która jest domyślną wartością w Kotlinie.

Pełną listę adnotacji znajdziesz w podsumowaniu pakietu Room.

Czym jest DAO?

DAO (obiekcie dostępu do danych) określasz zapytania SQL i łączysz je z wywołaniami metod. Kompilator sprawdza SQL i generuje zapytania na podstawie adnotacji ułatwiających tworzenie typowych zapytań, takich jak @Insert. Room używa obiektu DAO do tworzenia przejrzystego interfejsu API dla Twojego kodu.

Obiekt DAO musi być interfejsem lub klasą abstrakcyjną.

Domyślnie wszystkie zapytania muszą być wykonywane w osobnym wątku.

Biblioteka Room obsługuje współprogramy, dzięki czemu zapytania można oznaczać modyfikatorem suspend, a następnie wywoływać je z współprogramu lub innej funkcji zawieszającej.

Wdrożenie DAO

Napiszmy DAO, które będzie udostępniać zapytania dotyczące:

  • uzyskiwanie wszystkich słów w porządku alfabetycznym,
  • Wstawianie słowa
  • Usuwam wszystkie słowa
  1. Utwórz nowy plik klasy Kotlin o nazwie WordDao.
  2. Skopiuj poniższy kod i wklej go do pliku WordDao. W razie potrzeby popraw importy, aby kod się kompilował.
@Dao
interface WordDao {

    @Query("SELECT * from word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

Przyjrzyjmy się temu procesowi:

  • WordDao to interfejs. Obiekty DAO muszą być interfejsami lub klasami abstrakcyjnymi.
  • Adnotacja @Dao identyfikuje ją jako klasę DAO dla Room.
  • suspend fun insert(word: Word) : deklaruje funkcję zawieszającą, która wstawia jedno słowo.
  • @Insert adnotacja to specjalna adnotacja metody DAO, w której nie musisz podawać żadnego kodu SQL. (Istnieją też adnotacje @Delete@Update do usuwania i aktualizowania wierszy, ale nie są one używane w tej aplikacji).
  • onConflict = OnConflictStrategy.IGNORE: wybrana strategia onConflict ignoruje nowe słowo, jeśli jest ono identyczne z słowem, które już znajduje się na liście. Więcej informacji o dostępnych strategiach rozwiązywania konfliktów znajdziesz w dokumentacji.
  • suspend fun deleteAll(): deklaruje funkcję zawieszenia, która usuwa wszystkie słowa.
  • Nie ma wygodnej adnotacji do usuwania wielu elementów, więc jest ona oznaczona ogólną adnotacją @Query.
  • @Query("DELETE FROM word_table"): @Query wymaga podania zapytania SQL jako parametru ciągu znaków w adnotacji, co umożliwia złożone zapytania odczytu i inne operacje.
  • fun getAlphabetizedWords(): List<Word>: metoda pobierania wszystkich słów i zwracania List Words.
  • @Query("SELECT * from word_table ORDER BY word ASC"): zapytanie, które zwraca listę słów posortowanych w kolejności rosnącej.

Gdy dane się zmieniają, zwykle chcesz podjąć jakieś działanie, np. wyświetlić zaktualizowane dane w interfejsie. Oznacza to, że musisz obserwować dane, aby w razie ich zmiany móc odpowiednio zareagować.

W zależności od sposobu przechowywania danych może to być trudne. Obserwowanie zmian w danych w wielu komponentach aplikacji może tworzyć wyraźne, sztywne ścieżki zależności między komponentami. Utrudnia to m.in. testowanie i debugowanie.

LiveData – klasa biblioteki cyklu życia do obserwowania danych rozwiązuje ten problem. W opisie metody użyj wartości zwracanej typu LiveData, a Room wygeneruje cały niezbędny kod do aktualizacji LiveData po zaktualizowaniu bazy danych.

WordDao zmień sygnaturę metody getAlphabetizedWords() tak, aby zwracana wartość List<Word> była opakowana w LiveData.

   @Query("SELECT * from word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): LiveData<List<Word>>

W dalszej części tego laboratorium dowiesz się, jak śledzić zmiany danych za pomocą ObserverMainActivity.

Czym jest baza danych Room?

  • Room to warstwa bazy danych oparta na bazie danych SQLite.
  • Room wykonuje proste zadania, które wcześniej wykonywałeś(-aś) za pomocą SQLiteOpenHelper.
  • Room używa obiektu DAO do wysyłania zapytań do bazy danych.
  • Domyślnie, aby uniknąć niskiej wydajności interfejsu, biblioteka Room nie zezwala na wykonywanie zapytań w głównym wątku. Gdy zapytania Room zwracają LiveData, są one automatycznie wykonywane asynchronicznie w wątku w tle.
  • Room zapewnia sprawdzanie instrukcji SQLite w czasie kompilacji.

Implementowanie bazy danych Room

Klasa bazy danych Room musi być abstrakcyjna i rozszerzać klasę RoomDatabase. Zwykle w całej aplikacji wystarczy tylko jedna instancja bazy danych Room.

Utwórzmy go.

  1. Utwórz plik klasy Kotlin o nazwie WordRoomDatabase i dodaj do niego ten kod:
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time. 
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return instance
            }
        }
   }
}

Przyjrzyjmy się kodowi:

  • Klasa bazy danych dla Room musi być abstract i rozszerzać RoomDatabase
  • Klasę bazy danych Room oznaczysz adnotacją @Database, a za pomocą parametrów adnotacji zadeklarujesz encje należące do bazy danych i ustawisz numer wersji. Każda encja odpowiada tabeli, która zostanie utworzona w bazie danych. Migracje baz danych wykraczają poza zakres tych ćwiczeń z programowania, więc ustawiamy tutaj wartość exportSchema na false, aby uniknąć ostrzeżenia o kompilacji. W prawdziwej aplikacji warto ustawić katalog, którego Room będzie używać do eksportowania schematu, aby można było sprawdzić bieżący schemat w systemie kontroli wersji.
  • Baza danych udostępnia obiekty DAO za pomocą abstrakcyjnej metody „getter” dla każdego interfejsu @Dao.
  • Zdefiniowaliśmy singleton, WordRoomDatabase,, aby zapobiec otwieraniu wielu instancji bazy danych w tym samym czasie.
  • getDatabase zwraca pojedynczą instancję. Przy pierwszym dostępie utworzy bazę danych, używając narzędzia do tworzenia baz danych Room, aby utworzyć obiekt RoomDatabase w kontekście aplikacji z klasy WordRoomDatabase i nadać mu nazwę "word_database".

Co to jest repozytorium?

Klasa repozytorium abstrahuje dostęp do wielu źródeł danych. Repozytorium nie jest częścią bibliotek komponentów architektury, ale jest sugerowaną sprawdzoną metodą separacji kodu i architektury. Klasa Repository udostępnia przejrzysty interfejs API do uzyskiwania dostępu do danych w pozostałej części aplikacji.

Dlaczego warto korzystać z repozytorium?

Repozytorium zarządza zapytaniami i umożliwia korzystanie z wielu backendów. W najczęstszym przypadku repozytorium implementuje logikę decydującą o tym, czy pobrać dane z sieci, czy użyć wyników zapisanych w pamięci podręcznej w lokalnej bazie danych.

Implementowanie repozytorium

Utwórz plik klasy Kotlin o nazwie WordRepository i wklej do niego ten kod:

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

Główne wnioski:

  • Obiekt DAO jest przekazywany do konstruktora repozytorium, a nie do całej bazy danych. Wynika to z faktu, że potrzebuje on tylko dostępu do obiektu DAO, ponieważ zawiera on wszystkie metody odczytu i zapisu w bazie danych. Nie musisz udostępniać całego repozytorium bazy danych.
  • Lista słów jest własnością publiczną. Jest ona inicjowana przez pobranie LiveData listy słów z Room. Możemy to zrobić, ponieważ w kroku „Klasa LiveData” zdefiniowaliśmy getAlphabetizedWords metodę zwracającą LiveData. Room wykonuje wszystkie zapytania w osobnym wątku. Następnie obserwowany LiveData powiadomi obserwatora w głównym wątku, gdy dane ulegną zmianie.
  • Modyfikator suspend informuje kompilator, że tę funkcję należy wywołać z korutyny lub innej funkcji zawieszającej.

Co to jest ViewModel?

Jego zadaniem jest dostarczanie danych do interfejsu i przetrwanie zmian konfiguracji.ViewModel ViewModel pełni funkcję centrum komunikacji między repozytorium a interfejsem. Możesz też użyć ViewModel, aby udostępniać dane między fragmentami. ViewModel jest częścią biblioteki cyklu życia.

Wprowadzenie do tego tematu znajdziesz w ViewModel Overview lub w poście na blogu ViewModel: prosty przykład.

Dlaczego warto używać ViewModel?

ViewModel przechowuje dane interfejsu aplikacji w sposób uwzględniający cykl życia, który przetrwa zmiany konfiguracji. Oddzielenie danych interfejsu aplikacji od klas ActivityFragment pozwala lepiej przestrzegać zasady pojedynczej odpowiedzialności: aktywności i fragmenty odpowiadają za wyświetlanie danych na ekranie, a klasa ViewModel może zajmować się przechowywaniem i przetwarzaniem wszystkich danych potrzebnych do interfejsu.

W przypadku zmiennych danych, które będą używane lub wyświetlane w interfejsie, użyj symbolu ViewModel.LiveData Korzystanie z LiveData ma kilka zalet:

  • Możesz umieścić obserwatora danych (zamiast odpytywać o zmiany) i aktualizować interfejs tylko wtedy, gdy dane faktycznie się zmienią.
  • Repozytorium i interfejs użytkownika są całkowicie oddzielone znakiem ViewModel.
  • Nie ma wywołań bazy danych z ViewModel (wszystko jest obsługiwane w repozytorium), co ułatwia testowanie kodu.

viewModelScope

W Kotlinie wszystkie korutyny działają w ramach CoroutineScope. Zakres kontroluje czas życia korutyn za pomocą zadania. Gdy anulujesz zadanie w zakresie, anulujesz wszystkie korutyny uruchomione w tym zakresie.

Biblioteka AndroidX lifecycle-viewmodel-ktx dodaje viewModelScope jako funkcję rozszerzającą klasy ViewModel, co umożliwia pracę z zakresami.

Więcej informacji o korzystaniu z korutyn w ViewModel znajdziesz w kroku 5 w samouczku Using Kotlin Coroutines in your Android App (Korzystanie z korutyn Kotlin w aplikacji na Androida) lub w tym artykule na blogu.

Implementowanie ViewModel

Utwórz plik klasy Kotlin dla WordViewModel i dodaj do niego ten kod:

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(word)
    }
}

Tutaj:

  • Utworzono klasę o nazwie WordViewModel, która przyjmuje Application jako parametr i rozszerza AndroidViewModel.
  • Dodano prywatną zmienną członkowską, która będzie przechowywać odwołanie do repozytorium.
  • Dodaliśmy publiczną zmienną LiveData, która służy do przechowywania listy słów w pamięci podręcznej.
  • Utworzono blok init, który pobiera odwołanie do elementu WordDao z elementu WordRoomDatabase.
  • W bloku init zbudowano WordRepository na podstawie WordRoomDatabase.
  • W bloku init zainicjuj allWords LiveData za pomocą repozytorium.
  • Utworzono metodę opakowującą insert(), która wywołuje metodę insert() repozytorium. W ten sposób implementacja insert() jest odseparowana od interfejsu. Nie chcemy, aby wstawianie blokowało główny wątek, więc uruchamiamy nową korutynę i wywołujemy funkcję wstawiania repozytorium, która jest funkcją zawieszającą. Jak już wspomnieliśmy, obiekty ViewModel mają zakres coroutine oparty na ich cyklu życia, który nazywa się viewModelScope i którego używamy w tym przykładzie.

Następnie musisz dodać układ XML listy i elementów.

W tym laboratorium przyjęto założenie, że znasz tworzenie układów w XML, więc podajemy tylko kod.

Zmień motyw aplikacji na Material Design, ustawiając element nadrzędny AppTheme na Theme.MaterialComponents.Light.DarkActionBar. Dodaj styl do elementów listy w values/styles.xml:

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

Dodawanie układu layout/recyclerview_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" 
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

layout/activity_main.xml zastąp TextView symbolem RecyclerView i dodaj pływający przycisk polecenia (FAB). Układ powinien teraz wyglądać tak:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Wygląd FAB powinien odpowiadać dostępnemu działaniu, dlatego chcemy zastąpić ikonę symbolem „+”.

Najpierw musimy dodać nowy komponent wektorowy:

  1. Wybierz Plik > Nowy > Zasób wektorowy.
  2. W polu Clip Art: kliknij ikonę robota Androida.
  3. Wyszukaj „dodaj” i wybierz komponent „+”. Kliknij OK
    .
  4. Następnie kliknij Dalej.
  5. Potwierdź ścieżkę ikony jako main > drawable i kliknij Zakończ, aby dodać komponent.
  6. W pliku layout/activity_main.xml zaktualizuj pływający przycisk działania, aby zawierał nowy element rysowalny:
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

Dane wyświetlisz w RecyclerView, co jest nieco lepsze niż umieszczenie ich w TextView. W tym ćwiczeniu zakłada się, że wiesz, jak działają RecyclerView, RecyclerView.LayoutManager, RecyclerView.ViewHolderRecyclerView.Adapter.

Zmienna words w adapterze buforuje dane. W następnym zadaniu dodasz kod, który automatycznie aktualizuje dane.

Utwórz plik klasy Kotlin dla WordListAdapter, który rozszerza RecyclerView.Adapter. Oto kod:

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

    inner class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordItemView: TextView = itemView.findViewById(R.id.textView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

Dodaj RecyclerView do metody onCreate() klasy MainActivity.

W metodzie onCreate() po setContentView:

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter(this)
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

Uruchom aplikację, aby sprawdzić, czy wszystko działa. Nie ma żadnych elementów, ponieważ nie zostały jeszcze połączone dane.

W bazie danych nie ma danych. Dane dodasz na 2 sposoby: dodaj część danych po otwarciu bazy danych i dodaj Activity do dodawania słów.

Aby usunąć całą zawartość i ponownie wypełnić bazę danych przy każdym uruchomieniu aplikacji, utwórz RoomDatabase.Callback i zastąp onOpen(). Operacji na bazie danych Room nie można wykonywać w wątku interfejsu, więc onOpen() uruchamia korutynę w usłudze IO Dispatcher.

Aby uruchomić korutynę, potrzebujemy CoroutineScope. Zaktualizuj metodę getDatabase klasy WordRoomDatabase, aby jako parametr otrzymywać też zakres współprogramu:

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

Zaktualizuj inicjator pobierania bazy danych w bloku init pliku WordViewModel, aby przekazywać też zakres:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

WordRoomDatabase tworzymy niestandardową implementację RoomDatabase.Callback(), która otrzymuje też CoroutineScope jako parametr konstruktora. Następnie zastępujemy metodę onOpen, aby wypełnić bazę danych.

Oto kod tworzenia wywołania zwrotnego w klasie WordRoomDatabase:

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

Na koniec dodaj wywołanie zwrotne do sekwencji tworzenia bazy danych tuż przed wywołaniem funkcji .build()Room.databaseBuilder():

.addCallback(WordDatabaseCallback(scope))

Oto jak powinien wyglądać ostateczny kod:

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

   companion object {
       @Volatile
       private var INSTANCE: WordRoomDatabase? = null

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

Dodaj te zasoby ciągów znaków w values/strings.xml:

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>

Dodaj ten zasób koloru w value/colors.xml:

<color name="buttonLabel">#FFFFFF</color>

Utwórz nowy plik zasobu wymiaru:

  1. W oknie Project (Projekt) kliknij moduł aplikacji.
  2. Wybierz File > New > Android Resource File (Plik > Nowy > Plik zasobów Androida).
  3. W sekcji Dostępne kwalifikatory wybierz Wymiar .
  4. Ustaw nazwę pliku: dimens

Dodaj te zasoby wymiarów w values/dimens.xml:

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</dimen>

Utwórz nowy pusty projekt Androida Activity za pomocą szablonu Empty Activity (Pusta aktywność):

  1. Wybierz Plik > Nowy > Aktywność > Pusta aktywność.
  2. Wpisz NewWordActivity w polu Nazwa aktywności.
  3. Sprawdź, czy nowa aktywność została dodana do pliku manifestu Androida.
<activity android:name=".NewWordActivity"></activity>

Zaktualizuj plik activity_new_word.xml w folderze układu za pomocą tego kodu:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

Zaktualizuj kod aktywności:

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

Ostatnim krokiem jest połączenie interfejsu z bazą danych przez zapisywanie nowych słów wpisywanych przez użytkownika i wyświetlanie bieżącej zawartości bazy danych słów w RecyclerView.

Aby wyświetlić bieżącą zawartość bazy danych, dodaj obserwatora, który obserwuje LiveDataViewModel.

Gdy dane się zmienią, wywoływane jest wywołanie zwrotne onChanged(), które wywołuje metodę setWords() adaptera, aby zaktualizować dane w pamięci podręcznej adaptera i odświeżyć wyświetlaną listę.

MainActivity utwórz zmienną elementu dla ViewModel:

private lateinit var wordViewModel: WordViewModel

Użyj ViewModelProvider, aby powiązać ViewModelActivity.

Gdy Activity zacznie działać, ViewModelProviders utworzy ViewModel. Gdy aktywność zostanie zniszczona, np. w wyniku zmiany konfiguracji, obiekt ViewModel pozostanie. Gdy aktywność zostanie ponownie utworzona, ViewModelProviders zwróć istniejący ViewModel. Więcej informacji znajdziesz w sekcji ViewModel.

onCreate() poniżej bloku kodu RecyclerView pobierz ViewModelViewModelProvider:

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

onCreate() dodaj też obserwatora właściwości allWords LiveDataWordViewModel.

Metoda onChanged() (domyślna metoda w przypadku naszej funkcji Lambda) jest wywoływana, gdy zmieniają się obserwowane dane, a aktywność jest na pierwszym planie:

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.setWords(it) }
})

Chcemy, aby po kliknięciu przycisku FAB otworzył się ekran NewWordActivity, a po powrocie do ekranu MainActivity nowe słowo zostało wstawione do bazy danych lub wyświetlił się ekran Toast. Aby to osiągnąć, zacznijmy od zdefiniowania kodu żądania:

private val newWordActivityRequestCode = 1

W pliku MainActivity dodaj kod onActivityResult() dla NewWordActivity.

Jeśli aktywność zwróci wartość RESULT_OK, wstaw zwrócone słowo do bazy danych, wywołując metodę insert() obiektu WordViewModel:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

MainActivity,start NewWordActivity, gdy użytkownik kliknie FAB. W MainActivity onCreate znajdź FAB i dodaj onClickListener z tym kodem:

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

Gotowy kod powinien wyglądać tak:

class MainActivity : AppCompatActivity() {

   private const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       setSupportActionBar(toolbar)

       val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
       val adapter = WordListAdapter(this)
       recyclerView.adapter = adapter
       recyclerView.layoutManager = LinearLayoutManager(this)

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(it) }
       })

       val fab = findViewById<FloatingActionButton>(R.id.fab)
       fab.setOnClickListener {
           val intent = Intent(this@MainActivity, NewWordActivity::class.java)
           startActivityForResult(intent, newWordActivityRequestCode)
       }
   }

   override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
       super.onActivityResult(requestCode, resultCode, data)

       if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
           data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
               val word = Word(it)
               wordViewModel.insert(word)
           }
       } else {
           Toast.makeText(
               applicationContext,
               R.string.empty_not_saved,
               Toast.LENGTH_LONG).show()
       }
   }
}

Teraz uruchom aplikację. Gdy dodasz słowo do bazy danych w NewWordActivity, interfejs automatycznie się zaktualizuje.

Masz już działającą aplikację. Podsumujmy, co udało Ci się stworzyć. Oto ponownie struktura aplikacji:

Aplikacja składa się z tych komponentów:

  • MainActivity: wyświetla słowa na liście, używając RecyclerViewWordListAdapter. W MainActivity znajduje się Observer, który obserwuje słowa LiveData w bazie danych i otrzymuje powiadomienia o ich zmianach.
  • NewWordActivity: dodaje nowe słowo do listy.
  • WordViewModel: udostępnia metody dostępu do warstwy danych i zwraca LiveData, dzięki czemu MainActivity może skonfigurować relację obserwatora*.
  • LiveData<List<Word>>: umożliwia automatyczne aktualizacje komponentów interfejsu. W MainActivity znajduje się Observer, który obserwuje słowa LiveData w bazie danych i otrzymuje powiadomienia o ich zmianach.
  • Repository: zarządza co najmniej 1 źródłem danych. Repository udostępnia metody, za pomocą których ViewModel może wchodzić w interakcje z bazowym dostawcą danych. W tej aplikacji backendem jest baza danych Room.
  • Room: jest otoczką bazy danych SQLite i ją implementuje. Room wykonuje wiele czynności, które wcześniej trzeba było robić samodzielnie.
  • DAO: mapuje wywołania metod na zapytania do bazy danych, dzięki czemu gdy repozytorium wywołuje metodę taką jak getAlphabetizedWords(), Room może wykonać SELECT * from word_table ORDER BY word ASC.
  • Word: to klasa jednostki zawierająca jedno słowo.

* ViewsActivities (oraz Fragments) wchodzą w interakcję z danymi tylko za pomocą ViewModel. Dlatego nie ma znaczenia, skąd pochodzą dane.

Przepływ danych w przypadku automatycznych aktualizacji interfejsu (reaktywny interfejs)

Automatyczna aktualizacja jest możliwa, ponieważ używamy LiveData. W MainActivity znajduje się Observer, który obserwuje słowa LiveData w bazie danych i otrzymuje powiadomienia o ich zmianach. Gdy nastąpi zmiana, wykonywana jest metoda onChange() obserwatora, która aktualizuje mWordsWordListAdapter.

Dane można zaobserwować, ponieważ są LiveData. Obserwowana jest wartość LiveData<List<Word>> zwracana przez właściwość WordViewModel allWords.

WordViewModel ukrywa wszystko, co dotyczy backendu, przed warstwą interfejsu. Zawiera metody dostępu do warstwy danych i zwraca wartość LiveData, aby MainActivity mogło skonfigurować relację obserwatora. ViewsActivities (oraz Fragments) wchodzą w interakcję z danymi tylko za pomocą ViewModel. Dlatego nie ma znaczenia, skąd pochodzą dane.

W tym przypadku dane pochodzą z Repository. ViewModel nie musi wiedzieć, z czym to repozytorium wchodzi w interakcję. Musi tylko wiedzieć, jak wchodzić w interakcję z Repository, czyli za pomocą metod udostępnianych przez Repository.

Repozytorium zarządza co najmniej 1 źródłem danych. W aplikacji WordListSample backendem jest baza danych Room. Room to otoka i implementacja bazy danych SQLite. Room wykonuje wiele czynności, które wcześniej trzeba było robić samodzielnie. Na przykład Room umożliwia wykonywanie wszystkich czynności, które były dostępne w przypadku zajęć SQLiteOpenHelper.

Metoda DAO mapuje wywołania na zapytania do bazy danych, więc gdy repozytorium wywołuje metodę taką jak getAllWords(), Room może wykonać SELECT * from word_table ORDER BY word ASC.

Ponieważ wynik zwrócony przez zapytanie jest obserwowany LiveData, za każdym razem, gdy dane w Roomie ulegną zmianie, wykonywana jest metoda onChanged() interfejsu Observer, a interfejs jest aktualizowany.

[Opcjonalnie] Pobierz kod rozwiązania

Jeśli jeszcze tego nie zrobiono, możesz zapoznać się z kodem rozwiązania w tym laboratorium. Możesz przejrzeć repozytorium GitHub lub pobrać kod tutaj:

Pobierz kod źródłowy

Rozpakuj pobrany plik ZIP. Spowoduje to rozpakowanie folderu głównego android-room-with-a-view-kotlin, który zawiera kompletną aplikację.