相關文章:
- 【翻譯】安卓架構組件(1)-App架構指導
- 【翻譯】安卓架構組件(2)-添加組件到你的項目中
- 【翻譯】安卓架構組件(3)-處理生命周期
- 【翻譯】安卓架構組件(4)-LiveData
- 【翻譯】安卓架構組件(5)-ViewModel
- 【翻譯】安卓架構組件(7)-分頁庫
Room為SQLite提供了一個抽象層,使得可以流暢使用SQLite的所有功能。
處理大量結構化數據的app可以從本地數據持久化中獲取巨大利益。最常見的用例是緩存相關的數據。在這種情況下,當設備無法訪問網絡的時候,用戶仍然可以在離線時瀏覽內容。任何用戶原始數據的變化都會在連接網絡后同步。
核心框架提供了原生SQL的支持。盡管這些API很強大,但是比較底層并且需要花費大量的時間和努力去使用:
- 沒有原生SQL查詢語句的編譯時驗證。當你的數據結構變化時,你需要手動更新受影響的SQL。這個過程會花費大量的時間并且很容易錯誤頻出。
Room考慮到了這些,提供了SQLite的抽象層。
Room有三個主要的組件:
- 數據庫(Database):你可以使用該組件創建數據庫的持有者。該注解定義了實體列表,該類的內容定義了數據庫中的DAO列表。這也是訪問底層連接的主要入口點。注解類應該是抽象的并且擴展自
RoomDatabase
。在運行時,你可以通過調用Room.databaseBuilder()
或者Room.inMemoryDatabaseBuilder()
獲取實例。 - 實體(Entity):這個組件代表了持有數據庫表記錄的類。對每種實體來說,創建了一個數據庫表來持有所有項。你必須通過
Database
中的entities
數組來引用實體類。實體的每個成員變量都被持久化在數據庫中,除非你注解其為@Ignore
。
實體類可以擁有無參數構造函數(如果DAO類可以訪問每個持久化成員變量)或者擁有和實體類成員變量匹配參數的構造函數。Room也可以使用全部或者部分構造函數,例如只接收部分成員變量的構造函數。
- 數據訪問對象(DAO):這個組件代表了作為DAO的類或者接口。DAO是Room的主要組件,負責定義訪問數據庫的方法。被注解
@Database
的類必須包含一個無參數的抽象方法并返回被@Dao
注解的類型。當編譯時生成代碼時,Room會創建該類的實現。
通過使用DAO類訪問數據庫而不是查詢構建器或直接查詢,你可以將數據庫架構的不同組件分離。此外,DAO允許你在測試時很容易地模擬數據訪問。
這些組件和app的其他部分關系圖如下:
(img)
下面的代碼片段包含了簡單的數據庫配置,含有1個實體和一個DAO:
//User.java
@Entity
public class User {
@PrimaryKey
private int uid;
@ColumnInfo(name = "first_name")
private String firstName;
@ColumnInfo(name = "last_name")
private String lastName;
// 省略Getters Setters(實際代碼中不可省略)
}
//UserDao.java
@Dao
public interface UserDao {
@Query("SELECT * FROM user")
List<User> getAll();
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
List<User> loadAllByIds(int[] userIds);
@Query("SELECT * FROM user WHERE first_name LIKE :first AND "
+ "last_name LIKE :last LIMIT 1")
User findByName(String first, String last);
@Insert
void insertAll(User... users);
@Delete
void delete(User user);
}
//AppDatabase.java
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
創建上面的文件以后,你可以使用以下代碼獲取已創建數據庫實例:
AppDatabase db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, "database-name").build();
當實例化
AppDatabase
對象時,你可以遵循單例設計模式,因為每個RoomDatabase
實例代價是非常昂貴的,并且你幾乎不需要訪問多個實例。
實體
當一個類被@Entity
注解,并被@Database
注解的entities
屬性引用時,Room為這個實體在數據庫中創建一個表。
默認情況,Room為實體類的每個成員變量創建一個列。如果一個實體類的某個成員變量不想被持久化,你可以使用Ignore
注解標記,如:
@Entity
class User {
@PrimaryKey
public int id;
public String firstName;
public String lastName;
@Ignore
Bitmap picture;//不進行持久化
}
為了持久化成員變量,Room必須可以訪問它。你可以使成員變量是公共的,或者提供getter和setter方法。如果你使用getter/setter方法,請記住它們在Room中遵循Java Beans的概念。
主鍵
每個實體必須至少定義一個成員變量作為主鍵。甚至僅僅有一個成員變量,也要標記其為@PrimaryKey
。同時,如果你想要Room指定ID自增,你可以設置@Primary
的autoGenerate
屬性。如果實體的主鍵是綜合的,你可以使用@Entity
的primaryKeys
屬性,如:
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}
默認情況下,Room使用類名作為數據庫表的表名。如果你想要數據庫表有一個其他的名字,設置@Entity
注解的tableName
屬性即可:
@Entity(tableName = "users")
class User {
...
}
注意:SQLite中的表名是大小寫敏感的。
和tablename
屬性相似,Room使用成員名作為列名,如果你想要改變類名,在成員上添加@ColumnInfo
注解即可:
@Entity(tableName = "users")
class User {
@PrimaryKey
public int id;
@ColumnInfo(name = "first_name")
public String firstName;
@ColumnInfo(name = "last_name")
public String lastName;
@Ignore
Bitmap picture;
}
索引與唯一
取決于你如何訪問數據,你可能想要索引確切的字段以加速數據的查詢。為了向實體添加索引,在@Entity
中添加indices
屬性,列出你想要包括的字段名或者字段名組:
@Entity(indices = {@Index("name"), @Index("last_name", "address")})
class User {
@PrimaryKey
public int id;
public String firstName;
public String address;
@ColumnInfo(name = "last_name")
public String lastName;
@Ignore
Bitmap picture;
}
有些時候具體的成員或成員組必須是獨一無二的。你可以設置@Index
的屬性unique
為true
:
@Entity(indices = {@Index(value = {"first_name", "last_name"},
unique = true)})
class User {
@PrimaryKey
public int id;
@ColumnInfo(name = "first_name")
public String firstName;
@ColumnInfo(name = "last_name")
public String lastName;
@Ignore
Bitmap picture;
}
關系
因為SQLite是一個關系型數據庫,你可以指定對象間的關系。即使大多數ORM類庫允許實體對象互相引用,Room則顯式地禁止了這一點。
即使你不能直接使用關系映射,Room仍然允許你去定義實體鍵的外鍵約束。
例如,有另一個叫做Book
的實體,你可以通過使用@ForeignKey
注解定義其和User
實體的關系,如:
@Entity(foreignKeys = @ForeignKey(entity = User.class,
parentColumns = "id",
childColumns = "user_id"))
class Book {
@PrimaryKey
public int bookId;
public String title;
@ColumnInfo(name = "user_id")
public int userId;
}
外鍵是非常強大的,因為它們允許你指定引用實體更新時會發生什么、例如,你可以告訴SQLite去刪除所有的書籍,如果這些書所對應的User
被刪除并且指定了@ForeignKey
的屬性onDelete=CASCADE
。
SQLite將
@Insert(OnConfilct=REPLACE)
處理為REMOVE
和REPLACE
的集合而不僅僅是更新操作。這個替換沖突值的方法可能會對你的外鍵約束起作用。
內嵌對象
有些時候你想要一個實體類或POJO類作為數據庫邏輯的一部分。這種情況下,你可以使用@Embedded
注解來。你可以查詢內嵌成員,就像你可能查詢其他字段一樣。
例如,我們的User
類包含一個Address
類型的成員,代表了street
、city
、state
和postCode
。為了分別存儲這些字段,在User
類中包含一個Address
成員并標記為@Embedded
,如:
class Address {
public String street;
public String state;
public String city;
@ColumnInfo(name = "post_code")
public int postCode;
}
@Entity
class User {
@PrimaryKey
public int id;
public String firstName;
@Embedded
public Address address;
}
這個表代表了一個User
對象包含了以下字段:id
,firstName
,street
,state
,city
和post_code
。
內嵌成員也可以含有其他內嵌成員
如果一個實體含有多種相同類型的內嵌成員,你可以通過設置prefix
屬性保持每個字段的唯一性。Room之后添加提供的值到每個內嵌對象的起始位置。
數據訪問對象(DAO)
Room的主要組件是Dao
類。DAO以清晰的方式抽象除了訪問數據庫的行為。
Room不允許在主線程方位數據庫,除非你在Builder調用
allowMainThreadQueries()
,因為這可能會導致UI被鎖住。而異步查詢則不受此約束,因為異步調用在后臺線程運行查詢工作。
便捷方法
有很多可以使用DAO類的便捷查詢方法,例如:
Insert
當你創建一個DAO方法并標記其為@Insert
,Room會生成在單一事務中將所有參數存入數據庫的實現:
@Dao
public interface MyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
public void insertUsers(User... users);
@Insert
public void insertBothUsers(User user1, User user2);
@Insert
public void insertUsersAndFriends(User user, List<User> friends);
}
如果@Insert方法只接收一個參數,它會返回long,表示新插入項的row Id。如果參數是數組或集合,它會返回long[]
或者List<Long>
。
Update
Update是更新一組實體的便捷方法。它查詢匹配主鍵的記錄然后更新。如:
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}
盡管通常并不需要如此,你可以讓該方法返回一個int
值,表示更新至數據庫的行號。
Delete
Delete是刪除一組實體的便捷方法。它使用主鍵去尋找記錄并刪除:
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}
同上,你可以讓該方法返回一個int
值表示被刪除的行號。
使用@Query
@Query
是用于DAO類的主要注解。它允許你在數據庫上執行讀寫操作。每個Query
方法都會在編譯時驗證,因此如果查詢語句有問題,那么編譯時就會報錯,而不是在運行時發生。
Room同樣驗證查詢的返回值,如果返回對象的成員名和字段名不一致,Room會以以下兩種方式警告:
- 如果僅僅部分成員名相符,則發出警告
- 如果沒有成員名相符,則發出錯誤
簡單查詢
@Dao
public interface MyDao {
@Query("SELECT * FROM user")
public User[] loadAllUsers();
}
這是一個加載所有用戶的簡單查詢。在編譯時,Room知道這是查詢用戶表的所有字段。如果查詢語句含有語法錯誤,或者用戶表在數據庫中并不存在,Room會顯示相應的錯誤。
查詢中傳遞參數
大多數時候,你需要在查詢中傳遞參數來執行過濾操作,例如僅僅顯示具體年齡的用戶。為了完成這個任務,在你的Room注解中使用方法參數,如:
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}
當編譯時處理這個查詢時,,Room將:minAge
和minAge
匹配在一起。Room使用參數名進行匹配,如果匹配不成功,會在編譯時報錯。
你也可以傳遞多個參數或引用多次,如:
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
public User[] loadAllUsersBetweenAges(int minAge, int maxAge);
@Query("SELECT * FROM user WHERE first_name LIKE :search "
+ "OR last_name LIKE :search")
public List<User> findUserWithName(String search);
}
返回所有字段的子集
大多數時候,你可能需要一個實體的一部分成員變量,例如你的UI可能只顯示用戶的名和姓,而不是用戶的所有細節。通過僅僅獲取出現在你UI中的字段,你可以存儲很多資源,并且你的查詢完成地更快。
Room允許你從查詢中返回任何對象,只要結果字段集可以被映射到返回的對象上。例如,你可以創建下面的POJO類來獲取用戶的姓和名:
public class NameTuple {
@ColumnInfo(name="first_name")
public String firstName;
@ColumnInfo(name="last_name")
public String lastName;
}
現在你可以在你的查詢方法中這樣使用POJO類:
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user")
public List<NameTuple> loadFullName();
}
Room理解這次返回first_name
和last_name
字段的查詢,并可以映射到NameTuple
類。這樣,Room就能生成正確的代碼。如果查詢返回太多的字段,或者某個字段并不存在于NameTuple
中,Room會顯示一個警告。
傳遞參數集合
你的某些查詢可能會傳遞大量的參數,而且直到運行時才知道具體的參數。例如,你可能會獲取關于用戶所屬區域的信息。當參數為集合時,Room能夠理解并自動根據當前提供的參數進行擴展:
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public List<NameTuple> loadUsersFromRegions(List<String> regions);
}
可觀察查詢
當運行查詢時,你通常想要在數據變化的時候你的app界面自動更新。為了做到這一點,在查詢方法中使用LiveData
類型的返回值。Room會生成所有必要的代碼,當數據更新時,會自動更新LiveData
。
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}
RxJava
Room也可以從你定義的查詢中直接返回RxJava2的Publisher
和Flowable
對象。為了使用這個功能,添加android.arch.persistence.room:rxjava2
到你的Gradle構建依賴。你可以隨后返回RxJava2定義的類型,如:
@Dao
public interface MyDao {
@Query("SELECT * from user where id = :id LIMIT 1")
public Flowable<User> loadUserById(int id);
}
查詢多張表
你的一些查詢可能需要訪問多張表來計算結果。Room允許你寫任何的查詢,因此你可以使用連接表。另外,如果結果是可觀察數據類型,例如Flowable
或者LiveData
,Room會驗證所有SQL查詢語句。
下面的代碼片段展示了如何連接兩張表,一張表是包含用戶借書的信息,另一張包含當前借出的信息:
@Dao
public interface MyDao {
@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")
public List<Book> findBooksBorrowedByNameSync(String userName);
}
你也可以從這些查詢中返回POJO類。例如你可以這樣寫一個用戶和其寵物姓名的查詢語句:
@Dao
public interface MyDao {
@Query("SELECT user.name AS userName, pet.name AS petName "
+ "FROM user, pet "
+ "WHERE user.id = pet.user_id")
public LiveData<List<UserPet>> loadUserAndPetNames();
// 你也可以在單獨的文件中定義該類,只要你添加了public修飾符
static class UserPet {
public String userName;
public String petName;
}
}
使用類型轉換
Room提供內置工具用于基本類型和其封裝類型的裝換。但是有些時候你可能使用了使用了自定義數據類型,而想在數據庫表中始終單個字段。為了添加這類自定義類型支持,你需要提供一個TypeConverter
,將自定義類轉換到Room已知可以持久化的類型。
例如,如果我們想要持久化Date
實例,我們可以這樣寫:
public class Converters {
@TypeConverter
public static Date fromTimestamp(Long value) {
return value == null ? null : new Date(value);
}
@TypeConverter
public static Long dateToTimestamp(Date date) {
return date == null ? null : date.getTime();
}
}
上面的例子定義了兩個函數,一個將Date
類型轉換為Long
類型,另一個進行相反的轉換。
接下來,在AppDataBase
添加@TypeConverters
注解,使得Room可以使用你定義的轉換器:
@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
使用了這些轉換器以后,你可以在其他查詢中使用你的自定義類型,就像基本類型一樣:
//User.java
@Entity
public class User {
...
private Date birthday;
}
//UserDao.java
@Dao
public interface UserDao {
...
@Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
List<User> findUsersBornBetweenDates(Date from, Date to);
}
你可以限制@TypeConverters
的作用范圍,包括單獨實體,DAO以及DAO方法。
數據庫遷移
當你在app添加以及修改功能時,你需要修改你的實體類以響應這些變化。當一個用戶更新到最新版本的app時,你不想讓他們丟掉所有已經存在的數據,特別是不能再從遠程服務器獲取的數據。
Room允許你編寫Migration
類來保護用戶數據。每個Migration
類指定一個startVersion
和endVersion
。在運行時,Room運行每個Migration
類的migrate()
方法,使用正確的順序遷移至數據庫的更新版本。
如果你沒有提供必要的遷移,Room會重新構建數據庫,這意味著你將丟失所有數據庫中的數據。
Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
+ "`name` TEXT, PRIMARY KEY(`id`))");
}
};
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE Book "
+ " ADD COLUMN pub_year INTEGER");
}
};
為了保持你的遷移功能的正確性,使用完整的查詢語句而不是詢而不是引用表示查詢的常量。
在遷移過程完成后,Room驗證當前的表以確保遷移的正確性。如果Room找到問題,會拋出未匹配的異常信息。
測試遷移
遷移是很重要的事情,錯誤的編寫會導致你app的崩潰循環。為了保證app的穩定性,你應該測試你的遷移工作。Room提供了一個測試的Maven構件來幫助測試。但是,為了該構件可以工作,你需要導出你的數據庫表。
導出數據庫表
//build.gradle
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}
Room會將你數據庫的表信息導出為一個json文件。你應該在版本控制系統中保存該文件,該文件代表了你的數據庫表歷史記錄,這樣允許Room創建舊版本的數據庫用于測試。
為了測試遷移,添加android.arch.persistence.room:testing
到你的測試依賴,以及添加模式表的位置至asset文件夾,如:
//build.gradle
android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
測試包提供了一個MigrationTestHelper
類,可以讀取這些模式表文件。
@RunWith(AndroidJUnit4.class)
public class MigrationTest {
private static final String TEST_DB = "migration-test";
@Rule
public MigrationTestHelper helper;
public MigrationTest() {
helper = new MigrationTestHelper(InstrumentationRegistry.getContext(),
MigrationDb.class.getCanonicalName(),
new FrameworkSQLiteOpenHelperFactory());
}
@Test
public void migrate1To2() throws IOException {
SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
// db 版本為1. 使用SQL添加一些數據
// 你不能使用DAO,因為它表示的是最新的數據庫
db.execSQL(...);
// 準備下個版本
db.close();
// 重新打開數據庫版本2
db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);
}
}
測試你的數據庫
當運行測試你的app時,如果沒有測試數據庫本身,你不需要創建全部的數據庫。Room允許在你的測試中模擬數據訪問層。這個過程是可能的,因為你的DAO并沒有泄漏任何數據庫的細節。當測試剩下的app部分時,你應該創建模擬你的DAP類。
這里有兩種測試數據庫的方式:
- 在你的開發主機上
- 在Android設備上
在你的主機上測試
Room使用SQLite支持庫,提供了匹配安卓框架類的接口。這種支持允許你傳遞支持類庫的自定義實現以測試你的數據庫。
即使這種方案允許你測試非??旖荩遣⒉恢档猛扑],這是因為你設備上以及你用戶設備上運行的SQLite版本可能和你主機上運行的版本并不匹配。
在Android設備上測試
這種推薦的測試數據庫方法是編寫運行在安卓設備上的JUnit測試。因為這些測試并不需要創建Activity
,它們應該會比在UI上測試要快。
當設置你的測試時,你應該創建一個數據庫的內存版本來使得測試更密閉,如:
@RunWith(AndroidJUnit4.class)
public class SimpleEntityReadWriteTest {
private UserDao mUserDao;
private TestDatabase mDb;
@Before
public void createDb() {
Context context = InstrumentationRegistry.getTargetContext();
mDb = Room.inMemoryDatabaseBuilder(context, TestDatabase.class).build();
mUserDao = mDb.getUserDao();
}
@After
public void closeDb() throws IOException {
mDb.close();
}
@Test
public void writeUserAndReadInList() throws Exception {
User user = TestUtil.createUser(3);
user.setName("george");
mUserDao.insert(user);
List<User> byName = mUserDao.findUsersByName("george");
assertThat(byName.get(0), equalTo(user));
}
}
附加::沒有實體鍵的對象引用
從數據庫到對象間關系的映射是一個很常見的實踐,并且在服務端運行良好,在它們被訪問的時候進行高性能的惰性加載。
但是在客戶端,惰性加載并不可行,這是因為很有可能發生在主線程,在主線程查詢磁盤信息會導致很嚴重的性能問題。主線程有大概16ms來計算并繪制一個Activity
的界面更新,因此甚至一個查詢僅僅耗費5ms,你的app仍然會耗光繪制畫面的時間,導致顯著的Jank問題。更糟的是,如果有個并發運行的數據庫事務,或者如果設備正忙于處理其他磁盤相關的繁重工作,查詢會花費更多的時間完成。如果你不使用惰性加載的方式,app會獲取多余其所需要的數據,從而導致內存消耗的問題。
ORM通常將該問題交給開發者決定,使得他們可以根據自己的用例選擇最佳的方式。不幸地是,開發者通常終止模型和UI之間的共享。當UI變更超時時,問題隨之發生并且很難預感和解決。
舉個例子,UI界面讀取一組Book
列表,每本書擁有一個Author
對象。你可能開始會設計你的查詢去使用惰性加載,從而Book
實例使用getAuthor()
方法查詢數據庫。過了一些時間,你意識到你需要在app的UI界面顯示作者名。你可以添加以下方法:
authorNameTextView.setText(user.getAuthor().getName());
但是這種看似沒有問題的代碼會導致Author
表在主線程被查詢。
如果你急于查詢作者信息,這會變得很難去改變數據是如何加載的,如果你不再需要這個數據的話,例如當你app的UI不再需要顯示關于特定作者信息的時候。于是你的app必須繼續加載不再顯示的信息。這種方式更為糟糕,如果Author
類引用了其他表,例如getBooks()
方法。
由于這些原因,Room禁止實體間的對象引用。作為替換,你必須顯式地請求你所需要的數據。
簡單通俗地解釋一下Jank:第2幀畫面同步信號已經到來,由于第2幀數據還沒有準備就緒,顯示的還是第1幀。這種情況被Android開發組命名為“Jank”