Osheep

时光不回头,当下最重要。

Android architecture component架构

简介

引言

谷歌在今年的I/O大会上发布了新的架构库Android architecture component,为了解决开发者遇到的一些常见问题,推荐遵从以下两个原则构建应用:

  1. 关注点分离
    尽量避免在Activity或Fragment中编写所有的代码,任何不是处理 UI 或操作系统交互的代码都不应该在这些类中。保持它们尽可能的精简可以避免许多与生命周期有关的问题。
  2. model驱动UI
    使用持久化的model,因为如果 OS 销毁应用释放资源,用户不用担心丢失数据;而且即使网络连接不可靠或者是断开的,应用仍将继续运行。
    为此,推出了新的架构组件来帮助开发者快速搭建满足上述要求的应用,这就是四个新组件LiveDate,ViewModel,Room Persistence,Lifecycles的由来,而此次的重点就是Room。

Room

Room提供了一个SQLite之上的抽象层,使得在充分利用SQLite功能的前提下顺畅的访问数据库。Room中有三个主要的组件:Entity,Database和Dao。

《Android architecture component架构》

使用方法

gradle配置

打开整个项目的build.gradle,然后添加:

allprojects {
    repositories {
        jcenter()
        //因为GFW的原因,这个网站不一定连的上,参考下文中的问题一
        maven { url 'https://maven.google.com' }
    }
}

然后在app的build.gradle里添加:

// App Toolkit
    compile "android.arch.lifecycle:extensions:1.0.0-alpha1"
    compile "android.arch.persistence.room:runtime:1.0.0-alpha1"
    annotationProcessor "android.arch.lifecycle:compiler:1.0.0-alpha1"
    annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha1"

这样就把所需的库添加到项目中的。

Entity

当一个类用@Entity注解并且被@Database注解中的entities属性所引用,Room就会在数据库中为那个entity创建一张表。

默认Room会为entity中定义的每一个field都创建一个column。如果一个entity中有你不想持久化的field,那么你可以使用@Ignore来注释它们,如下面的代码所示:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}

要持久化一个field,Room必须有获取它的渠道。你可以把field写成public,也可以为它提供一个setter和getter。如果你使用setter和getter的方式,记住它们要基于Room的Java Bean规范。

Primary key

每个entity必须至少定义一个field作为主键(primary key)。即使只有一个field,你也必须用@PrimaryKey注释这个field。如果你想让Room为entity设置自增ID,你可以设置@PrimaryKey的autoGenerate属性。如果你的entity有一个组合主键,你可以使用@Entity注解的primaryKeys属性,具体用法如下:

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

    @Ignore
    Bitmap picture;
}

Room默认把类名作为数据库的表名。如果你想用其它的名称,使用@Entity注解的tableName属性,如下:

//SQLite中的表名是大小写敏感的。
@Entity(tableName = "users")
class User {
    ...
}

和tableName属性类似,Room默认把field名称作为数据库表的column名。如果你想让column有不一样的名称,为field添加@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;
}

Indices 和 uniqueness

为了提高查询的效率,你可能想为特定的字段建立索引。要为一个entity添加索引,在@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来实现唯一性。下面的代码防止了一个表中的两行数据出现firstName和lastName字段的值相同的情况:

@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库允许entity对象相互引用,但是Room明确禁止了这种行为。后文中的注意事项中解释了原因。
虽然不可以使用直接的关联,Room仍然允许你定义entity之间的外键(Foreign Key)约束。
比如,假设有另外一个entity叫做calledBook,你可以使用@ForeignKey注解定义它和User entity之间的关联,如下:

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

外键非常强大,因为它允许你指定当被关联的entity更新时做什么操作。例如,通过在@ForeignKey注解中包含Delete = CASCADE, 你可以告诉SQLite,如果相应的User实例被删除,那么删除这个User下的所有book。

嵌套对象

有时你可能想把一个entity或者一个POJOs作为一个整体看待,即使这个对象包含几个field。这种情况下,你可以使用@Embedded注解,表示你想把一个对象分解为表的子字段。然后你就可以像其它独立字段那样查询这些嵌入的字段。

