Android Room with a View — Kotlin

Цель компонентов архитектуры — предоставить руководство по архитектуре приложений, включая библиотеки для решения таких распространенных задач, как управление жизненным циклом и сохранение данных. Компоненты архитектуры помогают структурировать приложение таким образом, чтобы оно было надёжным, тестируемым и поддерживаемым с меньшим количеством шаблонного кода. Библиотеки компонентов архитектуры входят в состав Android Jetpack .

Это версия лабораторной работы на Kotlin. Версию на Java можно найти здесь .

Если во время работы над этой лабораторной работой у вас возникнут какие-либо проблемы (ошибки кода, грамматические ошибки, неясные формулировки и т. д.), сообщите о них, воспользовавшись ссылкой «Сообщить об ошибке» в левом нижнем углу лабораторной работы.

Предпосылки

Вам необходимо быть знакомым с Kotlin, концепциями объектно-ориентированного проектирования и основами разработки для Android, в частности:

Также полезно ознакомиться с шаблонами архитектуры программного обеспечения, которые отделяют данные от пользовательского интерфейса, такими как MVP или MVC. В этой лабораторной работе реализована архитектура, определённая в Руководстве по архитектуре приложений .

Эта лабораторная работа посвящена компонентам архитектуры Android. Не относящиеся к теме концепции и код предоставляются для простого копирования и вставки.

Если вы не знакомы с Kotlin, версия этой лабораторной работы на языке программирования Java доступна здесь .

Что ты будешь делать?

В этой лабораторной работе вы узнаете, как проектировать и создавать приложение с использованием Architecture Components Room, ViewModel и LiveData, а также как создать приложение, которое выполняет следующие функции:

  • Реализует нашу рекомендуемую архитектуру с использованием компонентов архитектуры Android.
  • Работает с базой данных для получения и сохранения данных, а также предварительно заполняет базу данных некоторыми словами.
  • Отображает все слова в RecyclerView в MainActivity .
  • Открывает второе действие при нажатии пользователем кнопки «+». Когда пользователь вводит слово, оно добавляется в базу данных и список.

Приложение простое, но достаточно сложное, чтобы использовать его в качестве шаблона для дальнейшего развития. Вот предварительный просмотр:

Что вам понадобится

В этой лабораторной работе представлен весь код, необходимый для создания полноценного приложения.

Использование компонентов архитектуры и реализация рекомендуемой архитектуры включает множество этапов. Самое главное — создать мысленную модель происходящего, понимая, как взаимодействуют все элементы и как передаются данные. Работая над этой практической работой, не просто копируйте и вставляйте код, а постарайтесь начать формировать это внутреннее понимание.

Чтобы познакомить вас с терминологией, предлагаем краткое введение в компоненты архитектуры и принципы их взаимодействия. Обратите внимание, что эта лабораторная работа посвящена подмножеству компонентов, а именно LiveData, ViewModel и Room. Каждый компонент будет подробно описан по мере его использования.

На этой диаграмме показана базовая форма архитектуры:

Entity : Аннотированный класс, описывающий таблицу базы данных при работе с Room .

База данных SQLite: Хранилище устройства. Библиотека сохранения данных Room создает и поддерживает эту базу данных.

DAO : объект доступа к данным. Сопоставление SQL-запросов с функциями. При использовании DAO вы вызываете методы, а Room заботится обо всём остальном.

База данных Room : упрощает работу с базой данных и служит точкой доступа к базовой базе данных SQLite (скрывает SQLiteOpenHelper) . База данных Room использует DAO для отправки запросов к базе данных SQLite.

Репозиторий: создаваемый вами класс, который в основном используется для управления несколькими источниками данных.

ViewModel : выступает в качестве коммуникационного центра между репозиторием (данными) и пользовательским интерфейсом. Пользовательскому интерфейсу больше не нужно беспокоиться об источнике данных. Экземпляры ViewModel сохраняются после воссоздания Activity/Fragment.

