Android Room 持久化库

这是一篇自己从Android开发文档中翻译来的关于Room 的文档。哪里不对,欢迎纠错

Room 持久化库

Room持久性库提供了SQLite的抽象层,以便在充分利用SQLite的同时允许流畅的数据库访问。
该库可帮助你在设备上创建应用程序的缓存数据,这样不管设备是否联网都能看到数据。

摘自 Room Persistence Library

使用 Room 在本地保存数据

原文地址 https://developer.android.com/training/data-storage/room/index.html

对于不重要的数据可以存储在本地,最常见的就是缓存相关的数据。这样,在设备没有网络的时候就可以浏览离线数据。当设备联网后,将用户改动的数据同步至服务端。

Room 有三个重要组件

  • Database
  • Entity
  • DAO

Database

包含数据库持有者,并作为与应用持久关联数据的底层连接的主要接入点。

使用@Database注解,并满足以下条件

  • 是抽象类,并且继承自RoomDatabase
  • 在注解中包含与数据库关联的实体列表。
  • 包含一个具有0个参数的抽象方法,并返回用@Dao注解的类。
    在运行时,可以通过调用Room.databaseBuilder()或Room.inMemoryDatabaseBuilder()来获取数据库实例。

Entity

表示数据库中的表格

DAO

包含用户访问数据库的方法

这些组件以及组件与APP其他部分的关系 如图所示
Room结构图

下面的代码片段是一个数据库实例配置包含了一个Entity和一个DAO:

User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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

1
2
3
4
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}

创建完完成后使用以下代码获取数据库实例:

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

数据库实例最好是单例以节省内存开销

使用 Room 实体定义数据

原文地址 https://developer.android.com/training/data-storage/room/defining-data.html

我们定义的每一个实体,Room 都会对应的在数据库中创建一个表。
默认 Room 会为 每个字段在表中创建对应的字段;如果其中一些属性不想被创建在表中怎么办,那就是使用 @Ignore 注解此属性。完成实体的创建之后必须在 Database 引用。

1
2
3
4
5
6
7
8
9
10
11
@Entity
class User {
@PrimaryKey
public int id;
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}

类中的每个字段都必须让Room能够访问到。否则Room无法管理。
[^] 注意 :要遵循 JavaBean 规约;否则 管杀不管埋;[^]

定义主键

每个实体必须定义最少一个主键,就算类中只有一个字段,也要保证使用 @PrimaryKey
如果想让Room自动分配ID,可以设置 autoGenerate 为true;
如果是联合主键,可以在@Entity中设置 primaryKeys 属性。

1
2
3
4
5
6
7
8
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}

默认Room会使用类名当作数据库表名,如果你想设置其他名字,可以设置 tableName 属性

1
2
3
4
@Entity(tableName = "users")
class User {
...
}

[^]Sqlite中表名不区分大小写[^]

就像表名一样,字段的名字默认的也是类中属性的名字如果想设置其他名字,可使用 @ColumnInfoname属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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;
}

注解索引和唯一约束

