持久庫Room

Room在SQLite上提供了一個抽象層,以便在利用SQLite的全部功能的同時使流暢的數據庫訪問。

需要處理一些重要的結構化數據的App通常會從本地的持久數據中受益匪淺。最常見的就是使用本地緩存,這樣的話下次如果設備無法聯網用戶也能瀏覽本地數據並進行更改。等下次聯網后再和服務器進行同步。

Android的Framework為了支持處理原始SQL而提供了SQLite這一強大的API,當時SQLite的API還是相對比較低級,在使用的時候需要花費大量的經歷:

  • 沒有對原始SQL語句的編譯時驗證,隨着數據庫表格的更改,你需要更新相關SQL操作,而這個過程可能耗時且容易出錯。
  • 你需要使用大量的樣板代碼在SQL查詢和Java數據對象之間進行轉換。

Room在為SQL提供抽象層的同時也會考慮到上述的問題。

下面是Room中三個主要組件:

  • Database:此組件用於創建數據庫的持有者,同時在類層級上使用註解來定義一系列的Entity,這些Entity對應着數據庫中的表格。Database類中的方法則用來獲取對應的DAO列表。Database是App層與底層SQLite之間的連接點。
    在應用中要使用此組件的話需要繼承RoomDatabase。然後通過Room.databaseBuilder()或者Room.inMemoryDatabaseBuilder().獲得該類的實例。(講到這裏其實讀者可以發現,這不就是GreenDao嗎??)。

  • Entity:此組件的一個實例表示數據庫的一行數據,對於每個Entity類來說,都會有對應的table被創建。想要這些Entity被創建,就需要寫在上面Database的註解參數entities列表中。默認Entity中的所有字段都會拿來創建表,除非在該字段上加上@Ignore註解。

注意:Entity默認都只有空的構造方法(如果DAO類可以訪問每個持久化字段),或者構造方法的參數與Entity中的字段的類型和名字相匹配。Room可以使用全字段構造方法,也可以使用部分字段構造方法。

  • DAO:這個組件用來表示具有Data Access Object(DAO)功能的類或接口。DAO類是Room的重要組件,負責定義訪問數據庫的方法。繼承RoomDatabase的類必須包含一個0參數且返回DAO類的方法。當在編譯期生成代碼的時候,Room會創建實現此DAO的類。

注意:通過使用DAO類而不是傳統的查詢接口來訪問數據庫,可以做到數據庫組件的分離。同時DAO可以在測試APP時支持Mock數據。

下面是其三者和數據庫的關係圖:


room architecture

下面看一下簡單的實例,其包含一個Entity,一個Dao以及一個Database。

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 and setters are ignored for brevity,
    // but they're required for Room to work.
}

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實例:

AppDatabase db = Room.databaseBuilder(getApplicationContext(),
        AppDatabase.class, "database-name").build();

注意:實例化AppDatabase對象時,應遵循單例設計模式,因為每個數據庫實例都相當昂貴,而且很少需要訪問多個實例。

Entity

當一個類被添加了@Entity註解並且在Database的@entities被引用,Room就會為其創建對應的數據庫。

默認情況Room會為Entity的每個字段創建對應的數據庫列,如果某個字段不想被創建的話可以使用@Ignore註解:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}

為了Room可以訪問到Entity的字段,你可以將這些字段聲明為public,或者可以給這些字段提供settergetter方法。如果使用setter和getter的話,需要注意命名規則。具體參照Java Beans

Primary key

每個Entity至少定義一個主鍵,即使你的Entity只有一個字段也是如此。定義主鍵使用@PrimaryKey。如果你想讓Room給你的Entity自動生成ID的話,可以使用@Primary的autoGenerate屬性。如果Entity具有複合主鍵的話,可以使用@Entity的primaryKeys屬性,參照下方代碼:

@Entity(primaryKeys = {"firstName", "lastName"})
class User {
    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}

默認情況Room使用Entity的類名來作為數據庫的表名。如果想自定義表名,可以使用@Entity的tableName屬性,如下:

@Entity(tableName = "users")
class User {
    ...
}

注意:SQLite中的表名是大小寫不敏感的。

與上面的tableName類似,Room使用Entity的字段名來作為對應的列名,如果想要自定義類名,可以使用@ColumnInfo註解的name屬性,如下:

@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上添加索引可以使用@Entity的indices屬性,可以添加索引或組合索引:

