Kotlin multiplatform (KMP), ранее известный как KMM, является интересной темой для многих Android-разработчиков, стремящихся обеспечить работу своих приложений на других платформах, например iOS.
Но одной из первых проблем, с которой они сталкиваются, являются популярные библиотеки, используемые почти в каждом Android-приложении, которые, как выясняется, не поддерживают KMP, и которые необходимо заменить на чисто Kotlin-библиотеки, чтобы начать свое путешествие по KMP. Одной из таких библиотек является Room, которая используется для простого управления локальной базой данных.
Поэтому в данном случае единственным вариантом является переход на другую библиотеку, и одним из хороших вариантов является SQLDelight.
Эта статья — ваше руководство по миграции продакшен приложения с реальной локальной базой данных с Room на SQLDelight без потери сохраненных данных.
Я исхожу из реального опыта, полученного в моем проекте с открытым исходным кодом Prayer Companion.
1. Внедрение SQLDelight
Прежде всего, необходимо внедрить в проект зависимости SQLDelight.
В приложении build.gradle.kts добавьте следующие строки (обратитесь к документации по SQLDelight для получения правильных версий):
plugins { id("app.cash.sqldelight") version "2.0.0" } dependencies { implementation("app.cash.sqldelight:android-driver:2.0.0") implementation("app.cash.sqldelight:coroutines-extensions:2.0.0") }
Затем синхронизируйте проект и в разделе dependencies
добавьте следующий код для инициирования класса Database, имя базы данных может быть любым подходящим, это будет имя сгенерированного класса, с которым вы будете взаимодействовать в своем коде, а затем имя пакета вашего приложения
Примечание: name
— это не имя файла базы данных, оно будет задано позже.
sqldelight { databases { create("MyAppDatabase") { packageName.set("com.domain.app") } } }
Имя пакета можно найти в том же файле класса, что и значение applicationId
в блоке defaultConfig
2. Создание таблиц
SQLDelight использует другой способ создания таблиц, чем Room, вместо создания класса Entity и автогенерации таблицы, здесь вам придется писать SQL-код самостоятельно, но он прост и предлагает автодополнение.
К счастью, переход из Room упрощает задачу, у нас уже где-то сгенерирована схема создания таблиц, чтобы найти ее, найдите в своем проекте класс с именем [имя класса вашей базы данных]_Impl.java
, имя класса базы данных — это класс, который наследует RoomDatabase()
в вашем проекте.
Чтобы увидеть сгенерированный класс, необходимо сначала собрать проект, он будет находиться в каталоге java (generated)
:
Открыв сгенерированный класс, вы найдете все схемы создания таблиц. В моем случае вот что я вижу для своего проекта:
Убедитесь, что схемы в SQLDelight написаны точно так же, как и в Room, иначе, скорее всего, возникнут ошибки.
Теперь, чтобы написать схему создания для SQLDelight, откройте проект, затем в app -> src -> main
создайте новый каталог sqldelight
, а внутри него — каталог tables
:
В каталоге tables
создайте для каждой таблицы новый файл с расширением .sq
.
Этот файл будет предназначен для создания таблицы и запросов к ней. Например, файл с именем my_table.sq
будет выглядеть следующим образом:
CREATE TABLE PrayersInfo ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, prayer TEXT NOT NULL, dateTime TEXT NOT NULL, status TEXT ); insert: INSERT INTO PrayersInfo VALUES(:id,:prayer,:dateTime,:status); getPrayers: SELECT * FROM PrayersInfo WHERE dateTime >= :startDateTime AND dateTime <= :endDateTime; getPrayer: SELECT * FROM PrayersInfo WHERE prayer = :prayer AND dateTime >= :startOfDay AND dateTime <= :endOfDay; getPrayersStatusesByDate: SELECT status FROM PrayersInfo WHERE dateTime >= :startDateTime AND dateTime <= :endDateTime AND prayer != :excludedPrayer; updatePrayerStatus: UPDATE PrayersInfo SET status = :status WHERE dateTime >= :startOfDay AND dateTime <= :endOfDay AND prayer = :prayer; delete: DELETE FROM PrayersInfo WHERE dateTime >= :startDateTime AND dateTime <= :endDateTime;
Логика:
- Схема создания таблицы
Будет выполняться только один раз, вам не нужно беспокоиться об этом - Все ваши запросы в следующей форме
Примечание: ключевые слова написаны заглавными буквами,from
не будет распознан, его нужно писать какFROM
Примечание2: SQLDelight сгенерирует Kotlin-функции для использования в вашем проекте для всех этих запросов
functionName: Query;
Затем создайте проект и приступайте к замене Room.
2.1. Перед заменой Room
Если вы работаете над уже выпущенным приложением, то, скорее всего, сейчас у вас номер версии больше 1.
В SQLDelight мы не указываем номер версии, как в Room,
SQLDelight генерирует номер версии в зависимости от количества файлов миграции + 1.
Чтобы разобраться с этим, в том же каталоге sqldelight
, который мы создали выше, создайте новый каталог с названием migrations
и создайте файл для каждой версии, с которой вы мигрировали, в следующей форме именования:
[version your migrating from].sqm
Таким образом, если вы находитесь на версии 3, вам необходимо создать два файла миграции 1.sqm
и 2.sqm
, и SQLDelight поймет, что вы находитесь на версии 3.
Убедитесь, что в этих файлах написаны утверждения миграции, аналогично тому, как ручная миграция работала в Room, только если вы использовали AutoMigration в Room, то вам придется написать и это.
Обратитесь к документации SQLDelight, если у вас возникли трудности с написанием миграции
В моем случае я создал новую таблицу при переходе на версию 2, поэтому вот как выглядел 1.sqm для меня:
3. Заменить инициализацию базы данных
Найдите место, где вы создаете базу данных Room databaseBuilder
, и замените ее на SQLDelight, обязательно реализуя класс Database из сгенерированных файлов (имя класса Database будет таким же, как вы написали в файле build.gradle.kts
), а не через вашу старую базу данных Room.
ВАЖНО: используйте такое же точное имя базы данных (в кавычках, например, «prayer-companion»), как и имя файла вашей базы данных, иначе SQLDelight создаст новую базу данных с пустыми данными.
val driver: SqlDriver = AndroidSqliteDriver(PrayerCompanionDatabase.Schema, applicationContext, "prayer-companion") return PrayerCompanionDatabase(driver)
Затем удалите класс RoomDatabase, он вам больше не нужен.
3.1. Перенос интерфейса Dao
Я хотел сохранить все в проекте неизменным, поэтому вместо того, чтобы заменять интерфейс классом, я просто создал новый класс Dao, наследующий от интерфейса Dao.
Таким образом, для этого Dao @Dao interface PrayersInfoDao
я создал новый класс class PrayersInfoDaoImpl: PrayersInfo
, затем я сгенерировал все функции точно так же, как они есть в интерфейсе.
В конструкторе можно инжектировать/передать сгенерированный класс Database:
class PrayersInfoDaoImpl @Inject constructor( db: PrayerCompanionDatabase ) : PrayersInfoDao { ... }
Затем получить ссылку на запросы к таблице, которые генерирует для вас SQLDelight:
class PrayersInfoDaoImpl @Inject constructor( db: PrayerCompanionDatabase ) : PrayersInfoDao { private val queries = db.prayersInfoQueries }
Далее использование запросов очень простое, запросы, которые вы написали в файле .sq
, теперь готовы к использованию, для вставки строки, например, вы напишете что-то вроде этого:
override fun insert(prayerInfo: PrayerInfoEntity) { queries.insert( id = null, prayer = prayerInfo.prayer.name, dateTime = converters.localDateToString(prayerInfo.dateTime), status = prayerInfo.status.name ) }
Примечание: id является null, поэтому мы можем позволить SQLDelight выполнить автоинкремент.
Однако при работе с запросами возникают некоторые сложности
a. Чтение строк
Чтение строк может быть не совсем интуитивным:
override fun getPrayers( startDateTime: LocalDateTime, endDateTime: LocalDateTime ): List<PrayerInfoEntity> { return queries.getPrayers( startDateTime = converters.localDateToString(startDateTime), endDateTime = converters.localDateToString(endDateTime) ) }
В приведенном ниже случае вызов getPrayers
вернет Query<PrayersInfo>
, в то время как ожидаемым результатом будет List<PrayerInfoEntity>
, чтобы сначала сопоставить класс данных с классом сущности, для каждого запроса есть две функции запроса, одна, как описано выше, только с необходимыми параметрами, а другая — с дополнительным параметром отображения функции, который можно использовать для сопоставления класса данных с классом сущности:
Я всегда предпочитаю не использовать сгенерированные классы данных, так как в дальнейшем становится сложнее что-либо изменить или добавить вспомогательные функции.
Использование второй функции будет выглядеть примерно так — обычная mapper функция на выбранный вами класс данных:
override fun getPrayers( startDateTime: LocalDateTime, endDateTime: LocalDateTime ): List<PrayerInfoEntity> { return queries.getPrayers( startDateTime = converters.localDateToString(startDateTime), endDateTime = converters.localDateToString(endDateTime) ) { id, prayer, dateTime, status -> // mapping PrayersInfo to PrayerInfoEntity PrayerInfoEntity( id = id.toInt(), prayer = Prayer.valueOf(prayer), dateTime = converters.stringToLocalDate(dateTime), status = status?.let { status -> PrayerStatus.valueOf(status) } ?: PrayerStatus.None ) } }
А для превращения Query<T>
в List<T>
вызовем .executeAsList()
:
override fun getPrayers( startDateTime: LocalDateTime, endDateTime: LocalDateTime ): List<PrayerInfoEntity> { return queries.getPrayers( startDateTime = converters.localDateToString(startDateTime), endDateTime = converters.localDateToString(endDateTime) ) { id, prayer, dateTime, status -> PrayerInfoEntity( id = id.toInt(), prayer = Prayer.valueOf(prayer), dateTime = converters.stringToLocalDate(dateTime), status = status?.let { status -> PrayerStatus.valueOf(status) } ?: PrayerStatus.None ) }.executeAsList() // transforming Query into List 👆 }
b. Чтение строк в Flow
Чтобы читать строки из таблицы в потоке, убедитесь, что у вас реализована зависимость расширений корутинов для SQLDelight:
implementation("app.cash.sqldelight:coroutines-extensions:2.0.0")
Тогда вместо вызова executeAsList
мы вызываем asFlow()
, а затем mapToList(DispatcherContext)
:
override fun getPrayersFlow( startDateTime: LocalDateTime, endDateTime: LocalDateTime ): Flow<List<PrayerInfoEntity>> { return queries.getPrayers( startDateTime = converters.localDateToString(startDateTime), endDateTime = converters.localDateToString(endDateTime) ) { id, prayer, dateTime, status -> PrayerInfoEntity( id = id.toInt(), prayer = Prayer.valueOf(prayer), dateTime = converters.stringToLocalDate(dateTime), status = status?.let { PrayerStatus.valueOf(it) } ?: PrayerStatus.None ) }.asFlow().mapToList(Dispatchers.IO) // transform into a flow list 👆 }
c. Вставка списка
Обычно для вставки нескольких строк сразу у нас есть функция insertAll
, но, к сожалению, SQLDelight не поддерживает ее из коробки, поэтому нам придется делать это самостоятельно.
В этом случае мы используем функцию queries.transaction { }
для вставки всех строк или ни одной в случае ошибки, что будет выглядеть примерно так:
override fun insertAll(prayersInfo: List<PrayerInfoEntity>) { queries.transaction { prayersInfo.forEach { queries.insert( id = null, prayer = it.prayer.name, dateTime = converters.localDateToString(it.dateTime), status = it.status.name ) } } }
А если вы хотите получать информацию об ошибке, то можно добавить это:
override fun insertAll(prayersInfo: List<PrayerInfoEntity>) { queries.transaction { // if the transaction failed and rolled back, throw an exception afterRollback { throw Exception("Failed to inserting prayers info ") } prayersInfo.forEach { queries.insert( id = null, prayer = it.prayer.name, dateTime = converters.localDateToString(it.dateTime), status = it.status.name ) } } }
Вот и все, дальше вам нужно только:
- Удалить все аннотации Room из интерфейсов Dao
- Удалить все аннотации Room из классов данных Entities
- Удалить все зависимости Room из файлов gradle.build.kts
- Инжектировать/передать новый класс
[Dao]Impl
в репозиторий, который использовал интерфейс Dao, и все должно работать точно так же.
Запустите приложение и убедитесь, что все ваши данные сохранились!
Полный код реальной миграции приложения можно посмотреть в пул-реквесте PrayerCompanion.