Android Architecture Components 之 Room 篇

薛定貓的諤發表於2019-03-04

Room,一個 SQLite 的 ORM 庫,可以方便地將 Java 物件轉成 SQLite 的表資料,不用再像傳統方式那樣寫 SQLite API 的樣板程式碼了。同時 Room 提供了 SQLite 語法的編譯時檢查,並且可以返回 RxJava,Flowable 和 LiveData observables。

新增依賴

    // Room (use 1.1.0-beta2 for latest beta)
    implementation "android.arch.persistence.room:runtime:1.0.0"
    annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
    // Test helpers for Room
    testImplementation "android.arch.persistence.room:testing:1.0.0"
複製程式碼

基本使用

Room 主要包含三個元件:

  • Database: 包含資料庫持有者,作為與應用持久化相關資料的底層連線的主要接入點。這個類需要用 @Database 註解,並滿足下面條件:
    • 必須是繼承 RoomDatabase 的抽象類
    • 註解中包含該資料庫相關的實體類列表
    • 包含的抽象方法不能有引數,且返回值必須是被 @Dao 註解的類
  • Entity: 表示了資料庫中的一張表
  • DAO: 包含了訪問資料庫的一系列方法

它們與應用程式的關係如圖所示:

room_architecture

@Entity(tableName = "products")
public class ProductEntity {

    @PrimaryKey
    private int id;
    private String name;
    private String description;
    ...
}
複製程式碼
@Dao
public interface ProductDao {

    @Query("select * from products")
    List<ProductEntity> getAllProducts();

    @Query("select * from products where id = :id")
    ProductEntity findProductById(int id);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertProduct(ProductEntity product);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertAllProducts(List<ProductEntity> products);

    @Delete
    void deleteProduct(ProductEntity product);
}
複製程式碼
@Database(entities = {ProductEntity.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract ProductDao productDao();
}
複製程式碼
    AppDatabase appDatabase = Room.databaseBuilder(this, AppDatabase.class, "product.db").build();
    ProductDao productDao = appDatabase.productDao();
    ...
    List<ProductEntity> allProducts = productDao.getAllProducts();
    ...
    productDao.insertProduct(productEntity);
複製程式碼

每個 entity 都代表了一張表,其中的欄位代表表中的一列。註解處理器會自動生成 AppDatabaseProductDao 對應的實現類 AppDatabase_ImplProductDao_Impl。可以通過呼叫Room.databaseBuilder()Room.inMemoryDatabaseBuilder()在執行時獲取Database例項,但要注意,例項化 RoomDatabase 是相當昂貴的,最好按照單例模式只建立一個Database例項。

定義 Entity

為了讓 Room 可以訪問 entity,entity 中的欄位必須是 public 的,或者提供了getter/setter方法。預設情況下,Room 會將 entity 中的每個欄位作為資料庫表中一列,如果你不想持久化某個欄位,可以使用 @Ignore 註解。預設資料庫表名為 entity 類名,你可以通過 @Entity 註解的 tableName 屬性 更改,預設列名是欄位名,你可以通過 @ColumnInfo 註解更改。

主鍵

每個 entity 必須至少有一個欄位作為主鍵(primary key),即使該 entity 只有一個欄位。使用 @PrimaryKey 註解來指定主鍵,如果你希望 SQLite 幫你自動生成這個唯一主鍵,需要將 @PrimaryKeyautoGenerate 屬性設定成 true,不過需要改列是 INTEGER 型別的。如果欄位型別是 longintInsert 方法會將 0 作為預設值,如果欄位型別是 IntegerLong 型別,Insert 方法會將 null 作為預設值。
如果 entity 的主鍵是複合主鍵(composite primary key),你就需要使用 @Entity 註解的 primaryKeys 屬性定義這個約束,如:

@Entity(primaryKeys = {"firstName", "lastName"})
class User {
    public String firstName;
    public String lastName;
    public String avatar;
}
複製程式碼

索引

有些時候,我們需要新增索引以加快查詢速度,可以使用 @Entity 註解的 indices 屬性建立索引,如果某個欄位或欄位組是唯一的,可以將 @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 明確禁止這樣做。因為級聯查詢不能發生在 UI 執行緒,UI 執行緒只有 16 ms 時間計算和繪製佈局,所以即使一個查詢只花費 5 ms,你的應用仍可能因此繪製超時,造成明顯的視覺問題。而且如果此時還有其他的資料庫事務正在執行或者裝置正在執行其他磁碟敏感任務,那麼該查詢將花費更多的時間。而如果你不使用懶載入,你的應用將不得不去獲取比所需要的更多的資料,從而產生記憶體佔用問題。
ORM 庫通常把這個決定權交給開發者,以便開發者根據自己應用的情況採取措施,而開發者通常會決定在應用和 UI 之間共享 model,然而,這種解決方案並不能很好地擴充套件,因為隨著UI的變化,共享 model 會產生一些難以讓開發人員預測和除錯的問題。
例如,UI 載入了 Book 物件列表,每個 book 都有一個 Author 物件,你可能最開始想採用懶載入的方式獲取 Book例項(使用getAuthor() 方法獲取 author),第一次呼叫 getAuthor() 會呼叫資料庫查詢。過一會,你意識到你需要在 UI 上顯示作者名,你寫了下面這樣的程式碼:

    authorNameTextView.setText(user.getAuthor().getName());
複製程式碼

這看似正常的變更會導致 Author 表在主執行緒中被查詢。那提前查詢好作者資訊是不是就行了呢?明顯不行,如果你不再需要這些資料,就很難改變資料的載入方式了。例如,如果你的 UI 不再需要顯示作者資訊了,你的應用仍然會載入這些不需要的資料,從而浪費昂貴的記憶體空間,如果 Author 又引用了其他表,那麼應用的效率將會進一步降低。
所以為了讓 Room 能同時引用多個 entity,你需要建立一個包含每個 entity 的 POJO,然後編寫一個連線相應表的查詢。這個結構良好的 model,結合 Room 健壯的查詢校驗功能,就能夠讓你的應用花費更少的資源載入資料,提升應用的效能和使用者體驗。
雖然不能直接指定物件間關係,但可以指定外來鍵(Foreign Key)約束。例如對於 Book entity 有一個作者的外來鍵引用 User,可以通過 @ForeignKey 註解指定這個外來鍵約束:

@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;
}
複製程式碼

