Здравствуй, дорогой читатель. Каждый Android-разработчик сталкивался (или столкнётся во время своей профессиональной карьеры) с задачей, в которой необходимо хранить большое количество изменяемых данных. В данной статье будет разобрана библиотека от Google - Room.
В статье будет рассказано об основных компонентах библиотеки и будет разобран базовый, не очень сложный пример.
Статья предназначена для новичков, не знакомых с данной библиотекой, но, желательно, имеющих базовые знаниях о SQLite, Kotlin Coroutines, Kotlin Flow, MVVM.
Все материалы и исходный код можно найти здесь.
Теоретическая часть
Для хранения каких-либо данных, Android-разработчику предоставлены следующие способы: Files, SharedPreferences, SQLite, Resources/Assets. Но какой способ выбрать? Для ответа на данный вопрос можно воспользоваться алгоритмом, изображенном на рисунке ниже.
В случае, если данные, которые мы будем хранить, могут изменяться и имеют не простую структуру, следует выбрать SQLite.
SQLite - это реляционная база данных, в которой все данные хранятся в таблицах, которые в свою очередь могут быть связаны между собой. Для взаимодействия с базой данных используется специальный язык запросов - SQL. В случае, если Вы незнакомы с данной реляционной базой данных, - рекомендую данный источник.
Один из способов для работы с SQLite в Android - это, встроенный в Android SDK, SQL API. Данное API позволяет работать с базой данных, но, по-моему мнению, данная технология далеко не простая, а в некоторых моментах даже сложная. В данной статье мы не будем разбирать данную технологию, а сразу перейдем к библиотеке "Room".
Room - это библиотека, представленная на Google I/O в 2017 году. Данная библиотека работает с базой данный SQLite и выполняет большую часть работы за Вас. Все что необходимо разработчику - это "объяснить" библиотеке как выглядят данные, их структуру и способы взаимодействия с помощью специальных аннотаций:
@Database
- аннотация для объявления базы данных.@Entity
- аннотация для объявления сущности базы данных.@Dao
- аннотация для объявления интерфейса, который будет заниматься манипулированием данными базы данных.@PrimaryKey
- аннотация для объявления первичного ключа сущности.@ColumnInfo
- аннотация для настроек конкретного столбца сущности.@Query
- аннотация, которая позволяет выполнить SQL-запрос в методах DAO-интерфейса.@Insert
- аннотация, которая позволяет выполнить вставку в таблицу базы данных.@Update
- аннотация, которая позволяет выполнить обновление некоторых строк в таблице базы данных.@Delete
- аннотация, которая позволяет выполнить удаление некоторых строк в таблице базы данных.@Transaction
- аннотация, которая помечает метод в DAO-интерфейсе как транзакция.
Это далеко не все аннотации, которые предоставляет "Room", но являющиеся основными. Более подробно аннотации будут рассмотрены в практическом примере.
Также следует отметить, что существуют специальные Tuple-классы, которые никак не помечаются, но являются важной частью при разработке. Данные классы используются при взаимодействии с базой данных (например, когда нам необходимо получить какую-то часть данных из таблицы, а не все данные сразу). Более подробно Tuple-классы будут рассмотрены в практическом примере.
Практическая часть
В качестве не сложного примера, создадим приложение, которое будет "имитировать" создание и отображение статистических данных какой-то игры (например, судоку). Приложение будет состоять из двух экранов: первый - заполнение и отправка данных в базу; второй - список со всеми данными из базы.
Статистические данные будут состоять из следующих компонентов: результат игры (победа / поражение), уровень сложности (легкая, сложная и т.д.), количество ошибок, количество набранных очков.
Важное уточнение. В данной статье не будут приведены листинги кода с версткой xml-файлов и всех классов приложения. Полностью готовый проект можно найти здесь.
В первую очередь необходимо указать все зависимости, которые будут использованы приложением. Для этого в файл сборки build.gradle
нашего приложения:
dependencies {
...
implementation 'androidx.room:room-runtime:2.5.0' // Библиотека "Room"
kapt "androidx.room:room-compiler:2.5.0" // Кодогенератор
implementation 'androidx.room:room-ktx:2.5.0' // Дополнительно для Kotlin Coroutines, Kotlin Flows
}
plugins {
...
id 'kotlin-kapt'
}
android {
...
defaultConfig {
...
kapt {
arguments {arg("room.schemaLocation", "$projectDir/schemas")}
}
}
}
Данные блоки кода подключают нужные библиотеки (первый блок), добавляют нужный плагин (второй блок) и указывают нужный путь к каталогу, который будет хранить схему нашей базы данных (третий блок).
Перед тем как перейти к описанию сущностей, необходимо представить как база данных будет выглядеть, из каких таблиц она будет состоять. В данном практическом примере база данных будет состоять из трех таблиц:
таблица "difficulty_levels", в которой будут хранится все доступные уровни сложности;
таблица "results", в которой будут хранится все доступные результаты игры;
таблица "statistic", в которой будут хранится все статистические данные.
Схематично база данных будет выглядеть следующим образом:
SQL-скрипт для такой базы данных выглядел бы следующим образом:
CREATE TABLE difficulty_levels(
id INTEGER PRIMARY KEY,
difficulty_name TEXT
);
CREATE TABLE results (
id INTEGER PRIMARY KEY,
result_name TEXT
);
CREATE TABLE statistic (
id INTEGER PRIMARY KEY,
result_id INTEGER,
difficult_id INTEGER,
mistakes INTEGER,
points INTEGER,
FOREIGN KEY (result_id) REFERENCES results(id),
FOREIGN KEY (difficult_id) REFERENCES difficulty_levels(id)
);
Примечание: никогда не храните секретные данные (например, пароли) в открытом виде. Всегда хэшируйте их!
После того, как была разобрана схема базы данных, необходимо перейти к созданию сущностей. Создадим data-class DifficultyLevelsDbEntity
, который будет описывать таблицу "difficulty_levels":
@Entity(tableName = "difficulty_levels")
data class DifficultyLevelsDbEntity(
@PrimaryKey val id: Long,
@ColumnInfo(name = "difficulty_name") val difficultyName: String
)
В данном случае мы пометили класс аннотацией @Entity
, в которой переопределили свойство tableName - данное свойство задаёт имя таблицы. В случае, если бы свойство не было бы определенно, то таблица назвалась аналогично названию класса, т.е. DifficultyLevelsDbEntity.
Также поля класса были помечены аннотациями @PrimaryKey
и @ColumnInfo
. Первая аннотация помечает поле класса, как первичный ключ, а вторая задаёт название столбца отличное от названии переменной. У аннотации @ColumnInfo
есть и другие свойства, например значение по умолчанию, более подробно про свойства данной аннотации можно узнать здесь.
Аналогично создадим класс ReultsDbEntity
:
@Entity(tableName = "results")
data class ResultsDbEntity(
@PrimaryKey val id: Long,
@ColumnInfo(name = "result_name") val resultName: String
)
Теперь перейдем к созданию более сложной сущности - StatisticDbEntity
:
@Entity(
tableName = "statistic",
indices = [Index("id")],
foreignKeys = [
ForeignKey(
entity = ResultsDbEntity::class,
parentColumns = ["id"],
childColumns = ["result_id"]
),
ForeignKey(
entity = DifficultyLevelsDbEntity::class,
parentColumns = ["id"],
childColumns = ["difficult_id"]
)
]
)
data class StatisticDbEntity(
@PrimaryKey(autoGenerate = true) val id: Long,
@ColumnInfo(name = "result_id") val resultId: Long,
@ColumnInfo(name = "difficult_id") val difficultId: Long,
val mistakes: Long,
val points: Long
)
Аннотации в свойствах data-class'а очень похожи на те, что были созданы ранее. Единственное отличие - в @PrimaryKey
было определено свойство autoGenerate = true. Данное свойство "объясняет" библиотеке, что при вставке нового объекта в таблицу, необходимо сгенерировать индекс самостоятельно. Например, в таблице находится пять элементов, при вставке нового элемента поле id автоматически станет равно шести.
Также в аннотации @Entity
было определенно больше свойств. Свойство indices "объясняет" библиотеки по каком полю производить индексацию, в данном случае - id.
Свойство foreignKeys объявляет составные ключи. В данном случае составных ключа два - result_id и difficult_id. В объекте ForeignKey указываются сущность-родитель (entity), столбец-родитель (parentColumns) и столбец-ребенок (childColumns).
После того как были созданы все сущности базы данных, создадим интерфейс, помеченный аннотацией @Dao
. Данный интерфейс будет взаимодействовать с базой данных с помощью специальных методов. Пока оставим данный интерфейс пустым, к его реализации следует вернуться чуть позже:
@Dao
interface StatisticDao {
}
Когда сущности были созданы, dao-интерфейс объявлен необходимо создать абстрактный класс AppDatabase
, который будет описывать базу данных:
@Database(
version = 1,
entities = [
DifficultyLevelsDbEntity::class,
ResultsDbEntity::class,
StatisticDbEntity::class
]
)
abstract class AppDatabase : RoomDatabase() {
abstract fun getStatisticDao(): StatisticDao
}
Данный класс помечен аннотацией @Database
, в которой необходимо обязательно описать два свойства: entities и version. Первое свойство принимает все сущности, которые были описаны выше, а второе свойство задаёт версию базы данных.
Версия базы данных используется для контроля базы данных, ее данных и т.д. Зачастую это используется при миграции базы данных с одной версии на другую, но это уже совсем другая история...
В самом классе создан абстрактный метод, который возвращает dao-интерфейс.
Так же создадим tupple-класс StatisticInfoTuple
, который будет использоваться при "вытягивании" статистических данных из таблицы. Данный класс очень похож на StatisticDbEntity, но поля, которые хранят результат и уровень сложности, уже имеют тип String, так как там будут хранится значения результата и уровня сложности.
data class StatisticInfoTuple(
val id: Long,
@ColumnInfo(name = "result_name") val result: String,
@ColumnInfo(name = "difficulty_name") val difficult: String,
val mistakes: Long,
val points: Lon
После всех проделанных действий необходимо перейти к реализации dao-интерфейса. В данном интерфейсе создадим три метода, которые будут вставлять новые статистические данные, удалять данные по уникальному значению (id) и получать список всех данных из таблицы statistic:
@Insert(entity = StatisticDbEntity::class)
fun insertNewStatisticData(statistic: StatisticDbEntity)
@Query("SELECT statistic.id, result_name, difficulty_name, mistakes, points FROM statistic\n" +
"INNER JOIN results ON statistic.result_id = results.id\n" +
"INNER JOIN difficulty_levels ON statistic.difficult_id = difficulty_levels.id;")
fun getAllStatisticData(): List<StatisticInfoTuple>
@Query("DELETE FROM statistic WHERE id = :statisticId")
fun deleteStatisticDataById(statisticId: Long)
Метод insertNewStatisticData принимает объект класса StatisticDbEntitty - объект, который необходимо вставить. Также данный метод помечен аннотацией @Insert
, в которой определено свойство entity, благодаря которому происходит вставка в нужную таблицу.
Методы getAllStatisticData и deleteStatisticDataById помечены аннотацией @Query
, которая принимает строку с SQL-запросом. Именно благодаря данному запросу выполняется получение всех элементов или удаление какого-то конкретного элемента.
И последнее, что необходимо сделать с базой данной - создать ее. Для этого выполним следующее:
object Dependencies {
private lateinit var applicationContext: Context
fun init(context: Context) {
applicationContext = context
}
private val appDatabase: AppDatabase by lazy {
Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database.db")
.createFromAsset("room_article.db")
.build()
}
}
В данном примере создается база данных appDatabase с помощью специального билдера: Room.databaseBuilder
, который принимает контекст, класс, содержащий описание нашей базы данных, и название самой базы.
Так же у билдера вызван метод createFromAssets
, данный метод заполняет базу данных приготовленными значениями. Т.е., если необходимо, чтобы при инициализации база данных хранила в себе какие-либо значения (например, уровни сложности и доступные результаты), нужно создать отдельно базу данных с помощью сторонних программ, таких как DB Browser for SQLite, заполнить ее и сохранить ее в папку assets приложения.
После того, как все манипуляции с базой данных были реализованы, необходимо создать data-класс Statistic
, который будет использоваться во всем приложении. В данном классе создан метод toStatisticDbEntity
, конвертирующий данный класс в сущность:
data class Statistic(
val resultId: Long,
val difficultId: Long,
val mistakes: Long,
val points: Long
) {
fun toStatisticDbEntity(): StatisticDbEntity = StatisticDbEntity(
id = 0,
resultId = resultId,
difficultId = difficultId,
mistakes = mistakes,
points = points
)
}
Теперь необходимо создать репозиторий, который будет обращаться к dao-интерфейсу и манипулировать данными базы данных:
class StatisticRepository(private val statisticDao: StatisticDao) {
suspend fun insertNewStatisticData(statisticDbEntity: StatisticDbEntity) {
withContext(Dispatchers.IO) {
statisticDao.insertNewStatisticData(statisticDbEntity)
}
}
suspend fun getAllStatisticData(): List<StatisticInfoTuple> {
return withContext(Dispatchers.IO) {
return@withContext statisticDao.getAllStatisticData()
}
}
suspend fun removeStatisticDataById(id: Long) {
withContext(Dispatchers.IO) {
statisticDao.deleteStatisticDataById(id)
}
}
}
В данном репозитории три метода, которые вставляют новые данные, получают всю статистику и удаляют какой-то элемент по идентификатору. Все эти методы являются suspend-функциями, т.к. будут вызываться из корутин. Так же следует изменить Dispatcher (withContext), т.к. обращаться к базе данных из основного потока нельзя.
Все эти методы вызываются в корутинах, запущенных во ViewModel, например вставка нового значения:
fun insertNewStatisticDataInDatabase(mistakes: Long, points: Long) {
viewModelScope.launch {
val newStatistic = Statistic(currentResult, currentDifficultyLevel, mistakes, points)
statisticRepository.insertNewStatisticData(newStatistic.toStatisticDbEntity())
}
}
Весь рабочий код Вы можете найти здесь.
Рекомендованные источники
В данной статье были разобраны основы библиотеки. Естественно, это далеко не все, что позволяет делать "Room". Если Вы хотите сильнее углубиться в эту тему изучите следующие источники, которые могут помочь Вам:
MUTbKA98
Вот всегда дико интересовало, ну неужели вся эта адова синтаксическая мишура, обмазанная декораторами в 7 слоев, чем-то удобнее, чем просто написать чуток очень простого SQL, и несколько несложных запросов для записи и чтения?
Итог же чем-то напоминает чтение некачественного перевода - зачастую, чтобы понять, что ж написано, требуется совершить "обратный перевод", чтобы восстановить исходный текст, и уже его постигать - и примерно так же и тут.
Rusrst
Вы видимо никогда не работали с sql в Android, room на пару порядков удобнее.
MUTbKA98
В Android да - как раз только что ознакомился, и испытал сильнейшую душевную боль. "Зачем делать сложным то, что проще простого" (c) Нау.
И я верю, что этот cамый Room - это облегчение.