Android používa SQLite databázu a prístup k nej zabezpečuje cez Room knižnicu. V ďalšej aplikácii bude použité dependency injection Hilt na redukciu boilerplate kódu.

Stručný popis k jednotlivým krokom (v tomto prípade commitom):

Odkazy

Nasledovný kód je potrebné doplniť k projektu, aby sme vedeli použiť Room knižnicu na prácu s databázou.

build.gradle.kts (Project: Weblinks)

plugins {
	...
    id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}

build.gradle.kts (Module :app)

plugins {
    ...
    id("com.google.devtools.ksp")
}

dependencies {

    val room_version = "2.6.1"
    implementation("androidx.room:room-runtime:$room_version")
    implementation("androidx.room:room-ktx:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")
    ksp("androidx.room:room-compiler:$room_version")

    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
    implementation("androidx.activity:activity-ktx:1.8.2")

    api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")

    ...
}

Ak medzičasom vyšla novšia verzia niektorej závislosti, ktorú používame, bude v Android Studio zvýraznená žltou farbou a po kliknutí nám bude ponúknutá možnosť upgrade na vyššiu verziu.

V Androide sa dáta ukladajú lokálne v SQLite databáze. O ďalších alternatívach ukladania dát je viac informácii v dokumentácii.

Room persistence library je súčasťou Android Jetpack. K databáze sa dá pristupovať aj priamo pomocou SQLite API, avšak použitie Room knižnice je už štandardom a prinášia niekoľko výhod:

architektura

Pomocou vhodných anotácii definujeme ako bude vyzerať tabuľka v databáze.

Weblink.kt

import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import java.io.Serializable
import java.util.UUID

@Entity(tableName = "weblinks")
data class Weblink(
    val title: String
) : Serializable {

    @PrimaryKey
    var uuid: UUID = UUID.randomUUID()

    @Ignore
    val url: String = "https://en.wikipedia.org/wiki/" + title.replace(' ', '_')

}

Takto napísaná trieda má 3 premenné (properties). Do databázy sa ukladajú iba dve z nich (title a uuid). Premenné uuid a url sú deklarované mimo deklarácie triedy, a teda nebudú súčasťou primárneho konštruktora.

Navyše všetky generované metódy (toString, equals a pod.) sú vytvorené na základe premenných z primárneho konštruktora. V tomto prípade by sme mohli uuid pridať do primárneho konštruktora alebo si prepísať toString a ďalšie metódy. Pre jednoduchosť to teraz môžeme ignorovať.

Data Access Object (DAO) poskytuje metódy, pomocou ktorých sa pracuje s dátami v databáze.

Technicky povedané, vytvorí sa rozhranie alebo abstraktná trieda. Častejšie (zvlášť pri jednoduchých prípadoch) sa používa rozhranie. Pomocou anotácii sa označia metódy a implementáciu tohto rozhrania dostaneme pri kompilácii vďaka knižnici Room.

@Dao poskytuje:

WeblinksDao.kt

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query

@Dao
interface WeblinksDao {

    @Query("SELECT * FROM weblinks")
    fun getAllWeblinks(): List<Weblink>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(weblink: Weblink)

    @Delete
    fun delete(weblink: Weblink)

    @Query("DELETE FROM weblinks WHERE title=:title")
    fun delete(title: String)

    @Query("DELETE FROM weblinks")
    fun deleteAll()

}

vararg

Pri používaní predvolených anotácii (napr. @Insert) môže nastať situácia, že chceme vkladať viacero záznamov naraz. Kotlin ponúka možnosť použiť vararg. Príklad s @Insert.

vararg je podobné ako ... v Jave.

V kotline ak je parameter funkcie varargs (musí byť na posledom mieste), tak môže prijať ľubovoľný počet vstupov daného typu, resp. pole týchto vecí. Interne vo funkcii sa pracuje s poľom v oboch prípadoch.

Pomocou abstraktnej triedy rozšírujúcej RoomDatabase a anotácie @Database deklarujeme triedu umožňujúcu prístup k údajom v databáze.

WeblinksDatabase.kt

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Weblink::class], version = 1, exportSchema = false)
abstract class WeblinksDatabase : RoomDatabase() {

    abstract fun weblinksDao(): WeblinksDao

