U Androidu je omogućen i rad sa bazama podataka preko biblioteke Room. Ovde ćemo obraditi neke elementarne slučajeve. Za detaljniju priču o bazama podataka, savetujemo da se pogleda zvanična dokumentacija Room biblioteke na sledećem linku, kao i W3Schools tutorijal iz SQL-a.
Da bi mogla da se koristi ova biblioteka, potrebno je ugraditi je u projekat preko Gradle fajla, baš kao što stoji u zvaničnoj dokumentaciji na ovom linku.
Kreiranje baze podataka se sastoji od sledeća tri koraka:
@Entity data class User( @PrimaryKey val id: Int, val firstName: String?, val lastName: String? )
Marker @PrimaryKey govori da je id primarni ključ. Takođe, moguće je na svaki od atributa klase dodati marker @ColumnInfo i onda zadati ime kolone. Ovo može biti korisno kada radimo upite nad bazom podataka. Recimo, na primeru bi to izgledalo ovako:
@Entity data class User( @PrimaryKey @ColumnInfo(name = "user_id") val id: Int, @ColumnInfo(name = "first_name") val firstName: String?, @ColumnInfo(name = "last_name") val lastName: String? )
U slučaju da je u primarnom ključu više kolona tabele, tada se definišu na ovaj način:
// definišemo ime tabele i primarne ključeve @Entity(tableName = "ime_tabele", primaryKeys = ["prvi_primarni", "drugi_primarni"]) data class Entitet1( @ColumnInfo(name = "prvi_primarni") val primarni1: ..., @ColumnInfo(name = "drugi_primarni") val primarni2: ..., ostali_atributi... )
Drugi korak u definisanju baze podataka je definisanje DAO. DAO može biti klasa ili interfejs i on sadrži operacije koje možemo izvršavati nad bazom podataka. Dakle, u DAO ćemo definisati metode za unošenje i brisanje vrsta, za određene upite. DAO koristi upite u jeziku SQLite. Slično sa entitetima, DAO se definiše tako što se naznači markerom @Dao pre imena klase. Za svaki od metoda interfejsa DAO, mora se markerom naznačiti o kojoj se operaciji radi. Recimo, najjednostavniji DAO ima strukturu sličnu ovoj:
@Dao interface UserDao { @Insert fun insertAll(vararg users: User) @Delete fun delete(user: User) @Query("SELECT * FROM user") fun getAll(): List<User> }
Metod insertAll dodaje sve članove entiteta iz argumenata, metod delete briše člana kog smo mu prosledili. Metod getAll pokazuje kako upiti u biblioteci Room funkcionišu u suštini. Metod će, logično, vratiti listu elemenata (mogla je da stoji i neka druga klasa, recimo niz). Kompajler sam zaključuje da FROM ovde referiše na tabelu klase User. Sada ćemo videti kako tri ključne operacije nad bazom podataka funkcionišu: horizontalno sečenje, vertikalno sečenje i spajanje tabela.
Za horizontalno sečenje tabele (restrikciju), potrebna nam je naredba WHERE. Kao argumente prosleđujemo ono sa čime poredimo određene atribute tabele. Recimo, to bi na primeru sa entitetom User izgledalo ovako:
@Query("SELECT * FROM user WHERE first_name LIKE :search") fun findUserWithName(search: String): List<User> @Query("SELECT * FROM user WHERE first_name LIKE :first_name AND last_name LIKE :last_name") fun findUser(first_name: String, last_name: String): List<User> @Query("SELECT * FROM user WHERE first_name IN (:names)") fun loadUsersFromRegions(names: List<String>): List<User>
Svaki od metoda prikazuje jednu od mogućnosti koje nam Room pruža. U prvom metodu je prosleđen jedan argument, u drugom dva. U trećem metodu vidimo da možemo da prosledilo listu argumenata. Ekvivalent trećem metodu bi bio da nad svakim elementom kolekcije koristimo prvi metod, pa da radimo uniju tako dobijenih kolekcija.
Za vertikalno sečenje (projekciju), potrebno je najpre definisati klasu objekata koju ćemo dobiti kada izvršimo upis. Ono što je bitno kompajleru je da ta klasa ima imenovane vrednosti kako bi mogao da koristi upit. Recimo, želimo da izdvojimo imena i prezimena ljudi u bazi podataka. Tada bi upit izgledao ovako:
data class NameTuple( @ColumnInfo(name = "first_name") val firstName: String?, @ColumnInfo(name = "last_name") val lastName: String? ) @Dao interface UserDao { ... @Query("SELECT first_name, last_name FROM user") fun loadFullName(): List<NameTuple> }
Kao i za projekciju,i za spajanje tabela moramo definisati klasu koja opisuje rezultujući entitet. Za spajanje se koristi JOIN. Recimo, na sledećem primeru se kombinuju restrikcija i spajanje.
@Query( "SELECT * FROM book " + "INNER JOIN loan ON loan.book_id = book.id " + "INNER JOIN user ON user.id = loan.user_id " + "WHERE user.name LIKE :userName" ) fun findBooksBorrowedByNameSync(userName: String): List<Book>
Room nam omogućava da vraćamo i mape. To može da bude korisno u situacijama kada koristimo naredbu GROUP BY. Recimo, na sledećem primeru se, za svakog korisnika, izdvaja spisak knjiga.
@Query( "SELECT * FROM user" + "JOIN book ON user.id = book.user_id" + "GROUP BY user.name WHERE COUNT(book.id) >= 3" ) fun loadUserAndBookNames(): Map<User, List<Book>>
Moguće je definisati i više entiteta i povezivati ih na određeni način. O tome se može pročitati na sajtu zvanične dokumentacije na sledećem linku.
Da bi se baza kreirala, mora prvo da se definiše kao potklasa klase RoomDatabase i da se naznači markerom @Database. U markeru @Database ćemo definisati entitete baze podataka. Recimo, baza podataka koja čuva podatke o korisnicima bi mogla da izgleda ovako:
@Database(entities = [User::class], version = 1) abstract class UserRoomDatabase : RoomDatabase()
U ovom slučaju, parametar version je podešen na 1, jer je to prva verzija baze podataka. Ostavlja se mogućnost da se verzije menjaju, pa je korisno imati taj parametar za slučaj da programer hoće da napravi novu verziju baze.
Ono što treba dalje uraditi je obavestiti bazu podataka koji joj je DAO. To se radi tako što se deklariše element klase ili interfejsa DAO.
@Database(entities = [User::class], version = 1) abstract class UserRoomDatabase : RoomDatabase() { abstract val userDatabaseDao: UserDao }
Sledeća stvar koja je ključna je definisanje baze podataka je kreiranje companion objekta. Drugi pristup je da napravimo konstruktor baze podataka, pa da instanciramo elemente baze. Pošto je jedina svhra ove klase da opiše jednu bazu podataka, tada nam to nije potrebno. Recimo, na sledećem primeru bi to izgledalo ovako u celini:
@Database(entities = [User::class], version = 1) abstract class UserRoomDatabase : RoomDatabase() { abstract val userDatabaseDao: UserDao companion object { @Volatile private var INSTANCE: UserRoomDatabase? = null fun getInstance(context: Context): UserRoomDatabase { synchronized(this) { var instance = INSTANCE if (instance == null) { instance = Room.databaseBuilder( context.applicationContext, UserRoomDatabase::class.java, "user_database" ) .fallbackToDestructiveMigration() .build() INSTANCE = instance } return instance } } } }
Najpre, promenljiva INSTANCE će čuvati našu bazu. Ona je označena sa @Volatile, što znači da će svako upisivanje i ispisivanje u nju da se odvija u glavnoj memoriji, bez keširanja podataka. Ta praksa se koristi da bi baza podataka uvek bila ažurirana na vreme, kao i da bi se sprečilo da dve niti pristupe keširanoj memoriji u isto vreme. Na početku, INSTANCE je null, jer bazu još nismo ni napravili.
Metod getInstance pravi bazu podataka i prosleđuje referencu na nju, ako baza ne postoji; ako baza postoji, onda će samo proslediti referencu na nju. On počinje pozivom synchronized(this), što obezbeđuje da u naredni blok koda ne mogu istovremeno ući dve niti. To obezbeđuje da se samo jedna baza podataka inicijalizuje.
Ako baza ne postoji, onda je kreiramo. Za kreiranje se koristi metod databaseBuilder. Ovaj metod prima kontekst, klasu kojoj baza pripada i ime baze.
Metod fallbakcToDestrucitveMigration se koristi da se opiše šta treba uraditi ukoliko je došlo do promene verzije baze podataka. Najjednostavniji način je da se baza uništi i izgradi nova verzija, pri čemu se gube podaci. To je rađeno baš u ovom primeru. Više o migracionim strategijama se može naći ovde.
Konačno, metod build će izgraditi bazu podataka, nakon čega je mi prosleđujemo promenljivoj INSTANCE.
Napravili smo bazu podataka. Ostaje samo da je povežemo sa ostatkom sistema. To se može uraditi tako što ćemo referencu na bazu podataka čuvati u ViewModel klasi. Za te potrebe, prosleđuje se DAO baze podataka na klasi ViewModel. Za kreiranje instance baze podataka, potrebno je proslediti i kontekst. U našem slučaju, prosledićemo referencu na samu aplikaciju. Tada će ViewModel klasa da ima ovakvu strukturu:
class AViewModel( val database: ADatabaseDao, application: Application) : AndroidViewModel(application) { ... }
Problem nastaje kada pokušavamo preko ViewModelProvider da napravimo instancu od ViewModel. Nažalost, ViewModelProvider ne može da napravi instancu za slučajeve kada u konstruktoru imamo barem jednu promenljivu. Iz tog razloga, prelazi se na ViewModelProvider.Factory koji to može da uradi. Sledeći kod se uglavnom ponavlja, pa se može i skroz iskopirati kada se piše aplikacija:
class AViewModelFactory( private val dataSource: ADatabaseDao, private val application: Application) : ViewModelProvider.Factory { @Suppress("unchecked_cast") override funcreate(modelClass: Class ): T { if (modelClass.isAssignableFrom(AViewModel::class.java)) { return AViewModel(dataSource, application) as T } throw IllegalArgumentException("Unknown ViewModel class") } }
Kod prihvata iste argumente kao i ViewModel. U metodu create se proverava postoji li naša klasa. Ako klasa postoji, vratiće njenu instancu, a u suprotnom će prijaviti grešku.
Sada u odgovarajućem fragmentu ili aktivnosti ćemo kreirati instancu klase AViewModel u metodu onCreate ako je aktivnost u pitanju, odnosno u onCreateView ako se radi o fragmentu:
val application = requireNotNull(this.activity).application val dataSource = ADatabase.getInstance(application).ADatabaseDao val viewModelFactory = AViewModelFactory(dataSource, application) val AViewModel = ViewModelProvider( this, viewModelFactory).get(AViewModel::class.java) binding.setLifecycleOwner(this) binding.AViewModel = AViewModel
Najpre se promenljiva application instancira. Potom se dobija kreira instanca baze podataka i dobija se referenca na njen DAO. Treća i četvrta linija koda prave redom ViewModelFactory i onda i ViewModel na osnovu prosleđenih argumenata. Ostatak je povezivanje sa binding objektom.
Kotlin podržava i konkurentno programiranje u vidu korutina. Konkurentno programiranje je veoma složena tema. Ovde ćemo izložiti samo neke osnovne stvari u Kotlinu i povezati sa ranije uvedenim pojmovima.
U svim ranijim kodovima, izvršavanje je sekvencijalno - programi se izvršavaju jedni za drugima, bez prekidanja. Ovaj pristup je najjednostavniji za implementaciju, ali isto tako donosi niz mana. Recimo, ako imamo veliku bazu podataka iz koje je potrebno izdvojiti neke podatke i prikazati na ekranu, tada će ekran ostati zablokiran i neće se ažurirati dok baza podataka ne odradi svoj deo posla. Iz ovakvih razloga, uvodi se koncept konkurentnog programiranja.
U Kotlinu, konkurentnost je obezbeđena preko korutina. Korutina predstavlja deo procesa koji može da se izvršava, ali i da bude prekinut. Korutine imaju dosta sličnosti sa nitima, ali ima i nekoliko razlika. Neke od razlika su što korutina može postojati, teorijski, beskonačno mnogo na sistemu, dok je broj niti ograničen. Međutim, da bi korutine mogle da se izvršavaju, potrebno ih je preslikati na niti. Ovo mnogo liči na preslikavanje korisničkih niti u niti jezgra. U nekim situacijama korisnik to sam radi, ali dobar deo posla urade biblioteke, što je i slučaj u Android bibliotekama. Druga razlika je što su korutine "lakše" od niti, što znači da zauzimaju manje memorijskog prostora.
Za rad sa korutinama, postoje tri glavna pojma:
Sada ćemo pokazati kako se korutine koriste da bi se naši poslovi izvršavali konkurentno. Najpre ćemo bazu podataka prilagoditi radu korutina.
Prvi korak je da uključimo odgovarajuće ekstenzije za rad sa korutinama u Room. To se radi modifikovanjem Gradle fajla na način koji je ranije opisan, dodavanjem sledećih linija:
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" // Kotlin Extensions and Coroutines support for Room implementation "androidx.room:room-ktx:$room_version"
Nakon što smo to uradili, potrebno je otići u DAO klasu/interfejs i izmeniti deklaracije metoda dodavanjem ključne reči suspend na ovakav način.
suspend fun nekiMetod(...) { ... }
Na ovaj način, stavlja se do znanja da dati metod može da bude prekinut i da se njegovo izvršavanje kasnije nastavi.
Sada je potrebno u ViewModel transformisati metode tako da rade na sledeći način:
fun nekiMetod(...) { viewModelScope.launch { naredbe... } }
Metod launch kreira korutinu od onoga što se nalazi u bloku naredbi. U opštem slučaju, potrebno je kao argument proslediti dispečer kao argument metoda launch. Kada se radi sa bazama podataka iz Room biblioteke, onda to nije potrebno zato što to biblioteka sama radi za nas. U našem slučaju, biblioteka će sama podesiti Dispatchers.IO. Ako bismo želeli da naznačimo koji dispečer da se koristi, uradili bismo sledeće:
fun nekiMetod(...) { viewModelScope.launch(Dispatcher.Main) { naredbe... } }