美文网首页原理解析androidAndroid知识
【翻译】安卓架构组件(6)-Room持久化类库

【翻译】安卓架构组件(6)-Room持久化类库

作者: Chuckiefan | 来源:发表于2017-06-07 23:50 被阅读2125次

    相关文章:

    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的其他部分关系图如下:

    image

    (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自增,你可以设置@PrimaryautoGenerate属性。如果实体的主键是综合的,你可以使用@EntityprimaryKeys属性,如:

    @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的属性uniquetrue

    @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)处理为REMOVEREPLACE的集合而不仅仅是更新操作。这个替换冲突值的方法可能会对你的外键约束起作用。

    内嵌对象

    有些时候你想要一个实体类或POJO类作为数据库逻辑的一部分。这种情况下,你可以使用@Embedded注解来。你可以查询内嵌成员,就像你可能查询其他字段一样。

    例如,我们的User类包含一个Address类型的成员,代表了streetcitystatepostCode。为了分别存储这些字段,在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对象包含了以下字段:idfirstNamestreetstatecitypost_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将:minAgeminAge匹配在一起。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_namelast_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的PublisherFlowable对象。为了使用这个功能,添加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类指定一个startVersionendVersion。在运行时,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”

    相关文章

      网友评论

      • MrAllRight_Liu:在其他地方只看到第一篇的翻译文章,在你这看到了六篇,应该是自己正在测试填坑中吧

      本文标题:【翻译】安卓架构组件(6)-Room持久化类库

      本文链接:https://www.haomeiwen.com/subject/bvqzfxtx.html