Uvod
Room je bibloteka preporučena od strane Google-a kao jedna od komponenti tzv. “Android Architecture Components” pristupa. Room je wrapper oko SQLite baze i predstavlja sloj apstrakcije koji olakšava rad sa bazom podataka. Room bibilioteka preuzima na sebe većinu obaveza tako da sada lakše kreiramo tabele i upravljamo podacima.
Room ima tzv. compile-time checks tj. kontrolu koda pri kompajliranju i ukoliko postoji greška ona će se pokazati pri samom kompajliranju. Room nas na taj način spašava iritantnih sitnih grešaka koje nastaju u radu sa SQL bazom (nedostatak tačka zareza ili razmaka…) a koje izazivaju RunTimeException.
Da bi radili sa ovom bibliotekom potrebno je da se definišu dependencies:
1 2 3 4 |
def room_version = "2.2.5" implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" |
Room biblioteka se sastoji iz tri glavne komponente:
Entity (tabela)
Entity je klasa koja predstavlja tabelu baze podataka a obeležava se sa @Entity. U nastavku ove anotacije (u okviru zagrada) možemo definisati naziv tabele koji može biti drugačiji od samog naziva klase. Ukoliko ne definišemo ime tabele na ovaj način, Room će generisati tabelu sa istim imenom kao što je naziv klase.
1 2 3 4 |
@Entity(tableName = "buy_item_table") public class BuyItem { } |
Svaki entitet mora imati konstruktor! Konstruktor se najčešće definiše tako da mu se parametri podudaraju sa poljima (na osnovu tipa i imena). Medjutim konstruktor ne mora da primi sva polja kao parametre, polje koje ne želimo da stavimo u konstruktor moramo da obeležimo sa anotacijom @Ignore u suprotnom će Android studio prikazati grešku. Takodje u okviru konstruktora ne moramo da stavimo polje koje se autogeneriše (tj. autoGenerate = true), ali za to polje mora da postoji public setter (mi ga nećemo koristi ali neophodan je Room biblioteci).
@PrimaryKey
U radu sa Room biblioteko jedna kolona mora da ima definisan tzv. PrimaryKey. Obeležavanje kolone koja će predstavljati “Primary key” se vrši sa dodavanjem anotacije @PrimaryKey kod tog polja. Kad definišemo da je neko polje PrimaryKey onda možemo da koristimo njegovu metodu autoGenerate() ili jednostavnije samo u zagradama da dodamo (autoGenerate = true).
Primer
U ovome primeru prvo polje mId se automatski generiše, pa ga nećemo ubaciti u konstruktor, ali je ipak neophodno da se obezbedi njegov setter jer je potreban Room biblioteci (za ostala polja to nisu neophodni setteri).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@Entity(tableName = "buy_item_table") public class BuyItem { @PrimaryKey(autoGenerate = true) private int mId; private String mName; private String mAmount; private String mTimestamp; public BuyItem(String mName, String mAmount, String mTimestamp) { this.mName = mName; this.mAmount = mAmount; this.mTimestamp = mTimestamp; } // Setter samo za id polje jer nije dat kao parametar u konstruktoru public void setId(int id){ mId = id; } // Geteri: public int getId(){ return mId; } public String getName() { return mName; } public String getAmount() { return mAmount; } public String getTimestamp() { return mTimestamp; } } |
NAPOMENA:
Konstruktor eventualno može biti i bez ijednog argumenata ali tada moraju postojati definisani setteri za sva polja.
@ColumnInfo
Room po default-u generiše tabelu sa nazivom kolone istio kao i polje klase, medjutim ukoliko želimo da naziv kolone ima drugačiji naziv od naziva polja koje predstavlja, onda moramo koristiti @ColumnInfo anotaciju i u zagradi definisti drugačije ime kolone.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Entity(tableName = "buy_item_table") public class BuyItem { @ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) private int mId; @ColumnInfo(name = "name") private String mName; @ColumnInfo(name = "amaunt") private String mAmount; @ColumnInfo(name = "time") private String mTimestamp; } |
@Embedded
Sa ovom notacijom je moguće ugneziditi jednu tabelu unutar druge (one-one reacija).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Address { public String street; public String state; public String city; @ColumnInfo(name = "post_code") public int postCode; } @Entity public class User { @PrimaryKey public int id; public String firstName; @Embedded public Address address; } |
Sada možemo praviti upite (query) u ovoj tabeli a User objekat ima sve kolone: id, firstName, street, state, city, i post_code.
NAPOMENA:
Možemo dodati prefiks svim nazivima kolona ubačene (embendded) tabel ako to definišemo u zagradi sa prefiks:
1 2 |
@Embedded public (prefix = "loc_") Address address; |
@ForeignKey
Sa ovom anotacijom povezujemo dva entiteta (tabele) povezujeći njihove kolone (kolona iz child entitea sa vrednosti kolone iz parent entiteta). Ovo je praktično dodatna anotacija u okviru @Entity anotacije child entiteta:
1 |
@Entity(foreignKeys = @ForeignKey(...)) |
Primer
Prvo definišemo parent entitet (tabelu) Course.
1 2 3 4 5 6 7 8 9 10 11 12 |
@Entity(tableName = "course") public class Course { @PrimaryKey(autoGenerate = true) private long id_course; private String courseName; public Course(String courseName) { this.courseName = courseName; } } |
Prethodna tabela može biti povezana sa više studenata, pa ćemo za definisanje child tabele Student koristiti anotaciju @ForeignKey:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@Entity(@ForeignKey (entity = Course.class, parentColumns = "id_course", childColumns = "id_fkcourse", onDelete = CASCADE )) public class Student { @PrimaryKey(autoGenerate = true) private long id_student; private long id_fkcourse; private String studentName; public Student(String studentName) { this.studentName = studentName; } } |
U ovome primeru smo povezali vrednosti id kolone User tabele sa vrednosti userId Repo tabele. Značenje dodeljenih vrednosti za onDelete/onUpdate su sledeće:
- int CASCADE – Akcija „CASCADE“ propagira operaciju brisanja ili ažuriranja nadređenog ključa na svaki zavisni podređeni ključ.
- int NO_ACTION – Podrazumevano ponašanje kada se roditeljski ključ modifikuje ili izbriše iz baze podataka, i ne preduzimaju se neke druge posebne radnje.
- int RESTRICT – Akcija RESTRICT znači da je aplikaciji zabranjeno brisanje (za onDelete ()) ili menjanje (za onUpdate ()) nadređenog ključa kada postoji jedan ili više podređenih ključeva koji su na njega preslikani.
- int SET_DEFAULT – Akcije “SET DEFAULT” slične su SET_NULL, osim što je svaki od podređenih stupaca ključa postavljen tako da sadrži podrazumevanu vrednost stupaca umesto NULL.
NAPOMENA:
Poznato je da kreiranje ovakve konekcije ne mora da vodi do relacije izmedju tih tabela, već samo pomaže da se jasno definiše šta će se desiti sa “child entitetom” kada se neki član “parent entiteta” obriše (onDelete) ili ažurira (onUpdate).
@Relations
Sa ovom anotacijom je omogućeno povezivanje dve tabele bez korišćenja @Foreign.
Primer
U prethodnom primeru je prikazano povezivanje dve tabele uz pomoć anotacije @ForeignKey, isti zahtev možemo rešiti bez Foreign kluča koristeći drugu notaciju pod imenom @Relation. Za to je neophodno kreirati novu klasu sa kojom možemo napraviti instancu koja sadrži i parent entity instancu i listu child entity instanci:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class CourseWithStudents { @Embedded public Course course; @Relation(parentColumn = "id_course", entityColumn = "id_student") public List<Student> students; public CourseWithStudents(Course course, List<Student> students) { this.course = course; this.students = students; } } |
Kasnije se za kreira DAO na sledeći način:
1 2 3 4 5 6 7 8 9 10 |
@Dao public interface CourseDao { @Transaction @Insert long insertCourse(Course course); @Insert void insertStudents(List<Student> students); } |
Više o ovome u narednoj sekciji.
Data Access Object – DAO
DAO (data access object) kao što mu i samo ime kaže je: objekat za pristup podacima. DAO mora biti ili interfejs ili abstraktna klasa, tj. njegove metode nemaju telo jer će Room generisati sav nepohodan kod u zavisnosti od anotacije (@Insert, @Delete, @Query…). Upravo na taj način se smanjuje količina kod-a koju je neophodno da kreira programer.
Takodje velika prednost ovog objekta je mogućnost da validira SQL naredbe u toku kompajliranja (eng. “at compile-time”) i na taj način nam pravovremeno ukaže za greške što nije bilo moguće u radu sa SQLiteOpenHelper klasom.
@Dao
Sa ovom anotacijom se Room-u daje do znanja da je dati interfejs ili abstraktna klasa ustvari DAO. Generalno se za svaki entitet pravi njegov DAO, pa bi za naš entitet iz primera “BuyItem” Dao ovako izgledao:
1 2 3 4 |
@Dao public interface BuyItemDao { } |
@Insert
Sa ovom anotacijom se obeležava metoda koja je zadužena za unos podataka u bazu.
1 2 |
@Insert void insertItemToDB(BuyItem buyItem); |
Ovo je sasvim dovoljno da zameni celu metodu insertItemToDB() koju smo koristili u okviru primera iz SQLiteOpenHelper klase:
@Delete
Sa ovom anotacijom se obeležava metoda koja je zadužena za brisanje podataka iz baze.
1 2 |
@Delete void removeItemFromDB(long id); |
Ovo je sasvim dovoljno da zameni celu metodu removeItemFromDB() koju smo koristili u okviru primera iz SQLiteOpenHelper klase:
@Query
Sa ovom anotacijom se obeležava metoda koja je zadužena za dobijanje podataka iz baze u zavisnosti od definisnog uslova (query-ja):
1 2 |
@Query("SELECT * FROM buy_item_table ORDER BY name DESC") List<BuyItem> getAllItemsFromDB(); |
Pri pisanju ovog queryja možete primetiti da Android studio ukazuje na greške ib nudi rešenja i promenjive. A ova dva reda su sasvim dovoljno da zamene celu metodu getAllItemsFromDB() koju smo koristili u okviru primera iz SQLiteOpenHelper klase.
Prosledjivanje parametra u query
Često je potrebno proslediti neki parametar sa kojim se filtira query pa to izgleda kao u sledećem primeru:
Primer
1 2 |
@Query("SELECT * FROM user WHERE age > :minAge") public User[] loadAllUsersOlderThan(int minAge); |
Čak možemo proslediti više parametara kao u sledećem primeru:
Primer
1 2 |
@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge") public User[] loadAllUsersBetweenAges(int minAge, int maxAge); |
Takodje možemo proslediti kao parametari i neku kolekciju (može da vraća LiveData objekat):
1 2 |
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)") public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions); |
NAPOMENA:
Kada prosleđujete podatke kroz slojeve arhitekture aplikacije (iz baze podataka Room, kroz Repository klasu, dalje zatim kroz ViewModel klasu sve do korisničkog interfejsa tj. View klase (aktivnost ili fragmenta), ti podaci moraju biti LiveData u svim slojevima, ili drugim rečima svi podaci koje Room šalje iz DAO kroz neki query u Repository, a zatim iz Repository u VievModel, moraju biti LiveData. Objašnjenje ovoga leži u tome da nigde u aplikaciji nemamo potrebu da setujemo to radi Room za nas, tako da nam ne treba nigde MutableLiveData objekat (koji za razliku od LiveData objekta ima public settere) metode).
Primer
Ovako izgleda jedan tipičan Dao interfejs:
1 2 3 4 5 6 7 8 9 10 11 |
@Dao public interface BuyItemDao { @Insert void insertItemToDB(BuyItem buyItem); @Delete void removeItemFromDB(BuyItem buyItem); @Query("SELECT * FROM buy_item_table ORDER BY name DESC") LiveData<List<BuyItem>> getAllItemsFromDB(); } |
DataBase
Klasa koja predstavalja Room bazu podataka mora da bude abstract i da ekstenduje klasu RoomDatabase:
1 2 3 |
public abstract class BuyItemDB extends RoomDatabase { } |
Pored ovoga je potrebno da obeležimo ovu klasu da bi Room znao o kojoj klasi je reč, a to se postiže kroz anotaciju @Database. U nastavku ove anotacije (u zagradi) definišemo koje sve entitete (tabele) ova baza sadrži, kao i trenutnou verziju baze:
1 2 3 4 |
@Database(entities = {BuyItem.class}, version = 1) public abstract class BuyItemDB extends RoomDatabase { } |
Kreiranje baze
Pri kreiranju baza je “pametno” da koristimo singleton patern. Baza se kreira uz pomoć Room metode pod nazivom databaseBuilder(). Ova metoda prihvata parametre: context, “klasu koja definiše bazu” i naziv baze. Da bi se baza kreirala i inicijalizovala potrebno je da pozovemo metodu build():
1 2 |
instance = Room.databaseBuilder(context.getApplicationContext(), BuyItemDB.class, DB_NAME) .build(); |
Ako želimo da sprečimo probleme pri migraciji baza onda je dobro da pozovemo metodu fallbackToDestructiveMigration().
1 2 3 4 5 6 7 8 9 |
public static BuyItemDB instance; public static synchronized BuyItemDB getInstance(Context context){ if (instance == null) { instance = Room.databaseBuilder(context.getApplicationContext(), BuyItemDB.class, "buy_items_database") .fallbackToDestructiveMigration() .build(); } return instance; } |
NAPOMENA:
Ako radi debuging-a želite da pristupite bazi (koristeći DeviceFileExplore) i da je pregledate koristeći DB Browser for SQLite ili neku sličnu aplikaciju, potrebno je da pri kreiranju baze pozovete i metodu setJournalMode(JournalMode.TRUNCATE), u protivnom će baza koju pregledate će biti prazna.
1 2 3 4 |
instance = Room.databaseBuilder(context.getApplicationContext(), BuyItemDB.class, DB_NAME) .fallbackToDestructiveMigration() .setJournalMode(JournalMode.TRUNCATE) .build(); |
Pored ovaga potrebno je kreirati abstraktnu metodu koja će da vraća odgovarajući Dao objekat:
1 |
public abstract BuyItemDao getBuyItemDao() |
Primer
Ovako izgleda jedna klasa u celosti:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public abstract class BuyItemDB extends RoomDatabase { private static final String DB_NAME = "buy_items_database"; public static BuyItemDB instance; public static synchronized BuyItemDB getInstance(Context context){ if (instance == null) { instance = Room.databaseBuilder(context.getApplicationContext(), BuyItemDB.class, DB_NAME) .fallbackToDestructiveMigration() .build(); } return instance; } public abstract BuyItemDao getBuyItemDao(); } |
Repository
Iako ova klasa ne pripada direktno Room biblioteci već je hijerarhijski iznad nje, ipak u ovome članku će biti obradjena zbog toga što Room biblioteka ne dozvoljava izvršenje operacija nad bazom iz glavnog threed-a! Zbog ovoga razloga sve metode vezane za CRUD operacije moraju da se izvršavaju na background thread-u, a jedini izuzetak je metoda koja vraća LiveData objekat, jer o njoj Room vodi računa i automatski je poziva iz background thread-a.
Jedan od načina da se neka metoda izvrši na background thread-u je da ona extenduje AsyncTask klasu, medjutim od verzije androida 11 (API 30) je klasa AsyncTask ukinuta, te je neophodno koristiti drugi pristup (pogledajte ovde kako bi izgledala naša Repository klasa ako bi koristili AsyncTask metode). Pošto moramo da izbegnemo korišćenje AsyncTask, drugi način za izvršenje je korišćenje Executor objekta.
Exexutor servis se kreira u Database klasi uz definisanje neohodnog broja thread-ova:
1 2 3 4 5 6 |
@Database(entities = {BuyItem.class},exportSchema = false, version = 1) public abstract class BuyItemDB extends RoomDatabase { private static final String DB_NAME = "buy_items_database"; private static final int NUMBER_OF_THREADS = 3; public static final ExecutorService databaseWriteExecutor = Executors.newFixedThreadPool(NUMBER_OF_THREADS); |
Sada u okviru naše repository klase možemo iskoristi ExecutorService i izvršiti DAO metodu asihorono sa backround thread-a.
Primer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
public class BuyItemsRepository { private BuyItemDao buyItemDao; private LiveData<List<BuyItem>> listFromDB; public BuyItemsRepository(Application app){ BuyItemDB database = BuyItemDB.getInstance(app); buyItemDao = database.getBuyItemDao(); listFromDB = buyItemDao.getAllItemsFromDB(); } /* Static instance - Singleton pattern */ private static BuyItemsRepository mInstance = null; public static BuyItemsRepository getInstance(Application application){ if(mInstance == null){ mInstance = new BuyItemsRepository(application); } return mInstance; } /* Kod ove metode ne moramo da brinemo o izvršenju sa background thread-a jer o tome brine Room */ public LiveData<List<BuyItem>> getAllItemsFromRepo() { return listFromDB; } /* Call DB method from repo on background thread */ public void insertItem (BuyItem buyItem) { BuyItemDB.databaseWriteExecutor.execute(new Runnable() { @Override public void run() { buyItemDao.insertItemToDB(buyItem); } }); } /* Call DB method from repo on background thread */ public void removeItem(BuyItem buyItem) { BuyItemDB.databaseWriteExecutor.execute(()-> { buyItemDao.removeItemFromDB(buyItem); }); } } |
Ceo projekat iz primera možete naći na GIthub-u pod naziom “sqliteWithRoomLib”.
Primer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
public class BuyItemsRepository { private BuyItemDao buyItemDao; private LiveData<List<BuyItem>> listFromDB; public BuyItemsRepository(Application app){ BuyItemDB database = BuyItemDB.getInstance(app); buyItemDao = database.getBuyItemDao(); listFromDB = buyItemDao.getAllItemsFromDB(); } /* Static instance - Singleton pattern */ private static BuyItemsRepository mInstance = null; public static BuyItemsRepository getInstance(Application application){ if(mInstance == null){ mInstance = new BuyItemsRepository(application); } return mInstance; } /* Kod ove metode ne moramo da brinemo o izvršenju sa background thread-a jer o njoj brine Room */ public LiveData<List<BuyItem>> getAllItemsFromRepo() { return listFromDB; } /* Call DB method from repo on background thread */ public void insertItem (BuyItem buyItem) { new InsertBuyItemAsyncTask(buyItemDao).execute(buyItem); } private static class InsertBuyItemAsyncTask extends AsyncTask<BuyItem, Void, Void> { private final BuyItemDao buyItemDao; private InsertBuyItemAsyncTask(BuyItemDao buyItemDao) { this.buyItemDao = buyItemDao; } @Override protected Void doInBackground(BuyItem... buyItem) { buyItemDao.insertItemToDB(buyItem[0]); return null; } } /* Call DB method from repo on background thread */ public void removeItem(BuyItem buyItem) { new RemoveBuyItemAsyncTask(buyItemDao).execute(buyItem); } private static class RemoveBuyItemAsyncTask extends AsyncTask<BuyItem, Void, Void> { private final BuyItemDao buyItemDao; private RemoveBuyItemAsyncTask(BuyItemDao buyItemDao) { this.buyItemDao = buyItemDao; } @Override protected Void doInBackground(BuyItem... buyItem) { buyItemDao.removeItemFromDB(buyItem[0]); return null; } } } |
1 2 3 |
public void removeItemFromDB(long id) { db.delete(DBContract.BuyListTable.TABLE_NAME, DBContract.BuyListTable._ID + "=" + id, null); } |
1 2 3 4 5 6 7 8 |
public void insertItemToDB(BuyItem buyItem) { ContentValues cv = new ContentValues(); cv.put(DBContract.BuyListTable.COLUMN_NAME, buyItem.getName()); cv.put(DBContract.BuyListTable.COLUMN_AMOUNT, buyItem.getAmount()); cv.put(DBContract.BuyListTable.COLUMN_TIME_STAMP, buyItem.getTimeStamp()); db.insert(DBContract.BuyListTable.TABLE_NAME, null, cv); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public List<BuyItem> getAllItemsFromDB() { List<BuyItem> buyItemsList = new ArrayList<>(); Cursor cursor = db.query( DBContract.BuyListTable.TABLE_NAME, null, null, null, null, null, DBContract.BuyListTable.COLUMN_TIMESTAMP + " DESC" ); /*Popunjavnje liste iz Cursora*/ while (cursor.moveToNext()){ BuyItem item = new BuyItem(); item.setName(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_NAME))); item.setId(cursor.getInt(cursor.getColumnIndex(DBContract.BuyListTable._ID))); item.setAmount(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_AMOUNT))); item.setmTimestamp(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_TIMESTAMP)));setItemFromCurrentCursor(item, cursor); buyItemsList.add(item); } return buyItemsList; } |
fallbackToDestructiveMigration()
Ova metoda je zadužena da migraciju baze kada menjamo verziju baze i ima sličnu ulogu kao OnUpgrade() metoda iz klase SQLiteOpenHelper.
1 2 3 4 5 6 7 |
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { /* Uništava tabelu */ db.execSQL("DROP TABLE IF EXISTS " + DBContract.BuyListTable.TABLE_NAME); /* Ponovno kreiranje tabele */ onCreate(db); } |
Ova metoda omogućava Room biblioteci da destruktivno ponovo kreira tabele baze podataka ako nisu pronađene migracije koje bi migrirale stare šeme baze podataka na najnoviju verziju šeme. Više o migraciji sa Room biblotekom pročitajte ovde.
Primary key
Primary key je kolona koja predstavlja jedinstveni indetifiaktor reda u tabeli baze podataka. Svaka tabela mora da ima bar jedan Primary key koji ne sme biti NULL. Primary key se definiše pri kreiranju tabele na sledeći način:
1 2 3 4 |
CREATE TABLE table_name( naziv_kolone_koja_je_identifikator INTEGER NOT NULL PRIMARY KEY, ... ); |
Druga sintaksa za definisanje Primary key-a ovako izgleda:
1 2 3 4 5 6 7 8 |
CREATE TABLE languages ( naziv_kolone_koja_je_identifikator INTEGER, name TEXT NOT NULL, . . . PRIMARY KEY (naziv_kolone_koja_je_identifikator) ); |