Room для Kotlin Multiplatform. Пробуем нативное решение
Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании Usetech. В начале мая Google нас порадовали релизами нескольких библиотек для локальных хранилищ. Наконец, в приложения Kotlin Multiplatform можно полноценно использовать Room(версия 2.7.0-alpha01 и выше). И сегодня мы опробуем работу с данной библиотекой на примере небольшого приложения Todo, написанного на KMP с использованием Compose Multiplatform.
Кроме Room, в проекте используется библиотека Lifecycle-viewmodel для KMP. И Koin для DI и гармонии.
Начнем с настроек проекта. Нам потребуется установить библиотеку Room и SQLite (ее зависимость). Пропишем зависимость в каталог lib.versions:
/*lib.versions*/
[versions]
\\..
androidxRoom = "2.7.0-alpha01"
sqlite = "2.5.0-alpha01"
[libraries]
\\..
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" }
Обратите внимание, что мы указываем для Room компилятор и runtime. SQLite — это хранилище по умолчанию, которое мы используем под капотом Room.
Также нам нужно подключить плагин для Room:
/*lib.versions*/
[plugins]
\\...
room = { id = "androidx.room", version.ref = "androidxRoom" }
/*build.gradle.kts app*/
plugins {
\\...
alias(libs.plugins.room).apply(false)
}
/*build.gradle.kts shared*/
plugins {
\\...
alias(libs.plugins.room)
}
Не забудем добавить в блок зависимостей таргета commonMain:
sourceSets {
commonMain.dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.sqlite.bundled)
implementation(libs.sqlite)
}
}
Запускаем синхронизацию и получаем ошибку. Потому что не добавили KSP. Одним из основных этапов миграции Room был переход с KAPT на KSP, что и сделало возможным поддержку мультиплатформы. Поэтому для корректной работы нам нужно установить плагин KSP:
/*lib.versions*/
[versions]
\\...
ksp = "1.9.23-1.0.19"
kotlin = "1.9.23"
\\...
[plugins]
\\...
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Учтите, что версия Kotlin должна совпадать с мажорной версией KSP.
/*build.gradle.kts app*/
plugins {
\\...
alias(libs.plugins.ksp) apply false
}
/*build.gradle.kts shared*/
plugins {
\\...
id("com.google.devtools.ksp")
}
Также добавим в самый низ build.gradle.kts (shared) блок процессинга модулей Room через KSP:
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}
Важный момент: для Kotlin 1.9.20 в gradle.properties указываем kotlin.native.disableCompilerDaemon = true.
# Disabled due to https://youtrack.jetbrains.com/issue/KT-65761
kotlin.native.disableCompilerDaemon = true
Укажем также путь для поиска схем базы данных:
room {
schemaDirectory("$projectDir/schemas")
}
Синхронизируем Gradle.
Готово, Room мы установили. Теперь давайте настроим наше хранилище.
Так же, как и в Android приложении, нам потребуется сделать следующие шаги (с некоторыми нюансами):
1. Создать модель-данных Entity для таблицы базы данных.
2. Создать Dao для запросов из нашей таблицы.
3. Настроить хранилище, как наследник RoomDatabase.
4. Создать репозиторий для запросов — шаг опциональный, больше для соблюдения архитектурного порядка.
Итак, для модели данных используем обычный data class с нужными нам полями. Добавим аннотацию @`Entity для генерации таблицы из модели. Аннотация @`PrimaryKey пометит поле первичного ключа:
@Entity
data class TodoEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String
val date: String
)
Теперь добавим интерфейс-Dao с методами для операций добавления элемента (Insert) и получения данных (Select):
@Dao
interface TodoDao {
@Insert
suspend fun insert(item: TodoEntity)
@Query("SELECT count(*) FROM TodoEntity")
suspend fun count(): Int
@Query("SELECT * FROM TodoEntity")
fun getAllAsFlow(): Flow<List<TodoEntity>>
}
Переходим к самому интересному — созданию базы данных. Как обычно, создаем абстрактный класс-наследник RoomDatabase:
@Database(entities = [TodoEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun getDao(): TodoDao
}
Добавим к нему билдер с учетом expect/actual:
//Android
fun getDatabaseBuilder(ctx: Context): RoomDatabase.Builder<AppDatabase> {
val appContext = ctx.applicationContext
val dbFile = appContext.getDatabasePath("my_room.db")
return Room.databaseBuilder<AppDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
fun getDatabase(ctx: Context): AppDatabase {
return getDatabaseBuilder(ctx).build()
}
//iOS
fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFilePath = NSHomeDirectory() + "/my_room.db"
return Room.databaseBuilder<AppDatabase>(
name = dbFilePath,
factory = { AppDatabase::class.instantiateImpl() }
)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
}
fun getDatabase(): AppDatabase {
return getDatabaseBuilder().build()
}
У наших билдеров разная сигнатура, поэтому пометить их actual и задать общую сигнатуру с expect мы не можем. Попробуем решить проблему следующим образом: будем использовать Koin для инициализации хранилища и создадим expect/actual модуль.
//commonMain
expect fun platformModule(): Module
//androidMain
actual fun platformModule() = module {
single<AppDatabase> { getDatabase(get()) }
}
//iOSMain
actual fun platformModule() = module {
single<AppDatabase> { getDatabase() }
}
Теперь небольшой челлендж: передать контекст со стороны Android приложения? Сделаем функцию в commonMain с параметром-блоком:
fun initKoin(appDeclaration: KoinAppDeclaration = {}) =
startKoin {
appDeclaration()
modules(platformModule())
}
Также добавим фабрику-синглтон для доступа к di:
object Koin {
var di: KoinApplication? = null
fun setupKoin(appDeclaration: KoinAppDeclaration = {}) {
if (di == null) {
di = initKoin(appDeclaration)
}
}
}
Koin.setupKoin() мы вызовем из нативных Android и iOS приложений:
Koin.setupKoin {
androidContext(applicationContext)
}
Наконец, закончили с инициализациями и настройками. Переходим к подключению логики работы с хранилищем к экранам приложения.
Добавим репозиторий. где вызовем методы Dao:
class TaskRepository(
private val database: AppDatabase
) {
private val dao: TodoDao by lazy {
database.getDao()
}
suspend fun addTodo(todoEntity: TodoEntity) {
dao.insert(todoEntity)
}
suspend fun loadTodos(): Flow<List<TodoEntity>> {
return dao.getAllAsFlow()
}
}
И добавим в наши ViewModel функции вызова. Для добавления записи:
class AddTodoViewModel(
private val taskRepository: TaskRepository
) : ViewModel() {
val titleText: MutableStateFlow<String> = MutableStateFlow<String>("")
fun onConfirm() {
viewModelScope.launch {
taskRepository.addTodo(TodoEntity(title = titleText.value))
}
}
}
И собственно, вызов для загрузки:
class TodoViewModel(private val repository: TaskRepository) : ViewModel() {
val tasks: MutableSharedFlow<List<TodoEntity>> = MutableSharedFlow(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
fun loadData() {
viewModelScope.launch {
repository.loadTodos().collectLatest {
tasks.tryEmit(it)
}
}
}
}
Проверяем работу:
Пробуем запустить на iOS. Объективно генерация такой простой схемы заняла несколько минут.
Также у вас могут вылезти ошибки компиляции и генерации ksp. API все-таки экспериментальное и не без багов.
Попробуйте указать toolChain и версию Kotlin для компилятора:
kotlin {
jvmToolchain(17)
}
//...
compilerOptions {
languageVersion.set(KOTLIN_1_9)
}
Проверяем результат:
Наш готовый проект:
github.com/anioutkazharkova/room-kmp
С какими сложностями я столкнулась в процессе:
— неверная версия sqlite-bundle, из-за чего не работал инстанс AppDatabase на iOS
— нужно подключать и sqlite, чтобы хранилище на iOS работало корректно
— обязательно указать toolchain и параметры Kotlin для компиляции
— в туториале Android Developer не была указана передача драйвера в билдер базы данных, без него у меня не работало
— не забудьте про KSP, без него Room не работает.
Ограничения Room KMP
Есть и различия в версиях Room для Kotlin Multiplatform. Например, использование в не-Android таргетах методов, помеченных аннотацией @`RawQuery, вызовет ошибку. Поддержка этой аннотации будет добавлена в следующих версиях Room.
Также поддерживаются только в Android:
1 API коллбэка:
- RoomDatabase.Builder.setQueryCallback,
- RoomDatabase.QueryCallback
2 Автоматическое закрытие базы данных по тайм-ауту:
- RoomDatabase.Builder.setAutoCloseTimeout
3 Множественные инстансы хранилища:
- RoomDatabase.Builder.enableMultiInstanceInvalidation
4 Создание базы данных из ассетов, файлов и т.п:
- RoomDatabase.Builder.createFromAsset,
- RoomDatabase.Builder.createFromFile,
- RoomDatabase.Builder.createFromInputStream,
- RoomDatabase.PrepackagedDatabaseCallback
Обещано в следующих версиях — ждем.
Дополнительно советую ознакомиться со статьей Джона О’Рейли и его тестовым проектом. Я советую смотреть реализацию в коде. Часть важных нюансов, без которых работать не будет, в статье у О’Рейли не отражена.
Спасибо за внимание, оставайтесь на связи)