使用 @Entityindices 来创建索引,并列出索引或者组合索引包含的列;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity(indices = {@Index("name"),
@Index(value = {"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 属性设置 唯一约束。
下面代码 firstName 和 lastName 两列组合唯一索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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 明确禁止这样做。至于为什么明确禁止,文章最后会说。原文链接:https://developer.android.com/training/data-storage/room/referencing-data.html#understand-no-object-references

虽然不能直接定义对象间引用,但是可以使用外键建立关系。

例如:有一个 Book 实体,可以使用 @ForeignKey 关联到 User 实体。下面代码演示使用

1
2
3
4
5
6
7
8
9
10
11
12
@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 是非常强大的,我们可以定义对象间的级联操作。例如可以在注解中设置 onDelete = CASCADE,当删除用户的的时候就会把用户所关联的书都删掉了。

[^]SQLite将@Insert(onConflict = REPLACE)作为一组REMOVE和REPLACE操作处理,而不是单个UPDATE操作。这种替换冲突值的方法可能会影响外键约束。有关更多详细信息,请参阅ON_CONFLICT子句的SQLite文档。[^]

创建嵌套对象

Room 支持在数据实体中嵌套其他对象来组合相关字段。例如 User 中嵌套一个 Address 这个地址对象中有三个字段:街道,城市,邮编。在数据表中这个三个字段是在用户表中的,就像其他字段一样。
通过在 User 使用 ` 注解 属性address` 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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。

[^] 嵌套字段可以嵌套其他字段[^]
如果数据实体中有多个 嵌套字段,可以通过设置属性 prefix 加前缀的方式保证字段名不重复。
如果在 User 中使用下面的代码,那么嵌套字段就会是 address_street,address_state,address_cityaddress_post_code

1
2
@Embedded(prefix = "address_")
public Address address;

使用 Room DAO 访问数据

原文地址:https://developer.android.com/training/data-storage/room/accessing-data.html

Room 使用数据对象和 DAO 访问数据库。
DAO 是 Room 的重要组件,他包含了操作数据的抽象方法;
DAO可以是一个接口或者抽象类,如果是抽象类的话,它可以有一个构造函数,它将RoomDatabase作为其唯一参数。Room会在编译时创建实现。
DAO不能在主线程的时候操作数据,可能会阻塞UI,除非在构建的时候调用 allowMainThreadQueries()。如果是返回 LiveData或者 Flowable 的异步查询例外。

定义操作方法

这里只列出几个常用方法

Insert

当创建一个DAO方法并使用它的时候,Room会生成它的实现并在单个事物中将所有参数插入。

1
2
3
4
5
6
7
8
9
10
11
@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类型的数组或集合。
关于 @Insert 的详细介绍查看文档 https://developer.android.com/reference/android/arch/persistence/room/Insert.html

Update

Room 会通过每个实体的主键进行查询,然后再进行修改。
返回值可以是一个 int 型的值,返回更新的行数。

1
2
3
4
5
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}

Delete

Room 会数据实体的主键删除相应的数据。
返回值可以是一个 int 型的值,用来表示删除的行数。

1
2
3
4
5
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}

查询信息

@Query 是 DAO 中主要使用的注解。它可以执行对数据库的读写操作。每一个 @Query 方法都会在编译时验证,如果出现问题也是在编译时出现问题不会在运行时出现问题。

Room 也会验证方法的返回值,如果返回对象中的字段名称和查询响应中的字段名字不匹配, Room 会通过以下方式给出提示

  • 如果只有一些字段名称不匹配,会发出警告
  • 如果没有字段名称匹配,会发出错误。

简单查询

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

这是一个非常简单的查询所有用户的查询。在编译时,Room会知道是查询用户表的所有列。如果查询包含语法错误或者数据库中不存在这个表。Room会在编译时报错并给出错误信息。

将参数传递给查询

大部分时候查询都是需要过滤参数的。比如要查询一些年龄比较大的用户。

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

在编译时,Room会将 :minAge 与方法参数匹配绑定。 Room使用参数名字匹配,如果匹配不上给出错误提示。

也可以传递多个参数或者引用多次:

1
2
3
4
5
6
7
8
9
@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);
}

返回列的子集

很多时候只需要数据实体的中几个列。例如你可能只想显示用户的姓和名而不是全部的用户信息。只查询需要的列可以节省资源并且查询的更快。

Room 允许返回任何的Java对象。只要查询的结果列能够和Java对象映射上即可。所以我们可以创建一个只包含需要的列的类。

1
2
3
4
5
6
7
public class NameTuple {
@ColumnInfo(name="first_name")
public String firstName;
@ColumnInfo(name="last_name")
public String lastName;
}

使用这个 POJO

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

Room 知道查询的值并知道怎么映射到对应的NameTuple字段中。所以 Room 会生成正确的代码。如果查询返回的列多了或者少了,Room会给出警告

