Room
Продолжение урока об архитектуре приложения будет посвящено хранению данных и работе с базой данных.
По ходу урока будет доработано приложение "Sleep Tracker" помогающее следить за сном и его состоянием.
На главном экране приложения располагаются кнопки "Start" и "Stop" для запуска таймера сна, а также список записей с информацией о сне. После остановки таймера сна приложение отображает экран "Quality", позволяющий оценить качество сна. После оценки, новая запись добавляется в список. Кнопка "Clear" на главном экране служит для очистки данных.
Архитектура приложения, использующего базу данных будет похожа на ту, что использовалась ранее. Единственное отличие заключается в том, что здесь ViewModel взаимодействует с базой данных Room.
Room
— это высокоуровневый интерфейс для работы с базой данных SQLite, встроенный в Android. Room
выполняет большую часть своей работы во время компиляции, создавая API-интерфейс поверх встроенного SQLite API, что избавляет от необходимости работать с устаревшими Cursor
и ContentResolver
.
В этом уроке будет рассмотрено как:
Room
для работы с базой данных.Room
в рамках шаблона архитектуры MVVM.Обзор стартового кода приложения-примера:
В Gradle-файле модуля app
уже включены все необходимые зависимости: Room, Lifecycle Library, Coroutines.
// Room and Lifecycle dependencies
implementation "androidx.room:room-runtime:$version_room"
kapt "androidx.room:room-compiler:$version_room"
implementation "androidx.lifecycle:lifecycle-extensions:$version_lifecycle_extensions"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version_coroutine"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version_coroutine"
Код приложения сгруппирован в каталогах по функциональности. В каталоге sleeptracker
содержатся классы, относящиеся к начальному экрану "Sleep Tracker". В каталоге sleepquality
содержатся классы для функционала экрана "Sleep Quality". В каталоге database
содержатся классы для работы с БД. Изначально они пустые.
Файл Util.kt
содержит вспомогательные функции для работы со значениями качества сна, форматирования строки с датой и временем для отображения, а также закомментированный код, который использует экземпляр ViewModel
и будет раскомментирован в ходе работы.
Данный урок предполагает, что читатель знаком с базами данных и SQL-синтаксисом.
При использовании Room
для работы с базой данных необходимо знание о двух основных понятиях: entities (объектах, сущностях) и queries (запросах).
Entity представляет собой объект или концепт, хранимый в базе данных. Entity-класс определяет таблицу базы данных, а каждый экземпляр такого класса — одну строку таблицы. Например, класс Person
, описывающий таблицу person
базы данных, будет являться entity-классом.
Query — это запрос на получение, добавление, изменение или удаление данных из таблицы или нескольких таблиц базы данных. Пример: запрос SELECT
на получение всех записей из некоторой таблицы.
Использование Room
значительно упрощает процесс объявления и использования entity-классов и запросов.
В приложении "Sleep Tracker" в качестве Entity-класса будет класс-модель SleepNight
, содержащий информацию о сне: дату и время начала сна, дату и время его окончания, а также его качество. Кроме, этого в классе будет описано поле nightId
, которое будет содержать идентификатор записи в таблице.
@Entity(tableName = "sleep_quality_table")
data class SleepNight(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var nightId: Long = 0L,
@ColumnInfo(name = "start_time_millis")
val startTimeMillis: Long = System.currentTimeMillis(),
@ColumnInfo(name = "end_time_millis")
var endTimeMillis: Long = startTimeMillis,
@ColumnInfo(name = "sleep_quality")
var sleepQuality: Int = -1
)
Для объявления класса как описания таблицы базы данных используется аннотация @Entity
, куда в качестве параметра передается имя таблицы. По общепринятому соглашению по именованию таблиц баз данных имя таблицы указывается с использованием подчеркивания в качестве разделителя между словами. Если в аннотации @Entity
не указать вручную имя таблицы, тогда в качестве имени будет использоваться имя класса, однако оно не будет соответствовать соглашению об именовании таблиц баз данных.
Для объявления столбцов таблицы, достаточно просто описать свойства класса в его конструкторе. Таким образом столбцы таблицы будут иметь те же имена, что и поля класса. Однако, такие поля также не соответствуют соглашению об именовании, и поэтому используются аннотации @ColumnInfo
с параметром name
для указания имени столбца вручную с использованием подчеркиваний.
Аннотация @PrimaryKey
используется для указания того, что поле является ключом таблицы. В данном случае это поле nightId
типа Long
. Также устанавливается параметр autoGenerate
, определяющий, что новый ключ будет генерироваться автоматически при добавлении новой записи в таблицу.
Таким образом создается класс SleepNight
, описывающий таблицу базы данных sleep_quality_table
с 4-мя столбцами: id
, start_time_millis
, end_time_millis
и sleep_quality
.
Во время использования базы данных возникает необходимость выполнения запросов к ней, например, на добавление или изменение данных, на удаление данных, а также на получение данных из базы, а зачастую еще и на получение с определенными условиями. Чтобы упростить работу с базой данных и особенно с выполнением запросов к ней, было придумано понятие объекта доступа к данным или Data Access Object, или коротко DAO.
Обычно DAO-классы — это классы, содержащие методы для работы с базой данных и выполнения запросов к ней. Снаружи интерфейс таких классов выглядит как набор методов, выполняющих определенные операции (запросы к БД), а внутри же каждого из методов описана логика выполнения запроса к БД и получения ответа от нее.
Библиотека Room
предлагает свое определение DAO-классов, вернее DAO-интерфейсов. Здесь описание DAO является описанием интерфейса с методами, а не класса. А основная логика выполнения запросов прячется за определением аннотаций.
Room
предоставляет следующие DAO-аннотации: @Insert
, @Update
, @Delete
, а также @Query
. Первые три используются для выполнения SQL-запросов INSERT
, UPDATE
и DELETE
. Аннотация @Query
позволяет описать любой запрос, поддерживающийся в SQLite.
В приложении "Sleep Tracker" уже создан интерфейс SleepDatabaseDao
, но он пуст. Необходимо добавить описание методов для:
1. Добавление интерфейса с описанием DAO:
Для добавления нового DAO-интерфейса необходимо создать новый интерфейс и пометить его аннотацией @Dao
.
В стартовом приложении уже есть интерфейс SleepDatabaseDao
, поэтому необходимо лишь добавить для него фигурные скобки блока интерфейса и аннотацию.
@Dao
interface SleepDatabaseDao {
}
2. Добавление метода insert()
:
Метод, который будет выполнять добавление новой записи о сне в БД, будет называться insert()
. В качестве параметра метод принимает экземпляр SleepNight
, который описан как сущность (entity) нашей базы данных.
Для того, чтобы этот метод выполнял запрос INSERT
к БД, необходимо добавить к методу аннотацию @Insert
. Во время компиляции проекта Room
сгенерирует код по аннотации, который будет корректно выполнять запрос INSERT
при вызове этого метода. Сгенерированный код, будет разбирать полученный объект SleepNight
на строки таблицы, которой объект принадлежит и выполнять запрос.
@Insert
fun insert(night: SleepNight)
3. Добавление метода update()
:
Для обновления записей в БД добавляется метод update()
и помечается аннотацией @Update
. Метод будет выполнять запрос UPDATE
к базе данных. Все работает аналогично методу insert()
.
@Update
fun update(night: SleepNight)
4. Добавление метода get()
:
Метод для получения записи из базы по ее ключу будет называться get()
. Поскольку ни @Insert
, ни @Update
, ни @Delete
, не подходят для выполнения запроса SELECT
на получение данных, поэтому здесь будет использоваться аннотация @Query
.
@Query("SELECT * FROM sleep_quality_table WHERE id = :key")
fun get(key: Long): SleepNight?
Аннотация @Query
принимает в качестве параметра строку с запросом, который необходимо выполнить. В данном случае — это запрос SELECT *
на получение всех записей из таблицы sleep_quality_table
с полем id
равным переданному в метод параметру key
. После компиляции также будет автоматически сгенерирован код по аннотации Query
, который будут выполнять описанный в аннотации запрос к БД.
К слову, если SQL-запрос написан с ошибкой, то среда подкрасит запрос красным, но собрать проект будет возможно.
5. Добавление метода clear()
:
Метод для удаления всех записей о сне из таблицы будет называться clear()
и он также будет помечен аннотацией @Query
. С описанием запроса.
@Query("DELETE FROM sleep_quality_table")
fun clear()
В данном случае не используется аннотация @Delete
по той причине, что запрос эта аннотация используется для удаления лишь одной конкретной записи с указанием конкретного экземпляра SleepNight
или списка экземпляров в качестве параметра метода. Здесь же запрос удаляет все данные из конкретной таблицы и его можно описать только с помощью аннотации @Query
.
6. Добавление метода getAllNights()
:
Метод для получения списка всех записей сна будет называться getAllNights()
и будет возвращать объект LiveData
со списком объектов SleepNight
. К методу также добавлена аннотация @Query
, описывающая запрос SELECT
по получению записей из таблицы и сортировке данных по убыванию id
. Таким образом самые новые записи будут самыми первыми.
@Query("SELECT * FROM sleep_quality_table ORDER BY id DESC")
fun getAllNights(): LiveData<List<SleepNight>>
Тип LiveData
будет использоваться далее для отслеживания изменений в таблице БД и обновления вида. Возможность возвращать данные, обернутые в LiveData
— одна из самых полезных функций Room
.
7. Добавление метода getTonight()
:
В завершении будет добавлен метод для получения последней добавленной записи getTonight()
. Метод также будет помечен аннотацией @Query
с описанием запроса. Запрос похож на тот, что описан в методе по получению всех записей. Отличие в том, что здесь используется параметр LIMIT 1
для гарантированного получения лишь одной записи, а не списка.
@Query("SELECT * FROM sleep_quality_table ORDER BY id DESC LIMIT 1")
fun getTonight(): SleepNight?
Таким образом был описан DAO-интерфейс с использованием аннотаций библиотеки Room
, упрощающих описание запросов к базе данных.
В конце, чтобы убедиться, что сборка проходит и приложение не падает, можно его запустить.
Room
Теперь, когда созданы классы сущности (entity) и DAO, можно перейти к добавлению базы данных. Для этого необходимо реализовать абстрактный класс унаследованный от RoomDatabase
помеченный аннотацией @Database
.
SleepDatabase
Для создания класса базы данных необходимо:
RoomDatabase
. Класс абстрактный, Room
создаст его реализацию самостоятельно при компиляции.@Database
с передачей в качестве параметров список сущностей (entity) для создания таблиц БД, а также версии БД.Room
также сгенерирует его реализацию.1. Добавление абстрактного класса SleepDatabase
:
В стартовом приложении уже создан пустой файл SleepDatabase.kt
. В него необходимо добавить объявление абстрактного класса SleepDatabase
с аннотацией @Database
.
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {
}
В параметрах аннотации описывается:
exportSchema
, определяющий должна ли схема БД быть экспортирована в каталог проекта. По умолчанию значение true
, но в этом простейшем примере нет необходимости куда-то экспортировать схему БД.2. Добавление объявления абстрактного метода для получения экземпляра DAO:
abstract fun getSleepDatabaseDao(): SleepDatabaseDao
3. Добавление статического поля SleepDatabase
:
Далее предлагается сделать класс SleepDatabaseDao
синглтоном, т.е. классом, хранящим собственный экземпляр внутри себя в виде статического поля. Таким образом состояние объекта будет одним и тем же во всем приложении независимо от класса, в котором SleepDatabaseDao
используется.
companion object {
@Volatile
private var INSTANCE: SleepDatabase? = null
}
Статические классы и методы объявляются внутри блока companion object
. Здесь объявляется поле INSTANCE
типа SleepDatabase
. Поле INSTANCE
является экземпляром созданного класса базы данных.
Аннотация @Volitile
помечает поле как поле доступное всем имеющимся потокам приложения. То есть это поле будет доступно в рамках всех потоков приложения, в том числе и основного UI-потока. Изменение поля помеченного @Volitile
будет видно мгновенно во всех потоках. Таким образом можно избежать проблемы, когда два потока используют поле и его данные, но в одном потоке данные актуальны, а во втором не синхронизированы, что может приводить к проблемам.
4. Добавление метода для получения экземпляра класса SleepDatabase
:
Для получения экземпляра синглтона SleepDatabase
в рамках приложения необходимо добавить статический метод, который будет инициализировать поле INSTANCE
если оно null
и возвращать его, либо возвращать объект, если он уже был создан ранее.
companion object {
...
fun getInstance(context: Context): SleepDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(context.applicationContext,
SleepDatabase::class.java, "sleep_tracker_db")
.build()
INSTANCE = instance
}
return instance
}
}
}
Метод getInstance()
принимает на вход экземпляр класса Context
, например, экземпляр активности. Контекст необходим для создания экземпляра класса БД SleepDatabase
с помощью Room
.
Весь код метода описан в блоке synchronized {}
. Если база данных, а следовательно и объект SleepDatabase
, используется несколькими потоками, то описание блока synchronized {}
позволяет выполнять кусок кода только в рамках одного потока. Остальные потоки будут дожидаться момента, когда первый поток закончит выполнение описанного блока, и только тогда код станет доступен для остальных потоков. Таким образом блок sunchronized {}
позволяет избежать ошибок множественного доступа к синглтон-объекту разными потоками.
В рамках блока synchronized {}
описывается инициализация поля INSTANCE
. Сперва инициализируется временное поле instance
значением поля INSTANCE
. Далее проверяется было ли поле проинициализировано ранее, и если нет, то инициализируется с помощью вызова Room.databaseBuilder().build()
. В качестве параметров передается контекст приложения, класс SleepDatabase
и имя файла базы данных, которое должно быть присвоено.
После создания экземпляра БД во временной переменной, переписываем ее в поле INSTANCE
и возвращаем значение временной переменной. Таким образом метод getInstance()
позволяет получать экземпляр класса базы данных SleepDatabase
. При этом, если экземпляр не был создан ранее, он будет создан.
Манипуляции с временными переменными используются для того, чтобы избежать ситуации, когда, в процессе выполнения метода, какой-нибудь из потоков меняет значение поля INSTANCE
на null
. Кроме того, компилятор предостерегает нас об этом. Если не использовать временной переменной, то он не позволит собрать такой код.
Таким образом создается синглтон-класс для инициализации и доступа к базе данных. Код должен успешно собираться, однако пока неясно работает ли он.
SleepDatabase
Для проверки работоспособности кода можно выполнить уже добавленные в стартовый код тест. Для этого необходимо перейти в файл SleepDatabaseTest.kt
и раскомментировать весь код.
Код тестов содержит метод createDb()
помеченный аннотацией @Before
. Аннотация объявляет данный метод как тот, что будет вызван перед выполнением каждого теста. Здесь инициализируется объект SleepDatabase
с помощью метода inMemoryDatabaseBuilder()
. Метод создаст временную БД, которая будет хранится в памяти устройства и будет удалена автоматически после завершения тестов. Метод allowMainThreadQueries()
позволяет выполнять запросы к БД на главном потоке приложения. По умолчанию это не разрешено, т.к. выполнение запросов на главном потоке блокирует выполнение остальных операций. Вызов же метода allowMainThreadQueries()
разрешает выполнение запросов на главном потоке. Однако, когда запросов становится много это может значительно замедлять работу приложения.
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
// Allowing main thread queries, just for testing.
.allowMainThreadQueries()
.build()
sleepDao = db.getSleepDatabaseDao()
}
Метод closeDb()
выполняет закрытие соединения с БД и т.к. он помечен аннотацией @After
он будет выполнен после каждого теста.
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
Метод же помеченный аннотацией @Test
является методом-тестом. Здесь такой метод проверяет корректность работы DAO по добавлению новой записи в БД. Метод создает новый объект SleepNight
, использует объект DAO для выполнения запроса INSERT
к БД, затем достает из БД последнюю добавленную запись и проверяет значение ее поля sleepQuality
на соответствие значению по умолчанию, т.к. вручную значение для поля не задавалось.
@Test
@Throws(Exception::class)
fun insertAndGetNight() {
val night = SleepNight()
sleepDao.insert(night)
val tonight = sleepDao.getTonight()
assertEquals(tonight?.sleepQuality, -1)
}
Для запуска тестов необходимо нажать на файле правой кнопкой мыши и выбрать Run
.
Для работы с базой данных в рамках шаблона MVVM необходимо добавить ViewModel
-класс с объектом DAO внутри.
1. Описание класса SleepTrackerViewModel
:
В стартовом коде приложения уже создан класс SleepTrackerViewModel
.
class SleepTrackerViewModel(
val dao: SleepDatabaseDao,
application: Application) : AndroidViewModel(application) {
}
Класс SleepTrackerViewModel
наследуется от AndroidViewModel
. Класс AndroidViewModel
является расширеним стандартного ViewModel
. Отличие заключается в том, что конструктор должен принимать в качестве параметра экземпляр класса Application
. Такое расширение может быть полезным в случаях, когда нам нужно использовать контекст приложения, например, для доступа к ресурсам приложения.
Конструктор класса SleepTrackerViewModel
принимает на вход два параметра: объект класса SleepDatabaseDao
, являющийся свойством класса, а также непосредственно объект Application
, передающийся в конструктор AndroidViewModel
.
2. Описание класса SleepTrackerViewModelFactory
:
Поскольку класс SleepTrackerViewModel
содержит конструктор с параметром, для создания объекта класса необходимо описать factory-класс SleepTrackerViewModelFactory
. В стартовом коде приложения такой класс уже добавлен.
class SleepTrackerViewModelFactory(
private val dao: SleepDatabaseDao,
private val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SleepTrackerViewModel::class.java)) {
return SleepTrackerViewModel(dao, application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Класс SleepTrackerViewModelFactory
наследуется от ViewModelProvider.Factory
. Конструктор класса также принимает объекты DAO и контекста приложения, которые передаются в конструктор SleepTrackerViewModel
.
2. Добавление ViewModel в фрагмент:
Для добавления ViewModel
необходимо добавить свойство viewModel
класса SleepTrackerFragment
и инициализировать его в методе onCreateView()
.
// SleepTrackerFragment
private lateinit var viewModel: SleepTrackerViewModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
...
val application = requireNotNull(this.activity).application
val dao = SleepDatabase.getInstance(application).getSleepDatabaseDao()
val viewModelFactory = SleepTrackerViewModelFactory(dao, application)
viewModel = ViewModelProvider(this, viewModelFactory)
.get(SleepTrackerViewModel::class.java)
...
}
В методе onCreateView()
сперва инициализируется объект application
, который содержит контекст приложения и который необходимо передать в конструктор SleepTrackerViewModel
. Экземпляр объекта можно получить из экземпляра активности, внутри которой содержится фрагмент. Здесь используется метод requireNotNull()
для проверки экземпляра активности на null
, и если активность содержит null
, то метод выкинет исключение с сообщением об этом. В нормальной ситуации такого произойти не должно, поэтому здесь мы не игнорируем прдупреждение компилятора, а обрабатываем его таким образом, чтобы как можно раньше узнать о возможной проблеме.
Далее инициализируется объект DAO. Получение объекта выполняется с помощью статического вызова метода getInstance(application).getSleepDatabaseDao()
класса базы данных SleepDatabase
. Вызов метода getInstance()
инициализирует или возвращает уже проинициализированный ранее экземпляр объекта базы данных SleepDatabase
, а метод getSleepDatabaseDao()
возвращает экземпляр DAO. Стоит напомнить, что метод описан как абстрактный, но при сборке Room
генерирует его реализацию автоматически.
Далее создается объект SleepTrackerViewModelFactory
с передачей в конструктор экземпляров DAO и контекста приложения, необходимых для инициализации ViewModel
-класса. И в завершении инициализируется объект viewModel
с помощью вызова ViewModelProvider().get()
.
Таким образом реализуется и описывается ViewModel
-класс с объектом DAO внутри и его инициализация в фрагменте.
Классы для работы с базой данных подготовлены. Теперь необходимо перейти к реализации функционала приложения: реализации функционала кнопок, добавлению списка записей на основной экран.
По нажатию на кнопку "Start" в базу данных должна добавляться новая запись.
По нажатию на кнопку "Stop" у последней записи в базе данных должно обновляться поле, обозначающее время окончания сна.
По нажатию на кнопку "Clear" из базы данных должны удаляться все записи.
Операции записи и чтения данных из БД являются длительными, когда записей становится достаточно много. По этой причине принято выделять выполнение подобных операций в отдельные потоки.
Современные мобильные устройства имеют несколько процессоров. Каждый процессор выполняет одновременно по несколько процессов. Процессами могут быть сами приложения или, например, выполнение каких-либо системных служб. В свою очередь каждый процесс может создавать множество потоков для выполнения тех или иных операций. Это и называется многопоточностью.
Например, можно представить человека, который читает одновременно три книги, переключаясь между ними после каждой главы. В этом примере читатель является процессом, а книги — тремя отдельными потоками выполнения.
В Android каждое приложение выполняется на главном потоке приложения, также этот поток называется UI-потоком, т.к. именно на этом потоке выполняются все операции, касающиеся пользовательского интерфейса. Кроме этого главный поток обновляет экран каждые 16 миллисекунд, что позволяет пользовательскому интерфейсу быть плавным и гладким.
Если на главном потоке будут выполняться длительные операции, например, запросы к БД с большим числом записей, то выполнение главного потока будет блокироваться. Т.е. на главном потоке не будет выполняться ничего до тех пор пока длительная операция не будет завершена, что приведет к зависанию пользовательского интерфейса.
Большая часть операций в приложении — сложные и их выполнение занимает более 16-ти миллисекунд. Например, выполнение сетевых запросов и ожидание ответов, чтение файлов или операции доступа к данным в БД. Таким образом выполнение длительных операций на главном потоке является по меньшей мере нежелательным. Для таких операций создаются отдельные потоки, выполняющиеся в фоне и не занимающие главный поток приложения.
В Kotlin для элегантной и эффективной обработки длительных задач используются корутины. Корутины — это способ написания асинхронного, неблокирующего кода.
Выдержка с описанием корутин из документации:
Корутины можно представить в виде облегчённого потока. Подобно потокам, корутины могут работать параллельно, ждать друг друга и общаться. Самое большое различие заключается в том, что корутины очень дешевые, почти бесплатные: мы можем создавать их тысячами и платить очень мало с точки зрения производительности. Потоки же обходятся дорого. Тысяча потоков может стать серьезной проблемой даже для современной машины.
Корутины обладают тремя основными характеристиками:
1. Асинхронность
Асинхронность означает, что корутины запускаются независимо от главного потока и того, что на нем выполняется. Корутины выполняются параллельно главному потоку и не мешают его выполнению.
Основной аспект асинхронности заключается в том, что нет необходимости в явном беспрерывном ожидании окончания выполнения и результата во время работы корутины. Например, если у вас есть некоторый сложный вопрос и вы задаете его коллегам, то нет необходимости останавливать свою работу пока коллеги на протяжении длительного времени работают и ищут ответ на ваш вопрос. В это время можно сделать работу, которая не зависит от ответа на ваш вопрос. По этому же принципу работают корутины, они позволяют выполнять некоторую работу (как поиск ответа на вопрос) параллельно, не мешая основной работе.
2. Корутины не блокируют главный поток
Корутины не блокируют главный поток выполнения приложения. Таким образом анимации приложения всегда будут плавными.
3. Функции приостановки
Поскольку код корутин компилируется из последовательного кода, нет необходимости использовать callback-функции для обработки результата выполнения отдельного потока.
Ключевое слово suspend
используется в Kotlin для того, чтобы пометить функцию как доступную для использования корутинами.
Когда корутина вызывает функцию помеченную как suspend
, вместо блокирования выполнения основного потока, функция просто приостанавливает его выполнение до тех пор, пока результат ее выполнения не будет готов. После получения результата, выполнение продолжается. В то время, пока выполнение главного потока приостановлено, некоторая другая работа может выполняться параллельно.
Таким образом разница между блокированием и приостановкой заключается в том, что когда поток заблокирован, никакая другая работа не может выполняться (на изображении слева), а когда поток приостановлен любая работа может выполняться параллельно пока результат не будет доступен.
Стоит обратить внимание, что ключевое слово suspend
не означает, что для выполнения функции будет создан отдельный поток. Функции помеченные suspend
выполняются либо на главном потоке, либо в фоновом потоке.
Далее. Для использования корутин в коде, необходимо несколько вещей:
Job
,Dispatcher
,Scope
.1. Job
Job
— это вещь, которую можно завершить и при этом завершится ее жизненный цикл. Все корутины имеют Job и их можно использовать для завершения выполнения корутин. Объекты Job
могут иметь иерархию типа родитель-ребенок, таким образом при завершении родительского Job, его "дети" тоже будут завершены.
2. Dispatcher
Dispatcher
(диспетчер) используется для запуска корутин на разных потоках. Например, диспетчер Dispatchers.Main
запускает корутины на главном потоке выполнения, а диспетчер Dispatchers.IO
используется для выгрузки блокирующих задач ввода/вывода в общий пул потоков.
3. Scope
Scope
объединяет в себе информацию о диспетчере и Job-объекте для определения контекста, в котором корутина запускается. Scope
всегда содержит информацию о корутинах. Scope
позволяет отслеживать выполнение корутин. Когда запускается корутина, она запускается в scope
. Это означает, что вы определяете какой scope
будет отслеживать выполнение корутины.
Для большего понимания концепции корутин рекомендуется ознакомиться с документацией:
Coroutines: https://kotlinlang.org/docs/reference/coroutines-overview.html
Coroutine context and dispatchers: https://kotlinlang.org/docs/reference/coroutines/coroutine-context-and-dispatchers.html
Dispatchers: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/index.html
Job: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/
Перейдем к добавлению корутин в код приложения.
Начать необходимо с класса SleepTrackerViewModel
. Именно этот класс будет заниматься работой с базой данных, управлением DAO и именно здесь необходимо использовать корутины для асинхронной работы с БД.
1. Добавление свойства Job
:
Для управления корутинами необходимо добавить свойство класса Job
в класс.
class SleepTrackerViewModel(
val dao: SleepDatabaseDao,
application: Application) : AndroidViewModel(application) {
private var viewModelJob = Job()
}
2. Завершение корутин, когда ViewModel
уничтожается:
Объект viewModelJob
позволит завершать корутины, которые будут создаваться в данном ViewModel
-классе. Когда ViewModel
будет уничтожен, нам необходимо остановить выполнение всех корутин. Для этого необходимо переопределить метод onCleared()
, и вызвать завершение на объектке viewModelJob
.
override fun onCleared() {
super.onCleared()
viewModelJob.cancel()
}
3. Добавление свойства Scope
:
Далее необходимо добавить свойство uiScope
для запуска корутин. Свойству необходимо знать на каком потоке будет выполняться корутина, и какой объект Job
будет завершать ее выполнение.
Для создания Scope
используется класс CoroutineScope
, принимающий на вход экземпляр диспетчера для главного потока выполнения и объект Job
.
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
Использование главного потока для выполнения корутин в рамках работы с ViewModel
считается подходящим решением, поскольку это удобно для реализации обновления UI после выполнения операций с данными.
4. Объявление свойств с данными:
Далее необходимо объявить свойства класса SleepTrackerViewModel
, которые будут содержать данные.
Во-первых, это поле tonight
, которое будет содержать данные о последнем добавленном (текущем) сне.
Во-вторых, это поле nights
, которое будет содержать список из всех, имеющийся в БД, записях о снах.
private var tonight = MutableLiveData<SleepNight?>()
private val nights = dao.getAllNights()
Свойство tonight
является классом MutableLiveData
. LiveData
— поскольку требуется отслеживать обновления, а Mutable
— поскольку свойство должно быть изменяемым.
Свойство nights
при объявлении инициализируется списком записей, полученных из БД с помощью DAO. Объект SleepDatabaseDao
возвращает список, обернутый в LiveData
, поэтому отдельно объявлять, что свойство nights
является LiveData
нет необходимости. Будут отслеживаться изменения непосредственно в базе данных.
5. Инициализация tonight
с использованием корутин:
Далее необходимо проинициализировать свойство tonight
записью, получаемой из базы данных. Инициализация выполняется в блоке init
.
init {
initializeTonight()
}
private fun initializeTonight() {
uiScope.launch {
tonight.value = getTonightFromDatabase()
}
}
Внутри метода initializeTonight()
используется корутина для получения записи из базы данных без блокирования выполнения главного потока. Для выполнения такой операции требуется Scope
— uiScope
. В рамках этого Scope
запускается корутина с помощью вызова launch
. Запуск корутины создает новую корутину без блокирования текущего потока в контексте, определенном при создании Scope
. Работа корутины будет завершена, когда будет выполнен код, описанный в блоке launch
. В данном случае это получение записи о текущем сне из базы данных и иницализация значения свойства tonight
.
Далее необходимо добавить метод getTonightFromDatabase()
и убедиться, что его выполнение не будет блокировать выполнение потока. Кроме этого метод должен возвращать экземпляр записи БД SleepNight
.
private suspend fun getTonightFromDatabase(): SleepNight? {
return withContext(Dispatchers.IO) {
var night = dao.getTonight()
if (night?.endTimeMillis != night?.startTimeMillis) {
night = null
}
night
}
}
Ключевое слово suspend
позволяет использовать метод внутри корутины и не блокировать при этом поток.
Для получения и возврата записи о текущем сне создается еще одна корутина в контексте ввода-вывода, т.е. с использованием диспетчера Dispatchers.IO
. В рамках данной корутины описывается Scope
-блок с получением записи о сне из БД, проверкой того, что это запись о сне, который идет прямо сейчас, и возврат полученной записи.
6. Добавление обработчика кнопки "Start":
Далее добавим обработчик нажатия на кнопку "Start". По нажатию на эту кнопку нам необходимо создать новый объект SleepNight
, добавить соответствующую запись в БД и присвоить значение новой записи свойству tonight
, т.к. это будет последняя добавленная запись с активным сном. Код метода будет очень похож по структуре на метод инициализации свойства tonight
.
fun onStartTracking() {
uiScope.launch {
val newNight = SleepNight()
insert(newNight)
tonight.value = getTonightFromDatabase()
}
}
В данном методе запускается корутина, поскольку операции с БД занимают длительное время, а здесь сраз две операции описаны: добавление новой записи, получение существующей записи. Здесь также используется uiScope
, поскольку результат выполнения корутины требуется для обновления интерфейса.
В блоке корутины создается новый экземпляр SleepNight
, где по умолчанию время начала сна — это текущее время.
Далее вызывается метод insert()
для добавления новой записи в БД. Метод insert
будет помечен как suspend
, что позволит не блокировать UI-поток.
В конце обновляется значение свойства tonight
за счет получения соответствующей записи из БД.
Далее необходимо определить метод insert()
. По реализации он похож на реализацию getTonightFromDatabase()
.
private suspend fun insert(night: SleepNight) {
withContext(Dispatchers.IO) {
dao.insert(night)
}
}
Метод insert()
— это метод помеченный suspend
. Метод также создает отдельную корутину с использованием диспетчера Dispatchers.IO
и вызывает внутри себя метод DAO insert()
для добавления новой записи в БД.
Метод withContext()
создает новый экземпляр Scope
, используя диспетчер ввода-вывода, и запускает новую корутину.
7. Резюме
Таким образом можно заметить использование одного шаблона. Одни корутины запускаются на UI-потоке (главном потоке) выполнения. Результат выполнения таких корутин влияет на UI.
Внутри вызываются suspend
-функции для выполнения длительных операций без блокирования UI-потока.
Длительные операции (например, операции с БД), никак не относящиеся к UI, выполняются в рамках отдельной корутины в контексте ввода-вывода (Dispatchers.IO
, а не Dispatchers.Main
), где выполнение операций будет оптимизировано.
8. Добавление обработчиков для кнопок "Stop" и "Clear":
Осталось добавить обработчики для кнопок "Stop" и "Clear".
Для обработки кнопки "Stop" определяется метод onStopTracking()
, где по описанному выше шаблону описывается корутина для обновления последней добавленной записи в БД с текущим сном.
Если значение текущего сна не определено и является null
, то дальнейший код не выполняется, а выполняется возврат из блока launch
с помощью return@launch
. Такая конструкция — это возврат по матке. Kotlin позволяет помечать код, и выполнять возврат к меткам (что-то вроде goto
в Си). Использование такого решения не приветствуется в современном программировании, однако, в данном конкретном случае он удобен в использовании в сочетании оператором ?:
.
В конце выполнения корутины выполняется обновление записи в БД с помощью метода update()
.
Метод update()
аналогичен методу insert()
.
fun onStopTracking() {
uiScope.launch {
val oldNight = tonight.value ?: return@launch
oldNight.endTimeMillis = System.currentTimeMillis()
update(oldNight)
}
}
private suspend fun update(night: SleepNight) {
withContext(Dispatchers.IO) {
dao.update(night)
}
}
Для обработки кнопки "Clear" используется метод onClear()
. Метод запускает корутину на главном потоке и выполняет suspend
-метод для очистки БД. В конце метод обнуляет значение свойства tonight
.
Метод clear()
аналогичен методам insert()
и update()
для выполнения операций с БД.
fun onClear() {
uiScope.launch {
clear()
tonight.value = null
}
}
private suspend fun clear() {
withContext(Dispatchers.IO) {
dao.clear()
}
}
9. Установка обработчиков для кнопок:
Установка обработчиков для кнопок выполняется стандартным образом с помощью вызовов setOnClickListener()
.
// SleepTrackerFragment.onCreateView
binding.startButton.setOnClickListener {
viewModel.onStartTracking()
}
binding.stopButton.setOnClickListener {
viewModel.onStopTracking()
}
binding.clearButton.setOnClickListener {
viewModel.onClear()
}
Как указывалось ранее, поле nights
в классе SleepTrackerViewModel
является классом LiveData
, поскольку инициализируется вызовом dao.getAllNights()
, который возвращает список записей БД, обернутый в LiveData
. Таким образом при обновлении данных в БД, будет обновляться и свойство nights
. Таким образом нет необходимости объявлять сеттер для свойства nights
. Room
будет обновлять поле автоматически.
Когда список записей о снах nights
обновляется, необходимо обновить пользовательский интерфейс в соответствии с изменениями. Список nights
содержит объекты класса SleepNight
и когда список обновляется, необходимо отобразить объекты на экране. Для простоты представления данных будем преобразовывать список объектов SleepNight
в строку.
1. Объявление и определение свойства nightsString
:
Строка с записями базы данных объявляется как свойство класса SleepTrackerViewModel
. Для преобразования списка объектов SleepNight
в строку используется класс Transformations
, вызов map()
которого принимает на вход список nights
, а в блоке вызова описываются преобразования. В данном случае преобразования заключаются в формировании и возврате строки с помощью функции formatNights()
. На вход функция принимает список nights
и объект доступа к ресурсам.
val nightsString = Transformations.map(nights) { nights ->
formatNights(nights, application.resources)
}
Поскольку свойство nights
является классом типа LiveData
, то при его обновлении и поле nightsString
будет обновлено.
2. Добавление функции formatNights()
:
Код функции formatNights()
уже добавлен в код приложения в файл Util.kt
. Необходимо лишь раскомментировать его.
Функция возвращает объект Spanned
, который является строкой в формате HTML. Функция формирует такую строку на основании данных из списка объектов SleepNight
и подготовленных строк из ресурсов.
3. Подписка на изменение данных:
В завершении для отображения данных на экране необходимо описать подписку фрагмента SleepTrackerFragment
на изменение свойства nightsString
.
// SleepTrackerFragment.onCreateView()
viewModel.nightsString.observe(viewLifecycleOwner, Observer { nightsString ->
binding.textview.text = nightsString
})
В блоке объекта Observer
описывается установка свойства text
для текстового поля, отображающего данные.
Если собрать проект и запустить приложение, можно убедиться, что по нажатию на кнопку "Start" на экране появляется новая запись о начале сна с датой и временем нажатия на кнопку. А по нажатию на кнопку "Stop" к записи добавляются данные об окончании сна (дата и время), качестве сна (ничего не отображается) и длительности сна (Часы:Минуты:Секунды).
Таким образом для реализации управления данными были выполнены следующие шаги:
ViewModel
добавлены поля Job
и Scope
в контексте главного потока (Dispatchers.Main
) для создания и управления корутинами.tonight
и nights
, содержащие данные.Dispatchers.IO
) для получения данных из базы.ViewModel
, и используют IO-контекст для выполнения операций с базой данных.nightsString
, являющееся строкой с представлением данных, добавленных в базу.nightsString
для обновления текстового поля для отображения текста на экране.Отображение данных из БД в виде строки — это примитивный пример. Такие данные необходимо отображать в специальных компонентах RecyclerView
.
Обновление записей базы данных уже описывалось ранее в обработчике кнопки "Stop" onStopTrackeng()
. В этом же разделе будет рассматриваться обновление информации о качестве сна: поля sleep_quality
таблицы sleep_quality_table
.
Основная идея заключается в том, что по нажатию на кнопку "Stop" должен выполняться переход к фрагменту с оценкой сна SleepQualityFragment
. На фагменте пользователь выбирает оценку, нажимает на нее и переходит обратно к фрагменту SleepTrackerFragment
с записями о снах.
Навигация между фрагментами уже создана и описана в файле navigation.xml
. Не добавлена лишь программная реализация.
Сперва необходимо добавить навигацию к фрагменту оценки сна по нажатию на кнопку "Stop". Для этого необходимо:
SleepTrackerViewModel
свойство, изменение которого будет событием для открытия фрагмента SleepQualityFragment
.SleepQualityFragment
.1. Добавление свойства navigateToSleepQuality
:
Подобно тому, как это делалось ранее, добавляется свойство-событие navigateToSleepQuality
. Изменение этого свойства будет индикатором того, что необходимо перейти к фрагменту оценки сна.
private val _navigateToSleepQuality = MutableLiveData<SleepNight>()
val navigateToSleepQuality: LiveData<SleepNight>
get() = _navigateToSleepQuality
Как и в предыдущем уроке, свойство инкапсулируется с помощью приватного изменяемого свойства _navigateToSleepQuality
и переопределения геттера get()
.
Кроме этого для сброса значения свойства (чтобы не выполнялись ложные срабатывания) объявляется метод doneNavigating()
:
fun doneNavigating() {
_navigateToSleepQuality.value = null
}
2. Установка свойства navigateToSleepQuality
:
Поскольку именно по нажатию на кнопку "Stop" должен выполняться переход к фрагменту оценки сна, то установка свойства-события добавляется именно в метод обработчик нажатия на кнопку onStopTracking()
.
fun onStopTracking() {
uiScope.launch {
val oldNight = tonight.value ?: return@launch
oldNight.endTimeMillis = System.currentTimeMillis()
update(oldNight)
_navigateToSleepQuality.value = oldNight
}
}
3. Подписка на изменение свойства navigateToSleepQuality
:
В класс SleepTrackerFragment
добавляется подписка на изменение свойства navigateToSleepQuality
. Обработчик изменения описывает вызов метода navigate()
для перехода к фрагменту SleepQualityFragment
с передачей ему идентификатора записи SleepNight
в качестве аргумента. В конце сбрасывается значение свойства методом doneNavigating()
.
// SleepTrackerFragment.onCreateView()
viewModel.navigateToSleepQuality.observe(viewLifecycleOwner, Observer { night ->
if (night != null) {
this.findNavController().navigate(
SleepTrackerFragmentDirections
.actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
viewModel.doneNavigating()
}
})
Если запустить приложение, то можно убедиться, что после нажатия на кнопку "Stop" выполняется переход к фрагменту оценки сна, т.е. добавленный код работает правильно. По нажатию на системную кнопку "Назад" выполняется переход к фрагменту со списком записей. Далее требуется добавить обработку нажатия на кнопки на фрагменте оценки сна.
Для реализации обновления значения качества в записи базы данных необходимо:
SleepQualityViewModel
с методом для обновления данных с помощью DAO и свойством-событием для возврата к экрану со списком записей.SleepQualityViewModelFactory
для создания экземпляра SleepQualityViewModel
с помощью конструктора с параметрами.SleepQualityFragment
.1. Реализация класса SleepQualityViewModel
:
Реализация класса SleepQualityViewModel
похожа на реализацию класса SleepTrackerViewModel
, описанного выше.
Для работы с корутинами также добавляются свойства Job
и Scope
, а также переопределение метода onCleared()
.
class SleepQualityViewModel(
private val nightKey: Long = 0L,
private val dao: SleepDatabaseDao) : ViewModel() {
private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
override fun onCleared() {
super.onCleared()
viewModelJob.cancel()
}
}
Для навигации к фрагменту SleepTrackerFragment
точно также необходимо добавить свойство-событие navigateToSleepTracker
с инкапсуляцией приватным свойством и с методом doneNavigating()
для сброса значения свойства.
private val _navigateToSleepTracker = MutableLiveData<Boolean>(false)
val navigateToSleepTracker: LiveData<Boolean?>
get() = _navigateToSleepTracker
fun doneNavigating() {
_navigateToSleepTracker.value = false
}
Для обработки нажатия на кнопки для оценки, добавляется метод onSetSleepQuality()
. Реализация метода похожа на реализацию метода onStopTracking()
. Отличие в том, что здесь обновляется значение sleepQuality
.
fun onSetSleepQuality(quality: Int) {
uiScope.launch {
withContext(Dispatchers.IO) {
val tonight = dao.get(nightKey) ?: return@withContext
tonight.sleepQuality = quality
dao.update(tonight)
}
_navigateToSleepTracker.value = true
}
}
Таким образом класс SleepQualityViewModel
готов к работе. Он позволяет выполнять обновление данных в БД и информировать фрагмент о том, что необходимо выполнить переход к предыдущему фрагменту.
2. Реализация класса SleepQualityViewModelFragment
:
Конструктор класс SleepQualityViewModel
имеет два параметра, а значит для создания экземпляра такого класса необходим factory-класс SleepQualityViewModelFactory
.
class SleepQualityViewModelFactory(
private val sleepNightKey: Long,
private val dao: SleepDatabaseDao) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SleepQualityViewModel::class.java)) {
return SleepQualityViewModel(sleepNightKey, dao) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Реализация factory-класса аналогична реализации такого же класса для SleepTrackerViewModel
.
3. Реализация обновления данных в SleepQualityFragment
:
Для реализации обновления данных в фрагменте оценки сна необходимо инициализировать объект ViewModel
, добавить обработчик для кнопок оценки сна и реализовать переход на фрагмент со списком записей после выбора оценки.
Для начала необходимо проинициализировать ViewModel
, а для этого необходимо сперва получить передаваемые данные, инициализировать объект DAO и factory-объект.
// SleepQualityFragment.onCreateView()
val args = SleepQualityFragmentArgs.fromBundle(requireArguments())
val dao = SleepDatabase.getInstance(application).getSleepDatabaseDao()
val viewModelFactory = SleepQualityViewModelFactory(args.sleepNightKey, dao)
val viewModel = ViewModelProvider(this, viewModelFactory)
.get(SleepQualityViewModel::class.java)
На фрагменте располагаются шесть кнопок (компонентов ImageView
) для оценки сна. Для обработки нажатия необходимо также назначить обработчик для события onClick
.
Чтобы не устанавливать для каждой кнопки обработчик вручную и не дублировать код, кнопки объединяются в список, а обработчики устанавливаются в цикле. Числовое значение оценки в данном случае равняется индексу кнопки в списке, что очень удобно.
// SleepQualityFragment.onCreateView()
val buttons = arrayListOf(binding.qualityZeroImage, binding.qualityOneImage,
binding.qualityTwoImage, binding.qualityThreeImage,
binding.qualityFourImage, binding.qualityFiveImage)
for ((index, button) in buttons.withIndex()) {
button.setOnClickListener {
viewModel.onSetSleepQuality(index)
}
}
Для обработки изменения свойства-события navigateToSleepTracker
добавляется подписка на изменения свойства. Если свойство имеет значение true
, то выполняется переход к фрагменту SleepTrackerFragment
и сброс значения свойства.
// SleepQualityFragment.onCreateView()
viewModel.navigateToSleepTracker.observe(viewLifecycleOwner, Observer { shouldNavigate ->
if (shouldNavigate) {
this.findNavController().navigate(SleepQualityFragmentDirections
.actionSleepQualityFragmentToSleepTrackerFragment())
viewModel.doneNavigating()
}
})
Если запустить приложение, нажать "Start" и затем "Stop", то откроется фрагмент оценки сна, позволяющий выбрать оценку для сна. После выбора оценки выполняется переход обратно к фрагменту со списком записей, где у последней записи в поле "Quality" отображается текстовое представление оценки сна.
На текущем этапе приложение выполняет свою основную функцию — создает записи о сне и позволяет указывать значение качества сна.
Единственный недочет заключается в том, что все кнопки всегда отображаются на экране. Хотя, когда записей в базе данных нет, то нет и необходимости отображать кнопку "Clear", а когда сон еще отслеживается, нет необходимость отображать кнопку "Start" для запуска отслеживания сна.
Самостоятельное упражнение заключается в реализации изменения отображения кнопок в соответствии с состоянием данных в базе.
Если база данных пуста, кнопка "Clear" не должна отображаться.
Если отслеживание сна еще не запущено, т.е. нет записи с текущим сном и без времени окончания, то кнопка "Stop" не должна отображаться.
Если отслеживание сна уже запущено, т.е. пользователь нажал "Start" и в БД добавлена запись о текущем сне, то кнопка "Start" отображаться не должна.