可以通過 @ForeignKey註解的 onDeleteonUpdate 屬性指定級聯操作,如級聯更新和級聯刪除:

@Entity(foreignKeys = @ForeignKey(entity = User.class,
                                  parentColumns = "id",
                                  childColumns = "user_id",
                                  onUpdate = ForeignKey.CASCADE,
                                  onDelete = ForeignKey.CASCADE))
複製程式碼

有時,一個包含巢狀物件的 entity 或 POJO 表示一個完整的資料庫邏輯,可以使用 @Embedded 註解將該巢狀物件的欄位分解到該表中,如 User 表需要包含 Address相關欄位,可以使用 @Embedded 註解表示這是個組合列:

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;
}
複製程式碼

也就是說, User 表包含 idfirstNamestreetstatecity,和 post_code 列。
Embedded 欄位也能包含其他 Embedded 欄位。
如果有另一個組合列也是 Address 型別的,可以使用 @Embedded 註解的 prefix 屬性新增列名字首以保證列的唯一性。

使用 DAO

DAO(data access objects)是應用中運算元據庫的最直接的介面,應用中對資料庫的操作都表現在這個物件上,也就是說,應用不需要知道具體的資料庫操作方法,只需要利用 DAO 完成資料庫操作就行了,所以這一系列 Dao 物件也構成了 Room 的核心元件。DAO 可以是個介面,也可以是個抽象類,如果是個抽象類,那麼它可以有個構造器,以 RoomDatabase 作為唯一引數,Room 會在編譯時自動生成每個 DAO 的實現類。

新增

定義一個用 @Insert 註解的 DAO 方法,Room 會自動生成一個在單個事務中將所有引數插入資料庫的實現,如果方法只有一個引數,那麼它可以返回 long 型別的 rowId,如果方法引數是陣列或集合,那麼它可以返回 long[]List<Long>:

@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);
}
複製程式碼

更新

@Update 註解的方法可以更改一系列給定的 entity, 使用匹配的主鍵去查詢更改這些 entity,可以返回 int 型的資料庫更新行數:

@Dao
public interface MyDao {
    @Update
    public void updateUsers(User... users);
}
複製程式碼

刪除

@Delete 註解的方法可以刪除一系列給定的 entity, 使用匹配的主鍵去查詢更改這些 entity,可以返回 int 型的資料庫刪除行數:

@Dao
public interface MyDao {
    @Delete
    public void deleteUsers(User... users);
}
複製程式碼

查詢

@Query 註解的方法可以讓你方便地讀寫資料庫,Room 會在編譯時驗證這個方法,所以如果查詢有問題編譯時就會報錯。Room 還會驗證查詢的返回值,如果查詢響應的欄位名和返回物件的欄位名不匹配,如果有些欄位不匹配,你會看到警告,如果所有欄位都不匹配,你會看到 error。下面是一個簡單的查詢,查詢所有的使用者:

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

如果你想要新增查詢條件,可以使用 :引數名 的方式獲取引數值:

@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);
}
複製程式碼

當然,查詢條件集合也是支援的:

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

很多時候,我們不需要查詢表中的所有欄位,我們只用到了 UI 用到的那幾列,為了節省資源,也為了加快查詢速度,我們就可以定義一個包含用到的欄位的 POJO(這個 POJO 可以使用 @Embedded 註解) ,查詢方法可以使用這個 POJO:

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

    @ColumnInfo(name="last_name")
    public String lastName;
}
複製程式碼
@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}
複製程式碼

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);
}
複製程式碼

