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):
build.gradle
sa doplnia dependencies na príslušné knižniceWeblink
je entitou. V DAO je urobených viac metód na ukážku.suspend
a Flow
. Vytvorenie jednotlivých prepojení cez Application
.ViewModel
, ktorý má dlhšiu životnosť ako aktivita. Spúštanie korutín.UUID
na String
, aby bol ako reťazec uložený aj v DB.Nasledovný kód je potrebné doplniť k projektu, aby sme vedeli použiť Room knižnicu na prácu s databázou.
plugins {
...
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}
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:
Pomocou vhodných anotácii definujeme ako bude vyzerať tabuľka v databáze.
@Entity
- ak tam nie je definovaný názov tabuľky, použije sa názov triedy@ColumnInfo
- jednotlivým premenným (property) sa dá nastaviť názov stĺpca. Ak nie je zadaný, berie sa názov premennej@PriorityKey
- označuje primárny kľúč@Ignore
- takto označené premenné sa do tabuľky neukladajú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:
@Insert
, @Update
, @Delete
@Query
- syntax adresovania premenných z funkcií v SQL dopyte si môžete všimnúť vo vzorovom kóde.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()
}
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.
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:
@Database
sa deklarujú entity, čo znamená aké tabuľky budú v databáze. Pri zmene štruktúry uchovávania údajov sa mení verzia databázy, čo následne vyžaduje riešenie migrácie medzi verziami (bez straty dát v aplikácii).companion object
a elvis operátora ?:
- ak inštancia danej triedy ešte neexistuje, tak sa vytvorí@Volatile
sa netýka úplne priamo iba práce s databázou. Táto kotlin anotácia označuje, že akákoľvek zmena je okamžite viditeľná aj v iných vláknach. synchronized
je využité, aby bola daná operácia vykonaná iba jedným vláknom. Tým sa docieli, že nevzniknú viaceré inštancie danej triedy.Room.databaseBuilder
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ť.
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ť.
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.
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).
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()
}
Insert
a Delete
(všetky verzie) upravíme na suspend
getAllWeblinks
si zmení svoju návratovú hodnotu na Flow
Flow v kotline spôsob ako sa modeluje asynchrónny prúd dát. V tomto prípade sa bude v danom toku nachádzať zoznam weblinkov, ktorý reprezentuje aktuálny stav databázy. Pri zmene údajov bude asynchrónne priradená nová hodnota. Tieto zmeny budeme sledovať na vyššej úrovni.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.
import android.app.Application
class WeblinksApplication : Application() {
val database by lazy { WeblinksDatabase.getDatabase(this) }
val repository by lazy { WeblinksRepository(database.weblinksDao()) }
}
<?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>
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.
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:
WeblinkViewModelFactory
- je to spôsob ako získať inštanciu ViewModel
, kde bude aj referencia na repository. V ďalšej aplikácii bude tento spôsob nahradený elegantnejším riešením.delete
spúšťa korutínu.weblinks
mení Flow
na LiveData
- použitie live dát uvidíme pri prepojení s aktivitou. V princípe ide o štruktúru, ktorá mení svoj obsah a upozorňuje na to (observable pattern).Korutína potrebuje nasledovné:
viewModelScope
- pri zániku viewModelu nedáva vykonávanie tohto kódu zmysel.launch
, ďalšie možností sú async
, runBlocking
, withContext
... Funkcia launch
je vhodná na úlohy typu fire-and-forget.suspend
funkcieSQLite 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.
...
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.
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()) }
}
...
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:
WeblinksViewModel
- využije sa factory a delegát by viewModels
(ktorý upravuje vykonávanie gettra a settra tejto property)LiveData
, ktoré sú vo ViewModel
.cachedWeblinks
onSwipe
- aby sa volala príslušná funkcia vo ViewModel
...
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
}
...
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.
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.).