比如,我们的User类可以包含一个类型为Address的field,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。
如果一个entity有多个嵌套字段是相同类型,你可以设置prefix属性保持每个字段的唯一性。Room就会在嵌套对象中的每个字段名的前面添加上这个值。

Data Access Objects (DAOs)

Room中的主要组件是Dao类。DAO抽象出了一种操作数据库的简便方法。

注:Room不允许通过主线程上访问数据库,除非您在构建器上调用allowMainThreadQueries(),因为它可能会长时间地锁定用户界面。异步查询(返回LiveData或RxJava Flowable的查询)将免除此规则,因为它们在需要时异步地在后台线程上运行查询。

便利的方法

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,代表新插入元素的rowId,如果参数是一个数组或者集合,那么应该返回long[]或者List。

  • update
    Update是一个更新一系列entity的简便方法。它根据每个entity的主键作为更新的依据。下面的代码演示了如何定义这个方法:
@Dao
public interface MyDao {
    @Update
    public void updateUsers(User... users);
}

你可以让这个方法返回一个int类型的值,表示更新影响的行数,虽然通常并没有这个必要。

  • delete
    这个API用于删除一系列entity。它使用主键找到要删除的entity。下面的代码演示了如何定义这个方法:
@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(); 
}

这是一个非常简单的查询,加载所有的user。在编译时,Room知道它是查询user表中的所有字段。如果query有语法错误,或者user表不存在,Room将在app编译时显示恰当的错误信息。

向query传递参数

大多数时候,你需要向查询传递参数来执行过滤操作,比如只显示大于某个年龄的user。为此,在Room注解中使用方法参数,如下:

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

当编译时处理到这个查询的时候,Room把:minAge用方法中的minAge匹配。Room使用参数的名称来匹配。如果有不匹配的情况,app编译的时候就会出现错误。
你也可以传递多个参数或者在查询中多次引用它们,如下面的代码所示:

@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的部分字段。比如,你的界面也许只需显示user的first name 和 last name,而不是用户的每个详细信息。只获取UI需要的字段可以节省可观的资源,查询也更快。
只要结果的字段可以和返回的对象匹配,Room允许返回任何的Java对象。比如,你可以创建如下的POJO获取user的first name 和 last name:

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

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

现在你可以在query方法中使用这个POJO:

//注:这些POJO也可以使用@Embedded注解。
@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user")
    public List<NameTuple> loadFullName();
}

Room知道这个查询返回first_name和last_name字段的值,并且这些值可以被映射到NameTuple类的field中。因此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的UI能自动在数据更新的时候更新。为此,在query方法中使用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);
}

注:对于version 1.0,Room使用query中的table列表来决定是否更新LiveData对象。

RxJava

Room还可以让你定义的查询返回RxJava2的Publisher和Flowable对象。要使用这个功能,在Gradle dependencies中添加Android.arch.persistence.room:rxjava2。然后你就可以返回RxJava2中定义的对象类型了,如下面的代码所示:

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

Direct cursor access

如果你的app需要获得直接返回的行,你可以在查询中返回Cursor对象,如下面的代码所示:

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

注:不推荐使用Cursor API,因为它无法保证行是否存在或者行中有哪些值。只有在当前的代码需要一个cursor,而且你又不好重构的时候才使用这个功能。

多表查询

某些查询可能需要根据多个表查询出结果。Room允许你书写任何查询,因此表连接(join)也是可以的。而且如果响应是一个可观察的数据类型,比如Flowable或者LiveData,Room将观察查询中涉及到的所有表。
下面的代码演示了如何执行一个表连接查询来查出借阅图书的user与被借出图书之间的信息。

@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对象。比如你可以写一个如下的查询加载user与它们的宠物名字:

@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内置了原始类型。但是,有时你会希望使用自定义数据类型。 要为自定义类型添加这种支持,可以提供一个TypeConverter,它将一个自定义类转换为Room保留的已知类型。
比如,如果我们要保留Date的实例,我们可以编写以下TypeConverter来存储数据库中的等效的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();
    }
}