LiveData : класс-держатель данных, за которым можно наблюдать . Всегда хранит/кэширует последнюю версию данных и уведомляет наблюдателей об их изменении. LiveData учитывает жизненный цикл. Компоненты пользовательского интерфейса просто наблюдают за соответствующими данными, не останавливая и не возобновляя наблюдение. LiveData автоматически управляет всем этим, поскольку отслеживает соответствующие изменения статуса жизненного цикла во время наблюдения.

Обзор архитектуры RoomWordSample

На следующей диаграмме показаны все части приложения. Каждый из блоков (за исключением базы данных SQLite) представляет собой класс, который вы создадите.

  1. Откройте Android Studio и нажмите «Начать новый проект Android Studio».
  2. В окне «Создать новый проект» выберите «Пустая активность» и нажмите «Далее».
  3. На следующем экране назовите приложение RoomWordSample и нажмите Готово .

Далее вам придется добавить библиотеки компонентов в файлы Gradle.

  1. В Android Studio перейдите на вкладку «Проекты» и разверните папку Gradle Scripts.

Откройте build.gradle ( Модуль: app ).

  1. Примените плагин Kotlin -процессора аннотаций kapt , добавив его после других плагинов, определенных в верхней части файла build.gradle ( Модуль: app ).
apply plugin: 'kotlin-kapt'
  1. Добавьте блок packagingOptions внутрь блока android , чтобы исключить модуль атомарных функций из пакета и предотвратить появление предупреждений.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. Добавьте следующий код в конец блока dependencies .
// 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. В файле build.gradle ( Project: RoomWordsSample ) добавьте номера версий в конец файла, как указано в коде ниже.
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

Данные для этого приложения — это слова, и вам понадобится простая таблица для хранения этих значений:

Room позволяет создавать таблицы с помощью Entity . Давайте сделаем это сейчас.

  1. Создайте новый файл класса Kotlin с именем Word , содержащий класс данных Word .
    Этот класс будет описывать сущность (представляющую таблицу SQLite) для ваших слов. Каждое свойство класса представляет столбец таблицы. Room в конечном итоге будет использовать эти свойства как для создания таблицы, так и для создания экземпляров объектов из строк базы данных.

Вот код:

data class Word(val word: String)

Чтобы сделать класс Word содержательным для базы данных Room, необходимо аннотировать его. Аннотации определяют, как каждая часть этого класса связана с записью в базе данных. Room использует эту информацию для генерации кода.

Если вы введете аннотации самостоятельно (вместо вставки), Android Studio автоматически импортирует классы аннотаций.

  1. Обновите свой класс Word с помощью аннотаций, как показано в этом коде:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Давайте посмотрим, что делают эти аннотации:

  • @Entity(tableName = "word_table" )
    Каждый класс @Entity представляет таблицу SQLite. Добавьте аннотацию к объявлению класса, чтобы указать, что это сущность. Вы можете указать имя таблицы, если хотите, чтобы оно отличалось от имени класса. В результате таблица будет называться «word_table».
  • @PrimaryKey
    Каждой сущности нужен первичный ключ. Для простоты каждое слово выступает в роли собственного первичного ключа.
  • @ColumnInfo(name = "word" )
    Указывает имя столбца в таблице, если вы хотите, чтобы оно отличалось от имени переменной-члена. Столбцу присваивается имя «word».
  • Каждое свойство, хранящееся в базе данных, должно быть общедоступным, что является значением по умолчанию в Kotlin.

Полный список аннотаций можно найти в справочнике по краткому содержанию пакета Room .

Что такое ДАО?

В DAO (объекте доступа к данным) вы указываете SQL-запросы и связываете их с вызовами методов. Компилятор проверяет SQL-запросы и генерирует запросы на основе удобных аннотаций для распространённых запросов, таких как @Insert . Room использует DAO для создания понятного API для вашего кода.

DAO должен быть интерфейсом или абстрактным классом.

По умолчанию все запросы должны выполняться в отдельном потоке.

Room поддерживает сопрограммы, что позволяет аннотировать ваши запросы модификатором suspend , а затем вызывать их из сопрограммы или из другой функции приостановки.

Реализовать DAO

Давайте напишем DAO, который будет выполнять запросы для:

  • Упорядочить все слова в алфавитном порядке
  • Вставка слова
  • Удаление всех слов
  1. Создайте новый файл класса Kotlin с именем WordDao .
  2. Скопируйте и вставьте следующий код в WordDao и исправьте импорт по мере необходимости, чтобы он скомпилировался.
@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()
}

Давайте разберемся:

  • WordDao — это интерфейс; DAO должны быть либо интерфейсами, либо абстрактными классами.
  • Аннотация @Dao идентифицирует его как класс DAO для Room.
  • suspend fun insert(word: Word) : объявляет функцию приостановки для вставки одного слова.
  • Аннотация @Insert — это специальная аннотация метода DAO, для которой не требуется предоставлять SQL! (Существуют также аннотации @Delete и @Update для удаления и обновления строк, но в этом приложении они не используются.)
  • onConflict = OnConflictStrategy.IGNORE : выбранная стратегия onConflict игнорирует новое слово, если оно полностью совпадает с уже имеющимся в списке. Подробнее о доступных стратегиях разрешения конфликтов см. в документации .
  • suspend fun deleteAll() : объявляет функцию приостановки для удаления всех слов.
  • Удобной аннотации для удаления нескольких сущностей не предусмотрено, поэтому она аннотируется с помощью общей @Query .
  • @Query ("DELETE FROM word_table") : @Query требует предоставления SQL-запроса в качестве строкового параметра аннотации, что позволяет выполнять сложные запросы на чтение и другие операции.
  • fun getAlphabetizedWords(): List<Word> : метод для получения всех слов и возврата List Words .
  • @Query( "SELECT * from word_table ORDER BY word ASC" ) : Запрос, который возвращает список слов, отсортированных по возрастанию.

При изменении данных обычно требуется предпринять какие-то действия, например, отобразить обновлённые данные в пользовательском интерфейсе. Это означает, что вам необходимо отслеживать данные, чтобы иметь возможность отреагировать на их изменение.

В зависимости от способа хранения данных это может быть непросто. Наблюдение за изменениями данных в нескольких компонентах приложения может привести к появлению явных и жёстких зависимостей между компонентами. Это, помимо прочего, затрудняет тестирование и отладку.

LiveData , класс библиотеки жизненного цикла для наблюдения за данными, решает эту проблему. Используйте возвращаемое значение типа LiveData в описании метода, и Room сгенерирует весь необходимый код для обновления LiveData при обновлении базы данных.

В WordDao измените сигнатуру метода getAlphabetizedWords() так, чтобы возвращаемый List<Word> был обернут в LiveData .

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

Далее в этой лабораторной работе вы будете отслеживать изменения данных с помощью Observer в MainActivity .

Что такое база данных Room ?

  • Room — это слой базы данных поверх базы данных SQLite.
  • Room берет на себя выполнение рутинных задач, которые раньше выполнялись с помощью SQLiteOpenHelper .
  • Room использует DAO для отправки запросов в свою базу данных.
  • По умолчанию, чтобы избежать снижения производительности пользовательского интерфейса, Room не позволяет выполнять запросы в основном потоке. Когда запросы Room возвращают LiveData , они автоматически выполняются асинхронно в фоновом потоке.
  • Room обеспечивает проверку операторов SQLite во время компиляции.

Реализовать базу данных Room

Класс базы данных Room должен быть абстрактным и расширять RoomDatabase . Обычно для всего приложения требуется только один экземпляр базы данных Room.

Давайте сделаем это сейчас.

  1. Создайте файл класса Kotlin с именем WordRoomDatabase и добавьте в него следующий код:
// 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
            }
        }
   }
}

