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

Обещано в следующих версиях — ждем.

Дополнительно советую ознакомиться со статьей Джона О’Рейли и его тестовым проектом. Я советую смотреть реализацию в коде. Часть важных нюансов, без которых работать не будет, в статье у О’Рейли не отражена.

Спасибо за внимание, оставайтесь на связи)

developer.android.com/kotlin/multiplatform/sqlite
developer.android.com/kotlin/multiplatform/room


ссылка на оригинал статьи https://habr.com/ru/articles/812453/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *