Цель компонентов архитектуры — предоставить руководство по архитектуре приложений, включая библиотеки для решения таких распространенных задач, как управление жизненным циклом и сохранение данных. Компоненты архитектуры помогают структурировать приложение таким образом, чтобы оно было надёжным, тестируемым и поддерживаемым с меньшим количеством шаблонного кода. Библиотеки компонентов архитектуры входят в состав Android Jetpack .
Это версия лабораторной работы на Kotlin. Версию на Java можно найти здесь .
Если во время работы над этой лабораторной работой у вас возникнут какие-либо проблемы (ошибки кода, грамматические ошибки, неясные формулировки и т. д.), сообщите о них, воспользовавшись ссылкой «Сообщить об ошибке» в левом нижнем углу лабораторной работы.
Предпосылки
Вам необходимо быть знакомым с Kotlin, концепциями объектно-ориентированного проектирования и основами разработки для Android, в частности:
-
RecyclerView
и адаптеры - База данных SQLite и язык запросов SQLite
- Базовые сопрограммы (если вы не знакомы с сопрограммами, вы можете ознакомиться с разделом Использование сопрограмм Kotlin в вашем приложении Android .)
Также полезно ознакомиться с шаблонами архитектуры программного обеспечения, которые отделяют данные от пользовательского интерфейса, такими как MVP или MVC. В этой лабораторной работе реализована архитектура, определённая в Руководстве по архитектуре приложений .
Эта лабораторная работа посвящена компонентам архитектуры Android. Не относящиеся к теме концепции и код предоставляются для простого копирования и вставки.
Если вы не знакомы с Kotlin, версия этой лабораторной работы на языке программирования Java доступна здесь .
Что ты будешь делать?
В этой лабораторной работе вы узнаете, как проектировать и создавать приложение с использованием Architecture Components Room, ViewModel и LiveData, а также как создать приложение, которое выполняет следующие функции:
- Реализует нашу рекомендуемую архитектуру с использованием компонентов архитектуры Android.
- Работает с базой данных для получения и сохранения данных, а также предварительно заполняет базу данных некоторыми словами.
- Отображает все слова в
RecyclerView
вMainActivity
. - Открывает второе действие при нажатии пользователем кнопки «+». Когда пользователь вводит слово, оно добавляется в базу данных и список.
Приложение простое, но достаточно сложное, чтобы использовать его в качестве шаблона для дальнейшего развития. Вот предварительный просмотр:
Что вам понадобится
- Android Studio 3.0 или более поздней версии и навыки её использования. Убедитесь, что Android Studio, а также SDK и Gradle обновлены.
- Устройство Android или эмулятор.
В этой лабораторной работе представлен весь код, необходимый для создания полноценного приложения.
Использование компонентов архитектуры и реализация рекомендуемой архитектуры включает множество этапов. Самое главное — создать мысленную модель происходящего, понимая, как взаимодействуют все элементы и как передаются данные. Работая над этой практической работой, не просто копируйте и вставляйте код, а постарайтесь начать формировать это внутреннее понимание.
Каковы рекомендуемые компоненты архитектуры?
Чтобы познакомить вас с терминологией, предлагаем краткое введение в компоненты архитектуры и принципы их взаимодействия. Обратите внимание, что эта лабораторная работа посвящена подмножеству компонентов, а именно 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) представляет собой класс, который вы создадите.
- Откройте Android Studio и нажмите «Начать новый проект Android Studio».
- В окне «Создать новый проект» выберите «Пустая активность» и нажмите «Далее».
- На следующем экране назовите приложение RoomWordSample и нажмите Готово .
Далее вам придется добавить библиотеки компонентов в файлы Gradle.
- В Android Studio перейдите на вкладку «Проекты» и разверните папку Gradle Scripts.
Откройте build.gradle
( Модуль: app ).
- Примените плагин Kotlin -процессора аннотаций
kapt
, добавив его после других плагинов, определенных в верхней части файлаbuild.gradle
( Модуль: app ).
apply plugin: 'kotlin-kapt'
- Добавьте блок
packagingOptions
внутрь блокаandroid
, чтобы исключить модуль атомарных функций из пакета и предотвратить появление предупреждений.
android {
// other configuration (buildTypes, defaultConfig, etc.)
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
- Добавьте следующий код в конец блока
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"
- В файле
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 . Давайте сделаем это сейчас.
- Создайте новый файл класса Kotlin с именем
Word
, содержащий класс данныхWord
.
Этот класс будет описывать сущность (представляющую таблицу SQLite) для ваших слов. Каждое свойство класса представляет столбец таблицы. Room в конечном итоге будет использовать эти свойства как для создания таблицы, так и для создания экземпляров объектов из строк базы данных.
Вот код:
data class Word(val word: String)
Чтобы сделать класс Word
содержательным для базы данных Room, необходимо аннотировать его. Аннотации определяют, как каждая часть этого класса связана с записью в базе данных. Room использует эту информацию для генерации кода.
Если вы введете аннотации самостоятельно (вместо вставки), Android Studio автоматически импортирует классы аннотаций.
- Обновите свой класс
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, который будет выполнять запросы для:
- Упорядочить все слова в алфавитном порядке
- Вставка слова
- Удаление всех слов
- Создайте новый файл класса Kotlin с именем
WordDao
. - Скопируйте и вставьте следующий код в
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.
Давайте сделаем это сейчас.
- Создайте файл класса 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 должен соответствовать доступному действию, поэтому нам нужно заменить значок символом «+».
Сначала нам нужно добавить новый векторный актив:
- Выберите Файл > Создать > Векторный объект .
- Щелкните значок робота Android в поле «Клип-арт» .
- Найдите «Добавить» и выберите актив «+». Нажмите «ОК».
- После этого нажмите кнопку Далее .
- Подтвердите путь к значку как
main > drawable
и нажмите «Finish» , чтобы добавить актив. - Оставаясь в
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>
Создайте новый файл ресурсов измерения:
- Щелкните модуль приложения в окне проекта .
- Выберите Файл > Создать > Файл ресурсов Android.
- Из доступных квалификаторов выберите «Измерение» .
- Задайте имя файла: dimens
Добавьте эти ресурсы измерений в values/dimens.xml
:
<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</dimen>
Создайте новую пустую Activity
Android с помощью шаблона Empty Activity:
- Выберите Файл > Создать > Активность > Пустая активность.
- Введите
NewWordActivity
в качестве имени действия. - Убедитесь, что новое действие добавлено в манифест 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
и Activities
(и Fragments
) взаимодействуют с данными только через 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
, содержащая полное приложение.