Room для Kotlin Multiplatform

sxju9_erpayx03ib5v8ybxw-0pw.png
Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании Usetech. В начале мая Google нас порадовали релизами нескольких библиотек для локальных хранилищ. Наконец, в приложения Kotlin Multiplatform можно полноценно использовать Room (версия 2.7.0-alpha01 и выше).
И сегодня мы опробуем работу с данной библиотекой на примере небольшого приложения Todo, написанного на KMP с использованием Compose Multiplatform.
gxrkgazg2pelwuausx_3v_cu4u0.png
n2doktkws2ilk_ojuuhqxodgkqu.png


Начнем с настроек проекта. Нам потребуется установить библиотеку 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)
}


Укажем также путь для поиска схем базы данных:

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>
}

Переходим к самому интересному — созданию базы данных. Как обычно, создаем абстрактный класс-наследник RoomDatabase:

@Database(entities = [TodoEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun getDao(): TodoDao
}


Добавим к нему билдер с учетом expect/actual:

//Android
fun getDatabaseBuilder(ctx: Context): RoomDatabase.Builder {
    val appContext = ctx.applicationContext
    val dbFile = appContext.getDatabasePath("my_room.db")
    return Room.databaseBuilder(
        context = appContext,
        name = dbFile.absolutePath
    )
}

//iOS
fun getDatabaseBuilder(): RoomDatabase.Builder {
    val dbFilePath = NSHomeDirectory() + "/my_room.db"
    return Room.databaseBuilder(
        name = dbFilePath,
        factory =  { AppDatabase::class.instantiateImpl() }
    )
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
}


У наших билдеров разная сигнатура, поэтому пометить их actual и задать общую сигнатуру с expect мы не можем. Попробуем решить проблему следующим образом: будем использовать Koin для инициализации хранилища и создадим expect/actual модуль.

//commonMain
expect fun platformModule(): Module

//androidMain
actual fun platformModule() = module {
    single { getDatabase(get()) }
}

//iOSMain
actual fun platformModule() = module {
    single { 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> {
        return dao.getAllAsFlow()
    }
}

И добавим в наши ViewModel функции вызова. Для добавления записи:

class AddTodoViewModel(
    private val taskRepository: TaskRepository
) : ViewModel() {

    val titleText: MutableStateFlow = MutableStateFlow("")
    fun onConfirm() {
        viewModelScope.launch {
            taskRepository.addTodo(TodoEntity(title = titleText.value))
        }
    }
}

И собственно, вызов для загрузки:


class TodoViewModel(private val repository: TaskRepository) : ViewModel() {

    val tasks: MutableSharedFlow> = MutableSharedFlow(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
    fun loadData() {
        viewModelScope.launch {
            repository.loadTodos().collectLatest {
                tasks.tryEmit(it)
            }
        }
    }
}

Проверяем работу:
kugdzpxohh7xy5xua2co4936n54.png

n2doktkws2ilk_ojuuhqxodgkqu.png

Пробуем запустить на iOS. Объективно генерация такой простой схемы заняла несколько минут.
Также у вас могут вылезти ошибки компиляции и генерации ksp. API экспериментальное и не без багов.
Попробуйте указать toolChain и версию Kotlin для компилятора:

kotlin {
    jvmToolchain(17)
}
//...
compilerOptions {
        languageVersion.set(KOTLIN_1_9)
    }


Проверяем результат:
kugdzpxohh7xy5xua2co4936n54.png

gxrkgazg2pelwuausx_3v_cu4u0.png

Наш готовый проект:
github.com/anioutkazharkova/room-kmp

Ограничения 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
johnoreilly.dev/posts/jetpack_room_kmp

Habrahabr.ru прочитано 6498 раз