Давайте разберем код:

  • Класс базы данных для Room должен быть abstract и расширять RoomDatabase
  • Вы аннотируете класс как базу данных Room с помощью @Database и используете параметры аннотации для объявления сущностей, принадлежащих базе данных, и указания номера версии. Каждая сущность соответствует таблице, которая будет создана в базе данных. Миграции базы данных выходят за рамки этой практической работы, поэтому мы устанавливаем здесь exportSchema в значение false, чтобы избежать предупреждения при сборке. В реальном приложении следует рассмотреть возможность настройки каталога, который Room будет использовать для экспорта схемы, чтобы можно было проверить текущую схему в системе контроля версий.
  • База данных предоставляет DAO через абстрактный метод «получателя» для каждого @Dao.
  • Мы определили синглтон WordRoomDatabase, чтобы предотвратить одновременное открытие нескольких экземпляров базы данных.
  • getDatabase возвращает синглтон. Он создаст базу данных при первом обращении к ней, используя конструктор баз данных Room для создания объекта RoomDatabase в контексте приложения из класса WordRoomDatabase и присваивая ему имя "word_database" .

Что такое репозиторий?

Класс репозитория абстрагирует доступ к нескольким источникам данных. Репозиторий не входит в библиотеки компонентов архитектуры, но является рекомендуемым передовым опытом для разделения кода и архитектуры. Класс репозитория предоставляет понятный API для доступа к данным остальной части приложения.

Зачем использовать репозиторий?

Репозиторий управляет запросами и позволяет использовать несколько бэкендов. В наиболее распространённом примере репозиторий реализует логику принятия решения о том, следует ли извлекать данные из сети или использовать результаты, кэшированные в локальной базе данных.

Реализация репозитория

Создайте файл класса Kotlin с именем WordRepository и вставьте в него следующий код:

// 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)
    }
}

Основные выводы:

  • В конструктор репозитория передаётся DAO, а не вся база данных. Это связано с тем, что ему нужен доступ только к DAO, поскольку DAO содержит все методы чтения/записи для базы данных. Нет необходимости предоставлять репозиторию доступ ко всей базе данных.
  • Список слов — это открытое свойство. Он инициализируется путём получения списка слов LiveData из Room; это возможно благодаря тому, как мы определили метод getAlphabetizedWords для возврата LiveData на шаге «Класс LiveData». Room выполняет все запросы в отдельном потоке. Затем наблюдаемый LiveData уведомит наблюдателя в основном потоке об изменении данных.
  • Модификатор suspend сообщает компилятору, что это необходимо вызвать из сопрограммы или другой функции приостановки.

Что такое ViewModel?

Роль ViewModel заключается в предоставлении данных пользовательскому интерфейсу и сохранении изменений конфигурации. ViewModel выступает в качестве центра связи между репозиторием и пользовательским интерфейсом. ViewModel также можно использовать для обмена данными между фрагментами. ViewModel является частью библиотеки жизненного цикла .

Вводное руководство по этой теме см. в ViewModel Overview или в записи блога ViewModels: A Simple Example .

Зачем использовать ViewModel?

ViewModel хранит данные пользовательского интерфейса вашего приложения с учётом жизненного цикла, сохраняя их работоспособность при любых изменениях конфигурации. Разделение данных пользовательского интерфейса приложения от классов Activity и Fragment позволяет лучше следовать принципу единой ответственности: ваши Activity и Fragment отвечают за отображение данных на экране, в то время как ViewModel отвечает за хранение и обработку всех данных, необходимых для пользовательского интерфейса.

В ViewModel используйте LiveData для изменяемых данных, которые будет использовать или отображать пользовательский интерфейс. Использование LiveData имеет несколько преимуществ:

  • Вы можете поместить наблюдателя за данными (вместо опроса на предмет изменений) и обновлять только
    пользовательский интерфейс, когда данные фактически изменяются.
  • Репозиторий и пользовательский интерфейс полностью разделены ViewModel .
  • Нет никаких вызовов базы данных из ViewModel (все это обрабатывается в Repository), что делает код более тестируемым.

viewModelScope

В Kotlin все сопрограммы выполняются внутри CoroutineScope . Область действия управляет жизненным циклом сопрограмм посредством своего задания. При отмене задания области действия отменяются все сопрограммы, запущенные в этой области действия.

Библиотека AndroidX lifecycle-viewmodel-ktx добавляет viewModelScope как функцию расширения класса ViewModel , позволяя работать с областями действия.

Чтобы узнать больше о работе с сопрограммами в ViewModel, ознакомьтесь с шагом 5 лабораторной работы «Использование сопрограмм Kotlin в вашем приложении Android» или с записью блога Easy Coroutines in Android: viewModelScope .

Реализуйте ViewModel

Создайте файл класса Kotlin для WordViewModel и добавьте в него этот код:

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)
    }
}

Вот что у нас есть:

  • Создан класс WordViewModel , который получает Application в качестве параметра и расширяет AndroidViewModel .
  • Добавлена частная переменная-член для хранения ссылки на репозиторий.
  • Добавлена публичная переменная-член LiveData для кэширования списка слов.
  • Создан блок init , который получает ссылку на WordDao из WordRoomDatabase .
  • В блоке init создан WordRepository на основе WordRoomDatabase .
  • В блоке init инициализируем allWords LiveData с помощью репозитория.
  • Создан метод-обёртка insert() , который вызывает метод insert() репозитория. Таким образом, реализация insert() инкапсулируется из пользовательского интерфейса. Мы не хотим, чтобы insert блокировал основной поток, поэтому запускаем новую сопрограмму и вызываем insert репозитория, которая является функцией приостановки. Как уже упоминалось, у ViewModel есть область действия сопрограммы, основанная на их жизненном цикле, называемая viewModelScope , которую мы здесь и используем.

Далее необходимо добавить XML-макет для списка и элементов.

В этой лабораторной работе предполагается, что вы знакомы с созданием макетов в XML, поэтому мы просто предоставляем вам код.

Создайте материальную тему приложения, установив родительский элемент AppTheme на Theme.MaterialComponents.Light.DarkActionBar . Добавьте стиль для элементов списка в 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>

Добавьте макет 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 замените TextView на RecyclerView и добавьте плавающую кнопку действия (FAB). Теперь ваш макет должен выглядеть так:

<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>

Внешний вид вашего FAB должен соответствовать доступному действию, поэтому нам нужно заменить значок символом «+».

Сначала нам нужно добавить новый векторный актив:

  1. Выберите Файл > Создать > Векторный объект .
  2. Щелкните значок робота Android в поле «Клип-арт» .
  3. Найдите «Добавить» и выберите актив «+». Нажмите «ОК».
  4. После этого нажмите кнопку Далее .
  5. Подтвердите путь к значку как main > drawable и нажмите «Finish» , чтобы добавить актив.
  6. Оставаясь в layout/activity_main.xml , обновите FAB, включив в него новый отрисовываемый элемент:
<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"/>

Вы будете отображать данные в RecyclerView , что немного удобнее, чем просто в TextView . В этой лабораторной работе предполагается, что вы знаете, как работают RecyclerView , RecyclerView.LayoutManager , RecyclerView.ViewHolder и RecyclerView.Adapter .

Обратите внимание, что переменная words в адаптере кэширует данные. В следующем задании вы добавите код, который автоматически обновляет данные.

Создайте файл класса Kotlin для WordListAdapter , расширяющий RecyclerView.Adapter . Вот код:

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
}

Добавьте RecyclerView в метод onCreate() объекта MainActivity .

В методе onCreate() после setContentView :

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

Запустите приложение, чтобы убедиться, что всё работает. Элементов нет, поскольку вы ещё не подключили данные.

В базе данных нет данных. Вы можете добавить данные двумя способами: добавить данные при открытии базы данных и добавить Activity для добавления слов.

