вторник, 6 марта 2018 г.

Язык Котлин. Часть 4. Архитектурные компоненты. Room, работа с БД.

Платформа Андроид до 2017-го года страдала полным отсутствием штатных высокоуровневых компонентов, которые позволяли бы выполнять рутинные операции по работе с базой данных, не городить огород из костылей для адекватной обработки поворотов экрана и т.п. Вообще, складывается ощущение, что у Гугла нет главного архитектора, который бы спроектировал удобную библиотечную среду для комфортного написания программ. Программирование под Андройд всегда сопровождалось и сопровождается постоянными спотыканиями на ровном месте. Скажем прямо — среда продумана плохо.

Тем не менее, ситуация немного сдвигается с мёртвой точки. В мае 2017 года Гуглом были представлены некоторые архитектурные компоненты, призванные попробовать решить часть вышеописанных проблем. Конечно, это только первый шаг, поскольку всё ещё остаётся масса вопросов по их применению в реальных приложениях в случае, когда приложения эти чуть отличаются от упрощённых синтетических примеров. То ли так плохо спроектирована сама среда, то ли они пустили всё на самотёк, науке это неизвестно, но программировать под Андроид даже с новыми компонентами всё ещё очень сложно.

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

Room

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

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

Для работы с базой данных достаточно подключить библиотеку в build.gradle модуля в разделе dependencies:

    // Room
    implementation "android.arch.persistence.room:runtime:$room_version"
    kapt "android.arch.persistence.room:compiler:$room_version"

где $room_version прописана в build.gradle проекта в разделе buildscript, например:

    ext {
        kotlin_version = '1.2.30'
        room_version = '1.1.0-alpha3'
        coroutines_version = '0.22.2'
        support_version = '27.1.0'
    }

Теперь создаём файл, например, DB.kt, в котором прописываем два класса и один интерфейс. Один класс описывает таблицу, второй — базу, в которой эта таблица содержится, а интерфейс описывает запросы, которые будут сформированы к этой базе. В качестве примеров я буду демонстрировать реальный код моего приложения по учёту показаний счётчиков ЖКХ.

Класс, описывающий таблицу (я добавил сюда дополнительный класс конвертера для поля даты, пусть он вас не смущает):

@Entity
class ReadoutTable(
        @ColumnInfo var addr: String?,  // String? - Поля могут иметь значения NULL
        @ColumnInfo var hvs: String?,
        @ColumnInfo var gvs: String?,
        @ColumnInfo var t1: String?,
        @ColumnInfo var t2: String?,
        @ColumnInfo var t3: String?,
        @ColumnInfo var comment: String?,
        @field:TypeConverters(DateConverter::class)   // Добавим дополнительные преобразователи типов (а именно, написанный нами класс DateConverter),                                                  
        @ColumnInfo var curDate: Date?                // которые может использовать поле даты. "field:" - применение аннотации TypeConverters к полю.
){

    @PrimaryKey(autoGenerate = true) var id: Long = 0 // Long - без вопросительного знака, значит поле NOT NULL
}

/*
 * Класс конвертеров Long в Date и обратно для TypeConverters() в Room, чтобы автоматически сохранять в базе типы, на которые она не рассчитана
 * Та или иная функция выбирается автоматически исходя из входного типа данных
 */
class DateConverter {
    @TypeConverter  // Указать, что метод является конвертером
    fun toDate(timestamp: Long?) = timestamp?.let { Date(it) }
    @TypeConverter  // Указать, что метод является конвертером
    fun toTimestamp(date: Date?) = date?.time
}

Класс, описывающий взаимодействие с базой:

/*
 * Интерфейс доступа к базе данных (DAO - Data Access Object)
 */
@Dao
@TypeConverters(DateConverter::class)                   // Указываем, что при доступе к некоторым полям будет задействован наш конвертер DateConverter
interface ReadoutModelDao {

    @get:Query("select * from ReadoutTable")            // "get:" означает применение аннотации "Query" к геттеру (функцию геттера для переменной allReadoutItems вручную не пишем)
    val allReadoutItems: List<ReadoutTable>             // Обёртываем возвращаемое значение LiveData<...> чтобы отслеживать изменения в базе. При изменении данных будут рассылаться уведомления

    @Query("select * from ReadoutTable where id = :id")
    fun getReadoutById(id: Long): ReadoutTable