    companion object {
        @Volatile
        private var INSTANCE: WeblinksDatabase? = null

        fun getDatabase(context: Context): WeblinksDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    WeblinksDatabase::class.java,
                    "weblinks_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Niekoľko poznámok k predošlému kódu:

Repository je akási medzivrstva, ktorá prepája svet databázy a svet aplikácie (aktivity). Myšlienkou je mať jedno miesto, kde sú definované zdroje dát. Aktivita (často nie priamo) pracuje s dátami, ale či tieto dáta sú uložené lokálne v databáze, alebo sa ťahajú odniekiaľ (REST API, Firebase database, cloud, ...) nie je jej starosť. Aktivita pristupuje k repository, v ktorom sa rozhoduje odkiaľ prichádzajú dáta.

V tejto aplikácii sa môže zdať, že ide o zbytočnú medzivrstvu. Vytvorme minimálne filtráciu, že sa sprístupnia iba tie metódy z dao, ktoré sa reálne v aplikácii budú používať.

WeblinksRepository.kt

import androidx.annotation.WorkerThread

class WeblinksRepository(private val weblinksDao: WeblinksDao) {

    val weblinks = weblinksDao.getAllWeblinks()

    suspend fun delete(weblink: Weblink) {
        weblinksDao.delete(weblink)
    }

}

Poznámka: v kóde nájdete anotáciu @WorkerThread. Bolo to odporúčané v starších návodoch pri práci s Android Room databázou. Táto anotácia sa už nezvykne používať.

Kotlin coroutines

Definícia z dokumentácie (viacero zaujímavých odkazov nájdete v prvom kroku tohto návodu):

A coroutine is an instance of a suspendable computation. It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one.

suspend funkcia delete znamená, že táto funkcia môže pozastaviť svoje vykonávanie bez toho, aby blokovala dané vlákno. Úmyslom je vykonávať prípadné dlhotrvajúce operácie mimo UI vlákna.

Asynchrónne DAO dopyty

Dopyty v DAO delíme na tri typy (viac v dokumentácii):

Prvé dva vieme implementovať v Kotline pomocou coroutines a suspend funkcií. Observable read je implementované pomocou Flow (záležitosť Kotlinu) a LiveData (záležitosť Android Jetpack, konkrétne Lifecycle).

WeblinksDao.kt

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface WeblinksDao {

    @Query("SELECT * FROM weblinks")
    fun getAllWeblinks(): Flow<List<Weblink>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(weblink: Weblink)

    @Delete
    suspend fun delete(weblink: Weblink)

    @Query("DELETE FROM weblinks WHERE title=:title")
    suspend fun delete(title: String)

    @Query("DELETE FROM weblinks")
    suspend fun deleteAll()

}

V androide je Application základnou triedou na spravovanie globálneho stavu aplikácie. Slúži ako entry point pre interakciu s jednotlivými komponentmi - aktivity, service a pod.

V tomto prípade budú referencie na databázu a repozitár dostupné cez Application. V ďalšej aplikácii využijeme dependency injection na vhodnejšie získanie týchto hodnôt.

Pri rozšírení triedy Application (rovnako ako pri zmene v aktivite) je potrebné upraviť aj Manifest.

WeblinksApplication.kt

import android.app.Application

class WeblinksApplication : Application() {

    val database by lazy { WeblinksDatabase.getDatabase(this) }
    val repository by lazy { WeblinksRepository(database.weblinksDao()) }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:name=".WeblinksApplication"
       	...
    >...
    </application>

</manifest>

Delegated property

V kotline sa využíva delegate pattern, ktorý implementuje princíp Composition over Inheritance.

Delegated property znamená, že getter a setter delegujeme na niekoho iného.

Delegát by lazy je už naprogramovaná vec, ktorá v zásade znamená, že inštancia sa vytvorí až pri prvom volaní gettra.

Odporúčam pozrieť sa aj na extension funkcie.

Android využíva návrhový vzor Model-View-ViewModel (MVVM). Úmyslom je oddeliť logiku aplikácie od UI komponentov, ktoré sú spravované aktivitou (resp. fragmentom - to uvidíme v ďalších aplikáciach).

ViewModel je trieda, ktorá uchováva a spravuje dáta týkajúce sa UI spôsobom citlivým k životnému cyklu. Videli sme v niektorej z predošlých aplikácii, že aktivita bola reštartovaná pri otočení zariadenia. ViewModel by bola pridružená trieda k tejto aktivite, ktorá uchováva dáta, ktoré aktivita zobrazuje.

WeblinksViewModel.kt

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class WeblinksViewModel(private val repository: WeblinksRepository) : ViewModel() {

    val weblinks = repository.weblinks.asLiveData()

    fun delete(weblink: Weblink) {
        viewModelScope.launch {
            repository.delete(weblink)
        }
    }

    class WeblinkViewModelFactory(private val repository: WeblinksRepository) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(WeblinksViewModel::class.java)) {
                return WeblinksViewModel(repository) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }

    }

}

Poznámky k tejto implementácii:

Korutína potrebuje nasledovné:

SQLite databáza povoľuje nasledovné typy hodnôt:

Ak máme uuid typu UUID, tak v databáze by bolo uložené ako BLOB. V tomto prípade by bolo vhodnejšie pracovať s danou hodnotou ako s textom. Samotná trieda UUID má funkcie na konverziu z reťazca a na reťazec.

Pomocou anotácií @TypeConverter a @TypeConverters definujeme spôsob prevodu medzi týmito typmi.

WeblinksDatabase.kt

...
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import java.util.UUID

@Database(entities = [Weblink::class], version = 1, exportSchema = false)
@TypeConverters(UuidConverter::class)
abstract class WeblinksDatabase : RoomDatabase() {