Чтобы удалить весь контент и заново заполнить базу данных при каждом запуске приложения, создайте RoomDatabase.Callback и переопределите onOpen() . Поскольку операции с базой данных Room невозможно выполнять в потоке пользовательского интерфейса, onOpen() запускает сопрограмму в диспетчере ввода-вывода.

Для запуска сопрограммы нам нужен CoroutineScope . Обновите метод getDatabase класса WordRoomDatabase , чтобы также получить область действия сопрограммы в качестве параметра:

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

Обновите инициализатор извлечения базы данных в блоке init WordViewModel , чтобы он также передавал область действия:

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

В WordRoomDatabase мы создаём пользовательскую реализацию RoomDatabase.Callback() , которая также получает CoroutineScope в качестве параметра конструктора. Затем мы переопределяем метод onOpen для заполнения базы данных.

Вот код для создания обратного вызова в классе 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!
    }
}

Наконец, добавьте обратный вызов в последовательность сборки базы данных прямо перед вызовом .build() в Room.databaseBuilder() :

.addCallback(WordDatabaseCallback(scope))

Вот как должен выглядеть окончательный код:

@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
        }
     }
   }
}

Добавьте эти строковые ресурсы в 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>

Добавьте этот цветовой ресурс в value/colors.xml :

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

Создайте новый файл ресурсов измерения:

  1. Щелкните модуль приложения в окне проекта .
  2. Выберите Файл > Создать > Файл ресурсов Android.
  3. Из доступных квалификаторов выберите «Измерение» .
  4. Задайте имя файла: dimens

Добавьте эти ресурсы измерений в values/dimens.xml :

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

Создайте новую пустую Activity Android с помощью шаблона Empty Activity:

  1. Выберите Файл > Создать > Активность > Пустая активность.
  2. Введите NewWordActivity в качестве имени действия.
  3. Убедитесь, что новое действие добавлено в манифест Android.
<activity android:name=".NewWordActivity"></activity>

Обновите файл activity_new_word.xml в папке макета следующим кодом:

<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>

Обновите код для активности:

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"
    }
}

Последним шагом является подключение пользовательского интерфейса к базе данных путем сохранения новых слов, введенных пользователем, и отображения текущего содержимого базы данных слов в RecyclerView .

Чтобы отобразить текущее содержимое базы данных, добавьте наблюдателя, который наблюдает за LiveData в ViewModel .

При каждом изменении данных вызывается обратный вызов onChanged() , который вызывает метод setWords() адаптера для обновления кэшированных данных адаптера и обновления отображаемого списка.

В MainActivity создайте переменную-член для ViewModel :

private lateinit var wordViewModel: WordViewModel

Используйте ViewModelProvider для связи ViewModel с Activity .

При первом запуске Activity ViewModelProviders создают ViewModel . При уничтожении Activity, например, из-за изменения конфигурации, ViewModel сохраняется. При повторном создании Activity ViewModelProviders возвращают существующую ViewModel . Подробнее см. в разделе ViewModel .

В onCreate() под блоком кода RecyclerView получите ViewModel из ViewModelProvider :

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

Также в onCreate() добавьте наблюдателя для свойства allWords LiveData из WordViewModel .

Метод onChanged() (метод по умолчанию для нашей Lambda) срабатывает, когда наблюдаемые данные изменяются, а активность находится на переднем плане:

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

Мы хотим, чтобы при нажатии на FAB открывалась NewWordActivity , а когда мы вернёмся в MainActivity , либо вставить новое слово в базу данных, либо вывести Toast . Для этого начнём с определения кода запроса:

private val newWordActivityRequestCode = 1

В MainActivity добавьте код onActivityResult() для NewWordActivity .

Если действие возвращается с RESULT_OK , вставьте возвращенное слово в базу данных, вызвав метод insert() 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, запустите NewWordActivity , когда пользователь нажмёт на FAB. В MainActivity onCreate найдите FAB и добавьте onClickListener со следующим кодом:

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