多表查詢也能使用 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();

   // 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;
   }
}
複製程式碼

查詢方法的返回值可以是 LiveData 以便你能隨著資料庫的更新實時更新 UI,返回值也可以是 RxJava2PublisherFlowable(需要新增 android.arch.persistence.room:rxjava2 依賴),甚至可以是 Cursor(不建議直接使用 Cursor API )。

資料庫的更新與遷移

隨著應用功能的改變,你需要去更改 entity 和資料庫,但很多時候,你不希望因此丟失資料庫中已存在的的資料,尤其是無法從遠端伺服器恢復這些資料時。也就是說,如果你不提供必要的遷移操作,Room 將會重建資料庫,資料庫中所有的資料都將丟失。
為此, Room 允許你寫一些 Migration 類去保護使用者資料,每個 Migration 類指定一個 startVersionendVersion,在執行時,Room 會執行每個 Migration 類的 migrate() 方法,以正確的順序將資料庫遷移到最新版本:

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 以確保遷移正確的完成了,如果 Room 發現了問題,會丟擲一個包含不匹配資訊的異常。
遷移資料庫是很重要也是無法避免的操作,如果遷移出錯可能會導致你的應用陷入崩潰迴圈,為了保持應用的穩定性,你必須提前測試好遷移的整的過程。為了更好地測試,你需要新增 android.arch.persistence.room:testing 依賴,並且你需要匯出資料庫的 schema。在編譯時,Room 會將你資料庫的 schema 資訊匯出為 JSON 檔案。為了匯出 schema,你需要在 build.gradle 檔案中設定 註解處理器屬性room.schemaLocation:

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

你需要將這個匯出的 JSON 檔案儲存在版本控制系統中,因為這個檔案代表了資料庫的 schema 歷史記錄。同時你需要新增 schema 位置作為 asset 資料夾:

android {
    ...
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}
複製程式碼

測試工具中的 MigrationTestHelper 類可以讀這些 schema 檔案,同時它也實現了 JUnit4 的 TestRule 介面,所以它可以管理建立資料庫:

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                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.
    }
}
複製程式碼

資料庫的測試

寫 JUnit 測試通常比 UI 測試更快更直觀,利用 Room.inMemoryDatabaseBuilder 構造 in-memory 版本的資料庫可以讓你的測試更封閉:

@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));
    }
}
複製程式碼

高階用法與技巧

TypeConverter

有些時候,我們需要把一些自定義資料型別存入資料庫,或者在存入資料庫前做一些型別轉換,如我們需要把 Date 型別的欄位作為 Unix 時間戳存入資料庫:

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();
    }
}
複製程式碼

然後使用 @TypeConverters 註解那些需要使用轉換器的元素。如果註解了 Database,那麼資料庫中所有的 DaoEntity 都能使用它。如果註解了 Dao,那麼 Dao 中所有的方法都能使用它。如果註解了 Entity,那麼 Entity 中所有的欄位都能使用它。如果註解了 POJO,那麼 POJO 中所有的欄位都能使用它。如果註解了 Entity 欄位,那麼只有這個 Entity 欄位能使用它。如果註解了 Dao 方法,那麼該 Dao 方法中所有的引數都能使用它。如果註解了 Dao 方法引數,那麼只有這個引數能使用它:

@Database(entities = {User.class}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}
複製程式碼

查詢的時候,你仍然可以用你的自定義型別,就像使用原語型別一樣:

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

Database 物件的建立

例項化 RoomDatabase 是相當昂貴的,最好使用 Dagger2 等依賴注入工具注入唯一的 Database 例項,如:

@Module(includes = ViewModelModule.class)
class AppModule {
    ...
    @Singleton @Provides
    GithubDb provideDb(Application app) {
        return Room.databaseBuilder(app, GithubDb.class,"github.db").build();
    }

    @Singleton @Provides
    UserDao provideUserDao(GithubDb db) {
        return db.userDao();
    }

    @Singleton @Provides
    RepoDao provideRepoDao(GithubDb db) {
        return db.repoDao();
    }
}
複製程式碼

即使不使用依賴注入,也應該採用單例的方式建立 Database:

@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {

    private static volatile AppDatabase INSTANCE;

    public abstract UserDao userDao();

    public static AppDatabase getInstance(Context context) {
        if (INSTANCE == null) {
            synchronized (AppDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
                            AppDatabase.class, "sample.db")
                            .build();
                }
            }
        }
        return INSTANCE;
    }

}
複製程式碼

執行緒切換

運算元據庫是個非常耗時操作,所以不能在主執行緒(UI執行緒)中查詢或更改資料庫,Room 也為此做了執行緒檢查,如果你在主執行緒中操作了資料庫會直接丟擲異常。為了方便,Room 還允許你在查詢操作中直接返回 LiveDataRxJavaPublisherFlowable

參考

相關文章