Room 持久化库

Room提供了一个SQLite之上的抽象层,使得在充分利用SQLite功能的前提下顺畅的访问数据库。

对于需要处理大量结构化数据的App来说,把这些数据做本地持久化会带来很大的好处。常见的用例是缓存重要数据块。这样当设备无法连网的时候,用户仍然可以浏览内容。而用户对内容做出的任何改动都在网络恢复的时候同步到服务端。

核心framework内置了对SQL的支持。虽然这些API很强大,但是都很低级,使用起来很花时间和精力:

  • 没有编译时的SQL查询检查机制。当数据表发生改变的时候,需要手动更新受影响的SQL查询。这个过程既耗时又容易出错。

  • 需要写很多公式化的代码在SQL查询与Java对象之间转换。

Room处理了这些相关的事情,同时提供了SQLite之上的抽象层。

Room中有三个主要的组件:

  • Database:你可以用这个组件来创建一个database holder。注解定义实体的列表,类的内容定义从数据库中获取数据的对象(DAO)。它也是底层连接的主要入口。

这个被注解的类是一个继承RoomDatabase的抽象类。在运行时,可以通过调用Room.databaseBuilder() 或者 Room.inMemoryDatabaseBuilder()来得到它的实例。

  • Entity:这个组件代表一个持有数据库的一个表的类。对每一个entity,都会创建一个表来持有这些item。你必须在Database类中的entities数组中引用这些entity类。entity中的每一个field都将被持久化到数据库,除非使用了@Ignore注解。

注:实体可以有一个空构造函数(如果DAO类可以访问每个持久化字段),或者一个构造函数的参数包含与实体中的字段匹配的类型和名称。Romm还可以使用全部或部分构造函数,例如只接收一些字段的构造函数。

  • DAO:这个组件代表一个作为Data Access Objec的类或者接口。DAO是Room的主要组件,负责定义查询(添加或者删除等)数据库的方法。使用@Database注解的类必须包含一个0参数的,返回类型为@Dao注解过的类的抽象方法。Room会在编译时生成这个类的实现。

注:通过DAO而不是query builders或者直接的query语句来处理数据库,可以把数据库的各个部分分离开来。而且DAO还可以让你轻松的使用假的database来测试app。

这些组件以及它们与app其余部分之间的关系如图1:

图片.png

下面是一个只有一个entity和一个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 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();
}

创建了上面的文件之后,可以使用下面的代码来得到database的实例了:

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

在实例化AppDatabase对象的时候应该遵循单例模式,因为每个Database实例都是相当耗费的,而且也很少需要多个实例。

Entities

当一个类用@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属性,如下:

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

注:SQLite中的表名是大小写敏感的。

和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明确禁止了这种行为。详细情况见:Addendum: No object references between entities

虽然不可以使用直接的关联,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:

@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将显示一个警告。

注:这些POJO也可以使用@Embedded注解。

传入参数集合

一些查询可能需要你传入个数是一个变量的参数,只有在运行时才知道具体的参数个数。比如,你可能想获取一个区间的用户信息。当一个参数代表一个集合的时候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功能的添加和修改,你需要修改entity类来反应这些变化。当一个用户更新了app的最新版本之后,你并不希望它们丢失所有的现有数据,尤其是当你无法通过远程服务器恢复这些数据的时候。

Room让你可以让你写Migration类来保存用户数据。每个Migration类指定from和to版本。运行时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");
    }
};

注意:为了让迁移的逻辑是可预知的,请使用完整的查询而不是用引用代表查询的constant。

当迁移过程完成之后,Room会检查schema以确保迁移正确进行。如果Room发现了问题,会抛出异常。

测试迁移

写迁移不是一件简单的事情,如果写法不恰当可能导致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.
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容