上述示例定义了两个函数,一个将Date对象转换为Long对象,另一个将从Long到Date转换为执行逆转换。 由于Room已经知道如何持久化Long对象,因此可以用此转换器来持久保存Date类型的值。
接下来,将@TypeConverters注释添加到AppDatabase类,以便Room可以使用你为该AppDatabase中的每个实体和DAO定义的转换器:

//AppDatabase.java
@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);
}

测试迁移

写迁移不是一件简单的事情,如果写法不恰当可能导致app的进入崩溃的恶性循环。为了保证app的稳定性,你应该先测试迁移。Room提供了一个testing Maven artifact来帮助你完成这个测试过程。但是要让这个artifact工作,需要导出数据库的schema。

导出schemas

在编译的时候,Room将database的schema信息导出到一个JSON文件中。为此,要在build.gradle 文件中设置room.schemaLocation注解处理器属性,如下面的代码所示:
build.gradle

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

你应该把导出的在这个JSON文件-它代表了你的数据库的schema历史-保存到你的版本管理系统中,这样就可以让Room创建旧版本的数据库来测试。

测试迁移需要把Room的Maven artifact android.arch.persistence.room:testing 添加到你的test dependencies,并且把schema的位置作为 asset folder添加进去,代码如下:
build.gradle

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

testing package提供了一个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.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.
    }
}

可能出现的问题

问题1

问题描述

编译项目消耗时间长,而且报错

compile "android.arch.lifecycle:runtime:1.0.0-alpha1"
compile "android.arch.lifecycle:extensions:1.0.0-alpha1"
annotationProcessor "android.arch.lifecycle:compiler:1.0.0-alpha1"

解决办法

纯粹是因为greatWall的问题,把app的build.gradle中的maven.google.com替换https://dl.google.com/dl/android/maven2/

问题2

问题描述

在测试数据库迁移的时候,需要往build.gradle上添加一句

    testCompile "android.arch.persistence.room:testing:1.0.0-alpha5"

然后编译的时候可能会报错:

《Android architecture component架构》

这里写图片描述

解决办法

把依赖修改为

    compile "android.arch.persistence.room:testing:1.0.0-alpha5"

注意事项

事项1

两个entity之间不能添加对象引用
官网翻译:在数据库和相关的对象模型之间建立映射关系是服务端非常常见且好用的办法,可以通过懒加载来提高性能。
然而,在客户端这边,懒加载的效果并不显著因为它发生在UI线程,而在UI线程里面执行对磁盘的查找会带来显著的性能问题。UI线程大概会花16ms的时间来计算与绘制页面布局的升级,所以就算查找只花5ms,app仍然可能在绘制框架的过程中耗尽时间并且造成可见的问题。更糟糕的是,如果查找是多个事务并行执行的话可能会花掉更多时间,或者设备同时在执行其他磁盘耗费严重的任务。而且如果不使用懒加载的话,将会带来更多内存上的消耗。
ORM通常将这个问题留给开发者自己根据应用的实际情况做决定。不幸的是,开发者通常会在模型层与UI之间共享模型。随着UI不停变更,很难去分析与调试出现的问题。

数据统计

操作 greendao耗时 room耗时
循环插入10000条数据 98238ms 66821ms
批量插入10000条数据 623ms 1051ms
循环更新10000次 97840ms 61348ms
批量更新10000条数据 739ms 1366ms
循环查询10000条数据 31923ms 6932ms
批量查询10000条数据 149ms 不支持大量参数的同时查询

greenDao 的数据来源于网上。

操作 greendao耗时 room耗时
循环插入10000条数据 98238ms 66821ms
批量插入10000条数据 623ms 1051ms
循环更新10000次 97840ms 61348ms
批量更新10000条数据 739ms 1366ms
循环查询10000条数据 31923ms 6932ms
批量查询10000条数据 149ms 不支持大量参数的同时查询
点赞