@Entity(indices = {@Index("firstName"), @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設置為ture來實現這一唯一性。以下代碼用於放置User表中出現姓名組合相同的數據。

@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是關係型數據庫,所以你可以指定對象之間的關係,但在Room中這是命令禁止的。

雖然在Room中的Entity不能有直接的引用關係,但Room任然支持在Entity間定義Foreign Key

例如有個另一個Entity叫做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;
}

外鍵是十分強大的,它允許你指定引用實體發生更新是發生的行為,比如,當需要刪除一個用戶的時候刪除其下所有的圖書,只需要為Book的@ForeignKey的屬性onDelete設置為CASCADE

注意:SQLite在處理@Insert(onConflict=REPLACE)的時候,其實是進行了REMOVEREPLACE兩個操作,而不是單單的UPDATE。此時這裏的REMOVE操作可能會影響到對應的外鍵,

嵌套對象

有時你需要在數據庫邏輯中表達一個實體或者Java類,你可以使用@Embedded註解來實現。具體看例子。

例如上面的User實體有一個Address類型的字段,Address包含了street,city,statepostCode這幾個字段。當生成表格時,Address中的字段將被分別定義為User表中的列名。如下:

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, citypost_code

注意:以上是可以多重嵌套的。

如果User中嵌套的A和B中存在相同字段,可以使用@Embedded的prefix屬性,Room會在生成table的時候將prefix的值加在列名前。

Data Access Objects (DAOs)

Room中的主要組件就是Dao,DAO以簡潔的方式抽象訪問數據庫。

Intert

當你創建了一個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);
}

如果插入方法只接受一個參數的話,表示僅僅插入一條數據,這是這個方法可以返回一個long型值,為新行的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方法都會在編譯期做校驗,所以如果query存在問題的話,你的App編譯將無法通過。

Room同時也會校驗query的返回值,如果返回結果和查詢語句中的結果不匹配,Room將會以一下兩種方式提醒你:

  • 如果有部分字段匹配的話會給出警告。
  • 如果沒有字段匹配,則給出錯誤提示。

簡單的查詢

@Dao
public interface MyDao {
    @Query("SELECT * FROM user")
    public User[] loadAllUsers();
}

這是一個加載所有用戶的查詢,寫法比較簡單。在編譯期,Room知道需要查詢User的所有列的值。如果查詢語句包含語法錯誤或者沒有user這個表,則Room會在編譯時期報錯並給出錯誤信息。

查詢的參數傳遞

大部分情況,你需要給查詢語句傳遞特定的參數,比如查詢特定年齡段的User,如下:

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge")
    public User[] loadAllUsersOlderThan(int minAge);
}

在編譯器處理這個查詢操作的時候,Room會將參數minAge與:minAge進行綁定。如果此時無法匹配,則會出現編譯錯誤。

當然也可以傳遞多個參數,如下:

@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);
}

返回所有列的子集

通常你需要的只是Entity的一部分字段,例如你的UI只需要先死User的姓名,而不是所有信息。這是為了保證UI的更新速度,你會選擇只查詢姓名這個兩個數據。

只要可以將查詢的結果集映射到返回對象的字段,你就可以返回任何對象,如下:

public class NameTuple {
    @ColumnInfo(name="first_name")
    public String firstName;

    @ColumnInfo(name="last_name")
    public String lastName;
}

現在你可以在DAO中使用NameTuple了。

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}

Room能夠返回的first_namelast_name能夠映射到NameTuple,所以Room會生成相應的賦值代碼。如果返回字段太多或者字段不存在於NameTuple中,則會發生編譯出錯。

注意:這裏的NameTuple也可以使用@Embedded註解。

將集合作為參數傳遞

有些情況當你查詢時需要傳遞較多的變量,例如想要查詢某一地區集合下的所有用戶,這個集合可能包含幾十個地區,如果用上述簡單的參數傳遞恐怕夠嗆,現在看看怎麼用集合傳遞:

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}

Room可以判斷你傳遞的是集合,並在SQL語句中將你的參數進行展開並填充。

可監聽的查詢

在進行查詢的時候,你希望UI會在查詢結束后自動更新UI,為了滿足這一點,這裏可以使用前面講到的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同樣支持返回ExJava2中的PublisherFlowable對象,如下:

@Dao
public interface MyDao {
    @Query("SELECT * from user where id = :id LIMIT 1")
    public Flowable<User> loadUserById(int id);
}

直接返回Cursor

如果你的App中有部分邏輯需要直接用Cursor的話,可以將DAO的返回值設置為Curso,如下:

@Dao
public interface MyDao {
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    public Cursor loadRawUsersOlderThan(int minAge);
}

注意:Room很不推薦使用以上Cursor的方法,應為你並不知道Cursor有無數據或者包含哪些列。

多表聯查

Room支持多表聯查,如果返回數據是可監聽的,那麼Room會監聽所有查詢中涉及到的表並及時更新數據。
下面這個例子是通過內聯查詢某個名字下借閱的圖書:

@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);
}

你也可以通過查詢返回純java對象,如下:

@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();

   // You can also define this class in a separate file, as long as you add the
   // "public" access modifier.
   static class UserPet {
       public String userName;
       public String petName;
   }
}

類型轉換

Room中的類型轉換支持你將某個類的值存儲到某一列中,為此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();
    }
}

這樣定義完以後,下次Room遇到Date,就能將其轉換成Room所支持的Long了。

下面看看AppDatabase要怎麼寫:

@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

在AppDatabase上添加TypeConverters註解,並將Converter作為其參數。

接着User實體:

@Entity
public class User {
    ...
    private Date birthday;
}

然後是DAO:

@Dao
public interface UserDao {
    ...
    @Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
    List<User> findUsersBornBetweenDates(Date from, Date to);
}

這裏你可以對@TypeConverter做一些範圍限制,比如限制只能在某個Entity,某個DAO或某個DAO方法中使用。詳細說明可見@TypeConverter文檔。

數據庫迭代升級

當你的App迭代升級的時候,也需要給你的Entity做迭代升級,為此你將修改Entity的代碼。當你的用戶升級到最新的App版本的時候,你可不希望他們丟失老版本的所有數據,尤其是在沒有服務器備份的情況下。

Room支持通過寫Migration類來保留用戶數據。每個Migration都需要指定上一個版本和現在的版本,在App運行的時候,Room會運行每一個Migration的migrate方法,並使用正確順序將數據庫升級到最新版本。

注意:如果你不提供Migration的話,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會進行schema的驗證,如驗證有誤,則會拋出異常。

測試升級

Migration並不是簡單的數據庫寫入操作,一旦升級失敗,會對App致命的Crash。為了保證應用的穩定性,應該事先測試Migration,Room提供了一套測試框架,下面我們來簡單學習下。

導出Schema文件

Room需要將你數據庫的Schema已Json格式的文件導出,為了導出Schema,需要在build.gradle中做如下配置:

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

你需要將導出的Json文件保存起來,以便Room通過schema文件創建老版數據庫進行升級測試。

為了進行升級測試,需要將android.arch.persistence.room:testing添加到你的測試依賴當中,然後添加如下配置:

android {
    ...
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

測試框架提供了名為MigrationTestHelper的類,它可以讀取schema文件,這也是一個遵循Junit4測試原則的類。具體測試代碼如下:

@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 has schema version 1. insert some data using SQL queries.
        // You cannot use DAO classes because they expect the latest schema.
        db.execSQL(...);

        // Prepare for the next version.
        db.close();

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

測試數據庫

當你的應用程序運行測試時,如果你沒有測試數據庫本身,則不需要創建完整的數據庫。Room允許你輕鬆地模擬測試中的數據訪問層。這個過程是可能的,因為您的DAO不會泄露您的數據庫的任何細節。測試其餘的應用程序時,應該創建DAO類的模擬或假的實例。

這裏推薦在Android設備上編寫JUnit測試,因為這些測試並不需要UI的支持,所以這些測試會比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));
    }
}

補充:禁止Entity之間的相互引用

將數據庫中的關係映射到相應的對象模型是一個常見的做法,在服務器端可以很好地運行,在訪問它們時,它們可以很方便地加載字段。

然而,在客戶端,延遲加載是不可行的,因為它可能發生在UI線程上,並且在UI線程中查詢磁盤上的信息會產生顯着的性能問題。UI線程有大約16ms的時間來計算和繪製Activity的更新的布局,所以即使一個查詢只需要5 ms,你的應用程序仍然可能耗盡用於繪製的時間,引起明顯的卡頓。更糟糕的是,如果并行運行單獨的事務,或者設備忙於其他磁盤重的任務,則查詢可能需要更多時間才能完成。但是,如果不使用延遲加載,則應用程序將獲取比其需要的更多數據,從而產生內存消耗問題。

ORM通常將此決定留給開發人員,以便他們可以為應用程序的用例做最好的事情。不幸的是,開發人員不會在他們的應用程序和UI之間共享模型。UI隨着時間的推移而變化,難以預料和調試的問題會不斷出現。

例如,使用加載Book對象列表的UI為例,每本書都有一個Author對象。你可能最初設計你的查詢時使用延遲加載,以便Book的實例使用getAuthor()方法來返回作者。一段時間后,你意識到需要在應用中显示作者姓名。你可以輕鬆添加方法調用,如以下代碼片段所示:

authorNameTextView.setText(user.getAuthor().getName());

就這麼一個簡單的操作,導致了在主線程中訪問數據庫。如果Author用引用了另一張表,那情況可能更糟糕。如果需求變化,這個界面不在需要作者姓名,那麼你的代碼可能會做無畏的延遲加載。

基於以上原因,Room禁止Entity之間的引用,如果需要加載相關數據,可以使用显示的方法去加載。