Здравствуй, дорогой читатель. Каждый Android-разработчик сталкивался (или столкнётся во время своей профессиональной карьеры) с задачей, в которой необходимо хранить большое количество изменяемых данных. В данной статье будет разобрана библиотека от Google - Room.

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

Статья предназначена для новичков, не знакомых с данной библиотекой, но, желательно, имеющих базовые знаниях о SQLite, Kotlin Coroutines, Kotlin Flow, MVVM.

Готовый практический пример, который будет разобран в статье.
Готовый практический пример, который будет разобран в статье.

Все материалы и исходный код можно найти здесь.

Теоретическая часть

Для хранения каких-либо данных, Android-разработчику предоставлены следующие способы: Files, SharedPreferences, SQLite, Resources/Assets. Но какой способ выбрать? Для ответа на данный вопрос можно воспользоваться алгоритмом, изображенном на рисунке ниже.

Алгоритм выбора правильного способа хранения данных в Android.
Алгоритм выбора правильного способа хранения данных в Android.

В случае, если данные, которые мы будем хранить, могут изменяться и имеют не простую структуру, следует выбрать 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". Если Вы хотите сильнее углубиться в эту тему изучите следующие источники, которые могут помочь Вам:

Комментарии (3)


  1. MUTbKA98
    30.01.2023 15:48
    +1

    Вот всегда дико интересовало, ну неужели вся эта адова синтаксическая мишура, обмазанная декораторами в 7 слоев, чем-то удобнее, чем просто написать чуток очень простого SQL, и несколько несложных запросов для записи и чтения?

    Итог же чем-то напоминает чтение некачественного перевода - зачастую, чтобы понять, что ж написано, требуется совершить "обратный перевод", чтобы восстановить исходный текст, и уже его постигать - и примерно так же и тут.


    1. Rusrst
      30.01.2023 16:52
      -1

      Вы видимо никогда не работали с sql в Android, room на пару порядков удобнее.


      1. MUTbKA98
        30.01.2023 17:22

        В Android да - как раз только что ознакомился, и испытал сильнейшую душевную боль. "Зачем делать сложным то, что проще простого" (c) Нау.

        И я верю, что этот cамый Room - это облегчение.