    ...
}

class UuidConverter {

    @TypeConverter
    fun uuidToString(uuid: UUID) = uuid.toString()

    @TypeConverter
    fun stringToUuid(string: String) = UUID.fromString(string)
}


Room databázu je možné vopred naplniť nejakými dátami aj s využitím súborov (viac v dokumentácii). Tu si ukážeme ako to urobiť manuálne v kóde.

Vytvorí sa callback - funkcia, ktorá sa zavolá, keď sa vytvára inštancia databázy. Tam sa vymaže všetok obsah a naplní príslušnými hodnotami.

Interakcia s databázou vyžaduje volanie suspend funkcií, ktoré sa dajú volať iba z inej suspend funkcie alebo z korutíny. Na korutínu je potrebný scope. V tomto prípade sa tento scope vytvorí v triede WeblinksApplication. Detaily o CoroutineScope(SupervisorJob()) nie sú aktuálne podstatné. Tento scope je ale iný ako ten, ktorý je v kontexte viewModelu.

WeblinksApplication.kt

import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob

class WeblinksApplication : Application() {

    val applicationScope = CoroutineScope(SupervisorJob())
    val database by lazy { WeblinksDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WeblinksRepository(database.weblinksDao()) }
}

WeblinksDatabase.kt

...
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

...
abstract class WeblinksDatabase : RoomDatabase() {

	...
    
    companion object {
    	... 
        
        fun getDatabase(context: Context, scope: CoroutineScope): WeblinksDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    WeblinksDatabase::class.java,
                    "weblinks_database"
                ).addCallback(object : RoomDatabase.Callback() {
                    override fun onCreate(db: SupportSQLiteDatabase) {
                        super.onCreate(db)
                        INSTANCE?.let {
                            scope.launch {
                                it.weblinksDao().deleteAll()
                                it.weblinksDao().insert(Weblink("André Planson"))
                                it.weblinksDao().insert(Weblink("Elena Zamura"))
                                it.weblinksDao().insert(Weblink("Walter K. Singleton"))
                                it.weblinksDao().insert(Weblink("1980 Major League Baseball draft"))
                                it.weblinksDao().insert(Weblink("Ōi Junction"))
                                it.weblinksDao().insert(Weblink("Pixham"))
                            }
                        }
                    }
                })
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

...

Pre plné prepojenie aktivity a databázy je potrebné urobiť nasledovné kroky:

WeblinksAdapter.kt

...

class WeblinksAdapter(private val listener: OnWeblinkClickListener) :
    RecyclerView.Adapter<WeblinksAdapter.WeblinksViewHolder>() {

    var cachedWeblinks: List<Weblink> = emptyList()
        set(value) {
            field = value
            notifyDataSetChanged()
        }

    ...

    override fun onBindViewHolder(holder: WeblinksViewHolder, position: Int) {
        holder.bind(cachedWeblinks[position], listener)
    }

    override fun getItemCount() = cachedWeblinks.size

}

MainActivity.kt

...
import androidx.activity.viewModels

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val weblinksViewModel: WeblinksViewModel by viewModels {
            WeblinksViewModel.WeblinkViewModelFactory((application as WeblinksApplication).repository)
        }

        val recyclerView = ...
        recyclerView.adapter = adapter

        val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
            0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
        ) {
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ) = false

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                val index = viewHolder.adapterPosition
                weblinksViewModel.delete(adapter.cachedWeblinks[index])
            }

        })
        itemTouchHelper.attachToRecyclerView(recyclerView)

        weblinksViewModel.weblinks.observe(this) {
            adapter.cachedWeblinks = it
        }
    }
}

Môžeme si vyskúšať App inspection v Android Studio na živé sledovanie aktuálneho obsahu databázy.

Gitlab kód.

Pre viac možností práce s databázou odporúčam pozrieť odkazy v prvom kroku tohto návodu alebo v dokumentácii (napr. vytvorenie views, migrácia, testovanie a pod.).