    @Query("select * from ReadoutTable where addr = :addr")
    fun getReadoutByAddr(addr: String): List<ReadoutTable>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun addReadout(readoutModel: ReadoutTable)

    @Update
    fun updateReadout(readoutModel: ReadoutTable)

    @Delete
    fun deleteReadout(readoutModel: ReadoutTable)
}

Класс, описывающий саму базу данных. Достаточно одной строчки внутри класса, а всё остальное, что внутри объекта companion, добавлено мной для реализации синглтона (чтобы при запросе базы больше одного объекта базы не создавалось) и двух апдейтов базы (напомню, что это код из реального приложения):

/*
 * Описание базы данных.
 */
@Database(entities = [ReadoutTable::class], version = 3)  // Перечисляем в entities, какие классы будут использоваться для создания таблиц.
abstract class ZhkhDatabase : RoomDatabase() {

    abstract fun readoutDAO(): ReadoutModelDao           // Описываем абстрактные методы для получения объектов интерфейса BorrowModelDao, которые вам понадобятся

    // Сопутствующий объект для получения базы данных (фактически синглтон). Можно не использовать.
    companion object {

        private var INSTANCE: ZhkhDatabase? = null

        fun getDatabase(context: Context): ZhkhDatabase? {
            if (INSTANCE == null) {
                INSTANCE = Room.databaseBuilder<zhkhdatabase>(context, ZhkhDatabase::class.java, "readout_db")
                        .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                        .build()
            }
            return INSTANCE
        }

        fun destroyInstance() {
            INSTANCE = null
        }

        // Апдейт базы с 1 на 2 версию, добавление поля
        val MIGRATION_1_2: Migration = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE ReadoutTable ADD COLUMN comment TEXT NOT NULL DEFAULT ''")
            }
        }

        // Апдейт базы с 2 на 3 версию, переделка всех полей из NOT NULL в NULL
        val MIGRATION_2_3: Migration = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE ReadoutTable RENAME TO ReadoutTable_old")
                database.execSQL("CREATE TABLE ReadoutTable (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 0, addr TEXT NULL, hvs TEXT NULL, gvs TEXT NULL, t1 TEXT NULL, t2 TEXT NULL, t3 TEXT NULL, comment TEXT NULL, curDate INTEGER)")
                database.execSQL("INSERT INTO ReadoutTable(id, addr, hvs, gvs, t1, t2, t3, comment, curDate) SELECT id, addr, hvs, gvs, t1, t2, t3, comment, curDate FROM ReadoutTable_old")
                database.execSQL("DROP TABLE ReadoutTable_old")
            }
        }
    }
}

Получение объекта базы данных будет выглядеть так:

val zhkhDatabase = ZhkhDatabase.getDatabase(this.getApplication())  // Создать или получить базу данных

И теперь читаем набор данных из таблицы (вернётся только одна запись с id = 0, а из неё читаем поле addr) с помощью прописанной в интерфейсе БД функции. Поле addr выводим в интерфейс:

val r = async(CommonPool) {                           // Асинхронно считаем данные из базы. Получаем запись с идентификатором ноль.
    zhkhDatabase?.readoutDAO()?.getReadoutById(0)     // Считаем нулевую запись. Результат последнего выражения потока возвращается в переменную r
}
launch (UI) {                                         // Запустить и забыть. Поток в потоке интерфейса UI. Ждём данные и выводим их в интерфейс.
    var rec = r.await()                               // Подождём результата.
    editText.setText(rec?.addr)                       // Выведем в интерфейс.
}

Апдейт нулевой записи:

var gorod = etAddr.text.toString()
launch(CommonPool) {
    val rec = zhkhDatabase?.readoutDAO()?.getReadoutById(0) // Асинхронно считаем данные из базы. Получаем запись с идентификатором ноль.
    if (rec != null) {
        rec.addr = gorod                                    // Заносим новые данные в поле адреса.
        zhkhDatabase?.readoutDAO()?.updateReadout(rec)      // Обновляем запись в базе.
    }                                    
}

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

Следующий вопрос, который сразу встаёт, это как осуществить автосинхронизацию данных в таблице БД и в интерфейсе. А вот для этого Room умеет возвращать свои записи не только в голом виде в виде простых списков, но и внутри специального класса, который рассылает сообщения своим подписчикам при изменении заключенных в него данных. Возвращаемые данные внутри такого класса становятся наблюдаемыми. Для этого применяется ещё один архитектурный компонент LiveData.

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

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

Комментариев нет:

Отправить комментарий