这里也可以使用@Embedded注解

传递参数集合

有时候查询的参数数量是动态的,只有运行的时候才知道。例如只查询某些地区的用户。
当参数是一个集合的时候,Room 会在运行的时候自动扩展它。

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

可观察的查询

在执行查询时,我们经常想让UI在数据更改时自动更新。要实现这一点,可以在查询方法使用 LiveData 类行的返回值。当数据更新时 Room 会自动生成所需的代码已更新LiveData

1
2
3
4
5
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}

从版本1.0开始,Room使用查询中访问的表的列表来决定是否更新LiveData的实例。

使用 RxJava 进行响应查询

Room还可以从定义的查询中返回 RxJava2 的 Publisher 和 Flowable 对象。要使用此功能,需要将 Room 组中的 android.arch.persistence.room:rxjava2 组件添加到构建Gradle依赖项中,添加组件之后就可以返回 Rxjava2 中的对象

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

更多 Room 和 Rxjava2 的使用 看另一篇文章 https://medium.com/google-developers/room-rxjava-acb0cd4f3757

直接访问 Cursor

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

非常不推荐使用Cursor API,因为它不能保证行是否存在或行包含的值。只有当已经拥有需要游标并且无法轻松重构的代码时才使用此功能。

查询多个表

有些时候可能需要查询多个表中的数据来计算结果。Room运行我们写任何查询,当然也允许连接其他表。如果响应式可观察数据类型,例如 Flowable 或者 LiveData,Room会监视查询中的所有表,使其无效。

1
2
3
4
5
6
7
8
@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。例如,可以编写一个查询来加载用户及其宠物的名称,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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 数据库

原文 https://developer.android.com/training/data-storage/room/migrating-db-versions.html

在APP升级时可能需要更改数据库来策应新的功能。这个时候当然不希望数据库中的数据丢失。

Room 允许我们编写 Migration ,以此来迁移数据。每个迁移类制定一个开始版本和结束版本。

在运行时,Room会运行每个Migration类的migrate()方法,并使用正确的顺序将数据库迁移到更高版本。

如果不提供必要的Migration , Room 会重建数据库,所以数据会丢失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 发现错误,会抛出一个包含不匹配的异常。

测试迁移

数据迁移是很重要的,一旦迁移失败可能会发生Crash。为了保证程序的稳定性,一定要确认是否否迁移成功。Room 提供了一个测试工件来帮助我们测试,为保证测试工件的正确运行,必须开启导出模式。

导出模式

编译后,Room将数据库的模式信息导出到JSON文件中。要导出模式,在build.gradle文件中设置room.schemaLocation注解处理器属性,如下面的代码片段所示:

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}

我们应该把导出的 json 文件加入到版本控制中,它记录了数据库的模式历史,它能让Room在测试时创建老版本的数据库。

为了测试迁移,增加 Room 的测试工件依赖,并设置数据库模式文件地址,如下所示:

1
2
3
4
5
6
android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}

测试包提供了一个MigrationTestHelper类,它可以读取这些模式文件。它实现了 JUnit4 的 TestRule 接口,它能够管理已经创建的数据库。

下面是一个简单的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@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.
}
}

测试数据库

使用 Room 创建数据库时,验证数据库和用户数据的稳定性非常重要。

测试数据库有两种方法

  • 在Android 设备上
  • 在开发主机上(不推荐)

关于测试指定数据库升级的信息 上面已经说过了。

注意:在测试时,Room允许创建Dao的模拟实例。这样的话,如果不是测试数据库本身就不需要创建完整的数据库,这个功能是很好的,Dao不会泄露数据库的任何信息

在设备上测试

测试数据库实现的推荐方法是编写在Android设备上运行的JUnit测试,由于这些测试不需要创建活动,它们应该比UI测试更快执行。