Ваш готовый код должен выглядеть так:

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()
       }
   }
}

Теперь запустите приложение! При добавлении слова в базу данных в NewWordActivity пользовательский интерфейс автоматически обновится.

Теперь, когда у вас есть работающее приложение, давайте подведем итоги. Вот структура приложения:

Компоненты приложения:

  • MainActivity : отображает слова в списке с помощью RecyclerView и WordListAdapter . В MainActivity есть Observer , который отслеживает слова LiveData в базе данных и получает уведомления об их изменении.
  • NewWordActivity: добавляет новое слово в список.
  • WordViewModel : предоставляет методы для доступа к слою данных и возвращает LiveData, чтобы MainActivity мог настроить отношение наблюдателя.*
  • LiveData<List<Word>> : обеспечивает автоматическое обновление компонентов пользовательского интерфейса. В MainActivity есть Observer , который отслеживает слова LiveData в базе данных и получает уведомления об их изменении.
  • Repository: управляет одним или несколькими источниками данных. Repository предоставляет методы ViewModel для взаимодействия с базовым поставщиком данных. В данном приложении таким бэкендом является база данных Room.
  • Room : это оболочка, реализующая базу данных SQLite. Room выполняет за вас большую часть работы, которую раньше приходилось выполнять самостоятельно.
  • DAO: сопоставляет вызовы методов с запросами к базе данных, так что когда Репозиторий вызывает такой метод, как getAlphabetizedWords() , Room может выполнить SELECT * from word_table ORDER BY word ASC .
  • Word : класс сущностей, содержащий одно слово.

* Views и ActivitiesFragments ) взаимодействуют с данными только через ViewModel . Таким образом, источник данных не имеет значения.

Поток данных для автоматического обновления пользовательского интерфейса (реактивный пользовательский интерфейс)

Автоматическое обновление возможно благодаря использованию LiveData. В MainActivity есть Observer , который отслеживает слова LiveData из базы данных и получает уведомления об их изменении. При изменении выполняется метод onChange() наблюдателя, который обновляет mWords в WordListAdapter .

Данные можно наблюдать, поскольку они представляют собой LiveData . А наблюдаемым объектом является LiveData<List<Word>> , возвращаемый WordViewModel через свойство llWords .

WordViewModel скрывает всю информацию о бэкенде от уровня пользовательского интерфейса. Он предоставляет методы для доступа к уровню данных и возвращает LiveData , чтобы MainActivity могла установить отношение наблюдателя. Views и Activities (а также Fragments ) взаимодействуют с данными только через ViewModel . Таким образом, источник данных не имеет значения.

В этом случае данные поступают из Repository . ViewModel не нужно знать, с чем взаимодействует этот Repository. Ей нужно лишь знать, как взаимодействовать с Repository , то есть через методы, предоставляемые Repository .

Репозиторий управляет одним или несколькими источниками данных. В приложении WordListSample таким бэкендом является база данных Room. Room — это обёртка вокруг базы данных SQLite, реализующая её. Room выполняет за вас многие задачи, которые раньше приходилось выполнять самостоятельно. Например, Room делает всё то, что раньше выполнялось с помощью класса SQLiteOpenHelper .

DAO сопоставляет вызовы методов с запросами к базе данных, так что когда Repository вызывает такой метод, как getAllWords() , Room может выполнить SELECT * from word_table ORDER BY word ASC .

Поскольку возвращаемый запросом результат — это наблюдаемые LiveData , каждый раз при изменении данных в Room выполняется метод onChanged() интерфейса Observer и обновляется пользовательский интерфейс.

[Необязательно] Загрузите код решения

Если вы ещё этого не сделали, вы можете ознакомиться с кодом решения для этой лабораторной работы. Вы можете посмотреть его в репозитории GitHub или скачать здесь:

Загрузить исходный код

Распакуйте скачанный ZIP-архив. Будет распакована корневая папка android-room-with-a-view-kotlin , содержащая полное приложение.