Bazy danych
Przechowywanie informacji w bazie danych jest jednym z najpopularniejszych sposobów zarządzania zbiorem danych o ustalonej strukturze. Doskonale spełnia swoją rolę w sytuacji gromadzenia przede wszystkim wielu obiektów zdefiniowanych typów (np. kontakty). Podobnie jak w przypadku plików w storage
czy wartości prymitywnych w SharedPreferences
dane pozostają dostępne do odczytu i zapisu niezależnie od cyklu życia aplikacji. Są zachowane w pamięci wewnętrznej urządzenia dopóki nie zostaną usunięte programowo lub ręcznie przez użytkownika poprzez wyczyszczenie danych aplikacji. Android
wykorzystuje bazy danych w schemacie SQL
w implementacji SQLite
. Pomimo możliwości bezpośredniego operowania na bazie danych za pomocą zapytań SQLite
przez klienta SQLiteOpenHelper
wysoce zalecane jest użycie biblioteki Room
dostarczającej dodatkowej warstwy abstrakcji.
Implementacja
Jedną z głównych zasad tworzenia baz danych SQL
jest formalna deklaracja struktury. Definiuje ona sposób w jaki baza danych jest organizowana oraz jakie typy obiektów mogą być przechowywane i modyfikowane. Dane znajdują się w tabelach zbudowanych z kolumn i wierszy. Reprezentacją jednego wpisu (porcji informacji) jest encja. Aby zaimplementować bazę danych należy rozszerzyć klasę SQLiteOpenHelper
oraz nadpisać metody onCreate
i onUpgrade
definiując jej strukturę. Modyfikacja zawartości bazy odbywa się przy pomocy zapytań SQL
w metodach put
, query
, delete
, update
.
//use SQLiteDatabase in some client
class SQLiteActivity : AppCompatActivity() {
private lateinit var database : SQLiteDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//get instance of writable or readable database to reuse it in multiple queries
database = Database(this).writableDatabase
//use database by defined methods below
}
override fun onDestroy() {
//close costly database connection
database.close()
super.onDestroy()
}
//some database manage methods, there could be inside some manager with conversion some object type into raw data
//notice how many lines of code are needed to do simple single query operation
fun put() {
//prepare data
val values = ContentValues()
values.put(DatabaseContract.Person.COLUMN_NAME, "Jack")
values.put(DatabaseContract.Person.COLUMN_AGE, 50)
//put the data and get id of new entry, returns -1 if fails
val id = database.insert(DatabaseContract.Entry.TABLE_PERSON, null, values)
}
fun read() {
//define the query params
val table = DatabaseContract.Person.TABLE_PERSON
val columns : Array<String>? = null //get all columns
val selection = DatabaseContract.Person.COLUMN_NAME + " = ?" //columns for WHERE
val args = arrayOf("Jack") //values for WHERE
val groupBy = null //ignore
val filterBy = null //ignore
val sortOrder = DatabaseContract.Person.COLUMN_AGE + " DESC"
//make query and read data from the cursor by iterator methods
val cursor : Cursor = database.query(table, columns, selection, args, groupBy, filterBy, sortOrder)
while (cursor.moveToNext()) {
val age = cursor.getString(cursor.getColumnIndex(DatabaseContract.Person.COLUMN_AGE))
//do something with item
}
cursor.close()
}
fun delete() {
//define query params
val table = DatabaseContract.Person.TABLE_PERSON
val selection = DatabaseContract.Person.COLUMN_NAME + " LIKE ?"
val args = arrayOf("Jack")
//delete entries and get number of the removed items
val count = database.delete(table, selection, args)
}
fun update() {
//prepare data
val values = ContentValues()
values.put(DatabaseContract.Person.COLUMN_AGE, 51)
//define query params
val table = DatabaseContract.Person.TABLE_PERSON
val selection = DatabaseContract.Person.COLUMN_NAME + " LIKE ?"
val args = arrayOf("Jack")
//update entries and updated count
val count = database.update(table, values, selection, args)
}
}
//extend SQLiteOpenHelper
class Database(context : Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
const val DATABASE_VERSION = 1
const val DATABASE_NAME = "Person.db"
}
//define SQL queries, use DatabaseContract
private val SQL_CREATE =
"CREATE TABLE ${DatabaseContract.Person.TABLE_PERSON} (" +
"${BaseColumns._ID} INTEGER PRIMARY KEY," +
"${DatabaseContract.Person.COLUMN_NAME} TEXT," +
"${DatabaseContract.Person.COLUMN_AGE} INTEGER)"
private val SQL_DELETE =
"DROP TABLE IF EXISTS ${DatabaseContract.Person.TABLE_PERSON}"
override fun onCreate(db: SQLiteDatabase?) {
//just create database with specific structure
db?.execSQL(SQL_CREATE)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
//do something when database structure has updated, e.g. clear all old data
db?.execSQL(SQL_DELETE)
onCreate(db)
}
//implement other methods like onDowngrade, onConfigure
}
//good practice is to define database scheme contract to simpler manage it
object DatabaseContract {
//create inner class for each Table, BaseColumns has primary key field called _ID
object Person : BaseColumns {
const val TABLE_PERSON = "person"
const val COLUMN_NAME = "name"
const val COLUMN_AGE = "age"
}
}
Ograniczenia
SQLiteOpenHelper
umożliwia zarządzanie zarówno strukturą jak i zawartością bazy danych. Jest narzędziem niższego poziomu, które wymaga od programisty implementacji kodu związanego ze strukturą i zapytaniami do bazy co jest podatne na błędy z uwagi na brak weryfikacji poprawności poleceń SQL
i zgodności ze schematem bazy. Wymaga generowania nadmiarowego kodu konwersji zapytań SQL
do obiektów i na odwrót (praktycznie jedna metoda dla każdej atomowej operacji dla danego typu). Rozwiązaniem tych problemów może być wykorzystanie biblioteki Room
.
Room
Room
dostarcza warstwy abstrakcji dla SQLite
dzięki czemu dostęp i zarządzanie bazą danych staje się łatwiejsze, a pisanie kodu szybsze. Weryfikuje poprawność zapytań SQL
już w trakcie kompilacji co znacznie zmniejsza możliwość popełnienia błędu. Ponadto wspiera mechanizm transakcji i dostarcza wiele adnotacji redukując tym samym potrzebę pisania wielu zapytań. Aby umożliwić współpracę z Room
należy dostarczyć trzy komponenty oznaczone jako @Database
, @Entity
, @Dao
.
Entity
Klasa oznaczona jako @Entity
reprezentuje tabele, gdzie każde jej pole jest kolumną. Pełni ona rolę modelu w bazie danych i nie zawiera żadnej logiki. @PrimaryKey
ustawia klucz podstawowy na wskazanym polu, a @ForeignKey
wskazuje klucz obcy i łączy. @ColumnInfo
definiuje nazwę kolumny, @Embedded
umożliwia dostęp do wewnętrznych pól klasy jako kolumn tabeli, natomiast @Ignore
pozwala zignorowanie konstruktorów czy pól przy tworzeniu obiektu przez RoomDatabase
(jeśli jest kilka konstruktorów).
//represents a table of database, define scheme here
@Entity(
tableName = "person",
foreignKeys = [ForeignKey(entity = Address::class, parentColumns = ["id"], childColumns = ["addressId"])])
data class Person
(
@PrimaryKey(autoGenerate = true)
val id : Long,
@ColumnInfo(name = "name") //set column nam for this field
var fullName : String,
//by default column name is field name so no need to use @ColumnInfo
var age : Int,
//some annotations can be declared as @Entity parameters e.g. foreignKeys instead of @ForeignKey
var addressId : Long,
@Embedded //fields of this class are columns of the table
var Contact : Contact
)
{
@Ignore //tell Room to ignore this constructor
constructor(fullName : String, age : Int, addressId : Long, contact : Contact) : this(0, fullName, age, addressId, contact)
//some class body
}
//by default tableName is a class name, indices speeds up select but slows down insert or update
@Entity(tableName = "address", indices = [Index(value = ["street"])])
data class Address(@PrimaryKey(autoGenerate = true) val id : Long, var city : String, var street : String) {
@Ignore
constructor(city : String, street: String) : this(0, city, street)
}
data class Contact(var phone : String, var email : String)
Dao
Klasa oznaczona jako @Dao
odpowiedzialna jest za dostarczenie deklaracji metod dostępowych do baz danych. Są one tworzone przy użyciu różnych adnotacji takich jak m.in. @Insert
, @Update
, @Delete
które automatycznie generują kod zapytań czy też przez adnotację @Query
wymagającej definicji zapytania. W przypadku ciągu operacji, które muszą zostać wykonane w ramach jednej transakcji należy użyć adnotacji @Transaction
.
//define methods to use database
@Dao
interface PersonDao {
//provide query command for @Query annotation
@Query("SELECT * FROM person")
fun getAll() : List<Person>
@Query("SELECT * FROM person WHERE name LIKE :name LIMIT 1")
fun findByName(name : String) : Person
@Query("SELECT * FROM person INNER JOIN address ON address.id = person.id WHERE address.city LIKE :city")
fun findByCity(city : String) : List<Person>
//do not have to provide any SQL with @Insert, when conflict just replace so it works also as update
//return id primary key
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(person : Person) : Long
@Insert
fun insertAll(vararg persons : Person)
//do not have to provide any SQL with @Update or @Delete but it can be also replace by normal @Query
@Update
fun update(person : Person)
@Delete
fun delete(person : Person)
//make transaction with atomic operations
@Transaction
suspend fun increaseAgeForAll() {
for (person in getAll()) {
person.age = person.age + 1
update(person)
}
}
}
@Dao
interface AddressDao {
@Query("SELECT * FROM address")
fun getAll() : List<Address>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(address: Address) : Long
@Delete
fun delete(address: Address)
}
Database
Klasa oznaczona jako @Database
łączy wybrane tabele klas @Entity
i metody dostępowe klas @Dao
w jedną całość. Taka klasa musi być abstrakcyjna i rozszerzać RoomDatabase
oraz deklarować metody abstrakcyjne zwracające obiekty @Dao
.
//serves as the access point, define list of entities associated with database
@Database(entities = [Person::class, Address::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { //must be abstract and extends RoomDatabase
//abstract no arg methods with returns of entities
abstract fun personDao() : PersonDao
abstract fun addressDao() : AddressDao
//define more methods for other entities class
}
Użycie
Tworzenie instancji bazy danych RoomDatabase
odbywa się przy użyciu budowniczego. Ze względu na kosztowność bazy warto rozważyć zastosowanie wzorca Singleton
i tym samym ograniczyć instancję do jednej dla całej aplikacji.
class RoomActivity : AppCompatActivity(), CoroutineScope by MainScope() {
lateinit var database : AppDatabase
lateinit var personDao : PersonDao
lateinit var addressDao : AddressDao
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//create or inject instance, it could be Singleton
//can be created also by inMemoryDatabaseBuilder() which helps testing
database = Room
.databaseBuilder(this, AppDatabase::class.java, "AppDatabase")
.build()
//there are more builders options like allowMainThreadQueries()
//access to database by dao
personDao = database.personDao()
addressDao = database.addressDao()
//must be run on the background thread otherwise exception will be thrown
doSomeOperations()
}
fun doSomeOperations() {
launch(Dispatchers.IO) {
createData()
val person = selectData()
updateData(person)
deleteData(person)
runTransaction()
}
}
fun createData() {
//put some data using dao example methods
val address = Address("Poznan", "sw. Marcin")
val contact = Contact("111 222 333", "jack@daniels.com")
val addressId = addressDao.insert(address)
personDao.insert(Person("Jack Daniels", 50, addressId, contact))
val polwiejskaId = addressDao.insert(Address("Poznan", "Polwiejska"))
val johnnie = Person("Johnnie Walker", 60, polwiejskaId, Contact("222 333 444", "johnnie@walker.com"))
val william = Person("William Grant", 70, polwiejskaId, Contact("333 444 555", "william@grant.com"))
personDao.insertAll(johnnie, william)
}
fun selectData() : Person {
//example of getting data usage
val persons = personDao.getAll()
val person = personDao.findByName("Jack Daniels")
return person
}
fun updateData(person : Person) {
person.age = 20
personDao.update(person)
}
fun deleteData(person : Person) {
personDao.delete(person)
}
suspend fun runTransaction() {
//run transaction by annotated method
personDao.increaseAgeForAll()
//or this can be achieve also by transaction directly on database
database.runInTransaction {
for (person in personDao.getAll()) {
person.age = person.age + 1
personDao.update(person)
}
}
}
}
Migracja
W trakcie rozwijania aplikacji nierzadko występuje potrzeba zmiany struktury bazy danych. W takiej sytuacji może okazać się, że jakaś część danych z poprzedniego formatu bazy jest nadal potrzebna. W związku z tym należy zapewnić poprawną migrację danych między wersjami bazy poprzez skonstrukowanie odpowiedniego zapytania SQL
w obiekcie Migration
oraz dodanie go przez addMigrations
w trakcie tworzenia bazy. Z uwagi na to, że proces migracji narażony jest na występowanie poważnych błędów przed wypuszczeniem wersji produkcyjnej aplikacji nie można zapomnieć o lokalnej kopii zapasowej danych i właściwych testach migracji wspieranych przez MigrationTestHelper
.
class MigrationActivity : AppCompatActivity() {
//address entity is extended by new building number column and database upgraded version
lateinit var database : AppDatabase
//provide proper SQL migration
val MIGRATION_1_2 = object: Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE address ADD COLUMN number TEXT")
//if new column can not be null inject some default values by execSQL
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
database = Room
.databaseBuilder(this, AppDatabase::class.java, "RoomDatabase")
.addMigrations(MIGRATION_1_2) //pass more of them if needed
.build()
}
}