在设置测试时,应该创建数据库的内存中版本,以使测试更加密封,如以下示例所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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));
}
}

在电脑上测试

Room使用SQLite Support Library,它提供了与Android Framework类中的接口相匹配的接口。此支持允许您传递支持库的自定义实现以测试数据库查询。

注意:即使此设置允许您的测试运行速度非常快,也不建议这样做,因为设备上运行的SQLite版本以及用户的设备可能与主机上的版本不匹配

使用Room引用复杂数据

Room提供了原始和包装类型转换的功能,但是不允许实体间对象引用。这里会解释为什么不支持对象引用和怎么使用类型转换器。

使用类型转换器

有时候你想存储自定义的数据类型在数据库的单个列中。这就需要为自定义类型添加一个类型转换器,这个转换器会将自定类型转换为Room能够认识的原始类型。

例如,我想保存Date类型的实例,我可以编写下面的类型转换器来在数据库中存储等效的Unix时间戳:

1
2
3
4
5
6
7
8
9
10
11
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对象持久化。

接下来,在AppDataBase类添加注解 @TypeConverters 这样AppDataBase中的Dao和实体就都能使用这个转换器了。

AppDatabase.java

1
2
3
4
5
@Database(entities = {User.class}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}

这样就可以使用自定义类型了,就像使用其他原始类型一样。

User.java

1
2
3
4
5
@Entity
public class User {
...
private Date birthday;
}

UserDao.java

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

还可以将@TypeConverters限制到不同的作用域,包括个体实体,DAO和DAO方法。关于 @TypeConverters更详细的介绍 请查看文档 https://developer.android.com/reference/android/arch/persistence/room/TypeConverters.html

理解Room不允许使用对象引用的原因

关键问题:Room不允许实体类之间的对象引用。相反,您必须明确您的应用需要的数据。

将数据库中的关系映射到相应的对象模型是常见的做法,并且在服务器端运行良好。即使程序在访问时加载字段,服务器仍然运行良好。

但是,在客户端,这种延迟加载不可行,因为它通常发生在UI线程上,并且在UI线程中查询磁盘上的信息会产生严重的性能问题。UI线程通常具有约16 ms的时间来计算和绘制活动的更新布局,因此即使查询只需要5 ms,仍然可能您的应用程序将耗尽时间来绘制框架,从而导致明显的视觉干扰。如果有单独的事务并行运行,或者设备正在运行其他磁盘密集型任务,则查询可能需要更多时间才能完成。但是,如果不使用延迟加载,则应用会获取比所需更多的数据,从而导致内存消耗问题。

对象关系映射通常将这个决定留给开发人员,以便他们可以为他们的应用程序的用例做最好的事情。开发人员通常决定在应用程序和用户界面之间共享模型。然而,这种解决方案并不能很好地扩展,因为随着UI的变化,共享模型会产生一些难以让开发人员预测和调试的问题。

例如,考虑加载一个Book对象列表的UI,每个书都有一个Author对象。最初可能会将查询设计为使用延迟加载,以便Book的实例使用getAuthor()方法返回作者。过了一段时间,你意识到你也需要在应用程序的用户界面中显示作者姓名。您可以轻松地添加方法调用,如以下代码片段所示:

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

但是,这个看起来无害的更改会导致在主线程上查询Author表。

如果提前查询作者信息,如果不再需要数据,则很难更改数据的加载方式。例如,如果您的应用程序的用户界面不再需要显示作者信息,则您的应用程序会有效地加载不再显示的数据,从而浪费宝贵的内存空间。如果作者类引用另一个表(如Books),则应用程序的效率会进一步降低。

要使用Room同时引用多个实体,需要创建一个包含每个实体的POJO,然后编写一个查询来加入相应的表。这种结构良好的模型与Room强大的查询验证功能相结合,可让您的应用在加载数据时消耗更少的资源,从而改善应用的性能和用户体验。

end

坚持原创技术分享,您的支持将鼓励我继续创作!