编程知识 cdmana.com

Android Jetpack架构组件(六)之Room

一、Room简介

在Android应用开发中,持久化数据的方式有很多,常见的有Shared Preferences、Internal Storage、External Storage、SQLite Databases和Network Connection五种。其中,SQLite使用数据库方式进行存储,适合用来存储数据量比较大的场景。

不过,由于SQLite写起来比较繁琐且容易出错,因此,社区出现了各种ORM(Object Relational Mapping)库,如ORMLite、Realm、LiteOrm和GreenDao等,这些第三方库有一个共同的目的,那就是为方便开发者方便使用ORM而出现,简化的操作包括创建、升级、CRUD等功能。
在这里插入图片描述

为了简化SQLite操作,Jetpack库提供了Room组件,用来帮助开发者简化开发者对数据库操作。Room 持久库提供了一个SQLite抽象层,让开发者访问数据库更加稳健,数据库操作的性能也得到提升。

二、Room使用

2.1 Room相关概念

Room组件库包含 3 个重要的概念,分布是Entity、Dao和Database。

  • Entity:实体类,对应的是数据库的一张表结构,需要使用注解 @Entity 进行标记。
  • Dao:包含访问一系列访问数据库的方法,需要使用注解 @Dao 进行标记。
  • Database:数据库持有者,是应用持久化相关数据的底层连接的主要接入点,需要使用注解 @Database 进行标记。

使用@Database注解需满足以下条件:

  • 定义的类必须是一个继承于RoomDatabase的抽象类。
  • 在注解中需要定义与数据库相关联的实体类列表。
  • 包含一个没有参数的抽象方法并且返回一个带有注解的 @Dao。

简单来说,应用使用 Room 数据库来获取与该数据库关联的数据访问对象 (DAO)。然后应用使用每个 DAO从数据库中获取实体,再将对这些实体的所有更改保存回数据库中。 最后应用使用实体来获取和设置与数据库中的表列相对应的值。

下面是使用Entity、Dao、Database三者和应用的对应架构示意图,如下所示。
在这里插入图片描述

2.2 基本使用

2.2.1 添加依赖

首先,在app的build.gradle中增加以下配脚本。

dependencies {
    
    def room_version = "2.2.5"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
}

2.2.2 Entity

Room的使用和传统的Sqlite数据库的使用流程是差不多的。首先,使用 @Entity注解定义一个实体类,类会被映射为数据库中的一张表,默认实体类的类名为表名,字段名为表名,如下所示。

@Entity
public class User {
    @PrimaryKey(autoGenerate = true)
    public int uid;

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

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

    @Ignore
    public boolean sex;
}

其中,@PrimaryKey注解用来标注表的主键,并且使用autoGenerate = true 来指定了主键自增长。@ColumnInfo注解用来标注表对应的列的信息比如表名、默认值等等。@Ignore 注解用来标示忽略这个字段,使用了这个注解的字段将不会在数据库中生成对应的列信息。

2.2.3 Dao

Dao类是一个接口,主要用于定义一系列操作数据库的方法,即通常我们所说的增删改查。为了方便开发者操作数据库,Room提供了@Insert、@Delete、@Update 和 @Query等注解。

@query注解
@Query 是一个查询注解,它的参数时String类型,我们直接写SQL语句进行执行。比如,我们根据ID查询某个用户的信息。

@Query("SELECT * FROM user WHERE uid IN (:userIds)")
List<User> loadAllByIds(int[] userIds);

@Insert注解
@Insert注解用于向表中插入一条数据,我们定义一个方法然后使用 @Insert注解标注即可,如下所示。

@Insert
void insertAll(User... users);

其中,@Insert注解有个onConflict参数,表示的是当插入的数据已经存在时候的处理逻辑,有三种操作逻辑,分布是REPLACE、ABORT和IGNORE。

@Delete注解
@Delete注解用于删除表的数据,如下所示。

@Delete
void delete(User user);

@Update注解
@Update注解用于修改某一条数据 ,和@Delete一样也是根据主键来查找要删除的实体。

@Update
void update(User user);

接下来,我们新建一个UserDao类,并添加如下代码。

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

    @Query("DELETE  FROM user WHERE uid = :uid ")
    void deleteUserById(int uid);

    @Query("UPDATE  user SET first_name = :firstName where uid =  :uid")
    void updateUserById(int uid, String firstName);

    @Update
    void update(User user);
}

2.2.4 Database

首先,定义一个继承RoomDatabase的抽象类,并且使用 @Database 注解进行标识,如下所示。

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

    private static  AppDatabase instance = null;

    public static synchronized AppDatabase getInstance(Context context) {
        if (instance == null) {
            instance = Room.databaseBuilder(
                    context.getApplicationContext(),
                    AppDatabase.class,
                    "user.db" //数据库名称
                ).allowMainThreadQueries().build();
        }
        return instance;
    }
}

完成上述操作之后,使用以下代码获得创建数据库的实例。

    AppDatabase db = AppDatabase.getInstance(this);
        UserDao dao = db.userDao();
        User user=new User();
        user.firstName="ma";
        user.lastName="jack";
        dao.insertAll(user); 

2.2.5 综合示例

接下来,我们通过一个简单的综合练习来说说Room的基本使用方法。首先,我们在activity_main.xml布局文件中新增4个按钮,分别用来增删改查。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_insert"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAllCaps="false"
        android:textSize="24dp"
        android:text="插入数据" />

    <Button
        android:id="@+id/btn_delete"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:textSize="24dp"
        android:text="删除数据" />

    <Button
        android:id="@+id/btn_query"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:textSize="24dp"
        android:text="查询数据" />

    <Button
        android:id="@+id/btn_update"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:textSize="24dp"
        android:text="更新数据" />

</LinearLayout>

然后我们编写代码实现相关的功能,如下所示。

public class MainActivity extends AppCompatActivity {

    AppDatabase db=null;
    UserDao dao=null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        init();
    }

    private void init() {
       db = AppDatabase.getInstance(this);
       dao = db.userDao();
       insert();
       query();
       update();
    }


    private void insert() {
        findViewById(R.id.btn_insert).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                for (int i=0;i<10;i++) {
                    User user=new User("张三"+i,"100"+i);
                    dao.insertAll(user);
                }
            }
        });
    }

    private void query() {
        findViewById(R.id.btn_query).setOnClickListener(new View.OnClickListener() {
            @RequiresApi(api = Build.VERSION_CODES.N)
            @Override
            public void onClick(View v) {
                dao.getAll().forEach(new Consumer<User>() {
                    @Override
                    public void accept(User user) {
                        Log.d("Room", user.firstName+","+user.lastName);
                    }
                });
            }
        });
    }

    private void update() {
        findViewById(R.id.btn_update).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                dao.updateUserById(2, "李四");
                User updateUser = dao.loadUserById(2);
                Log.e("Room", "update${user.firstName},${user.lastName}");
            }
        });
    }
}

接下来,运行代码,执行插入操作,生成的数据库位于data/data/packageName/databases目录下。然后,再执行查询功能,控制台输出内容如下。

com.xzh.jetpack D/Room: 张三0,1000
com.xzh.jetpack D/Room: 张三1,1001
com.xzh.jetpack D/Room: 张三2,1002
com.xzh.jetpack D/Room: 张三3,1003
com.xzh.jetpack D/Room: 张三4,1004
com.xzh.jetpack D/Room: 张三5,1005
com.xzh.jetpack D/Room: 张三6,1006
com.xzh.jetpack D/Room: 张三7,1007
com.xzh.jetpack D/Room: 张三8,1008
com.xzh.jetpack D/Room: 张三9,1009

需要说明的是,所有对数据库的操作都不可以在主线程中进行,除非在数据库的Builder上调用了allowMainThreadQueries()或者所有的操作都在子线程中完成,否则程序会崩溃报并报如下错误。

java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

三、 预填充数据库

有时候,我们希望在应用启动时数据库中就已经加载了一组特定的数据,我们将这种行为称为预填充数据库。在 Room 2.2.0 及更高版本中,开发者可以使用 API 方法在初始化时用设备文件系统中预封装的数据库文件中的内容预填充 Room 数据库。

3.1 从应用资源预填充

预填充指的是从位于应用 assets/ 目录中的任意位置的装数据库文件预填充 Room 数据库,使用的时候调用createFromAsset() 方法,然后再调用 build()方法即可,如下所示。

 Room.databaseBuilder(appContext, AppDatabase.class, "Sample.db")
        .createFromAsset("database/myapp.db")
        .build();

createFromAsset() 方法接受一个包含assets/ 目录的相对路径的字符串参数。

3.2 从文件系统预填充

除了将数据内置到应用的 assets/ 目录除外, 我们还可以 从位于设备文件系统任意位置读取预封装数据库文件来预填充 Room 数据库,使用时需要调用createFromFile() 方法,然后再调用 build(),如下所示。

    Room.databaseBuilder(appContext, AppDatabase.class, "Sample.db")
        .createFromFile(new File("mypath"))
        .build();
    

createFromFile() 方法接受代表预封装数据库文件的绝对路径的 File 参数,Room 会创建指定文件的副本,而不是直接打开它,并且使用时请确保应用具有该文件的读取权限。

四、迁移数据库

4.1 基本使用

在使用数据库的时候就避免不了需要对数据库进行升级。例如,随着业务的变化,需要在数据表中新增一个字段,此时就需要对数据表进行升级。

在Room中, 数据库的升级或者降级需要用到Migration 类。每个 Migration 子类通过替换 Migration.migrate() 方法定义 startVersion 和 endVersion 之间的迁移路径。当应用更新需要升级数据库版本时,Room 会从一个或多个 Migration 子类运行 migrate() 方法,以在运行时将数据库迁移到最新版本。

例如,当前设备中应用的数据库版本为1,如果要将数据库的版本从1升级到2,那么代码如下。

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`))");
    }
};

其中,Migration方法需要startVersion和endVersion两个参数,startVersion表示的是升级开始的版本,endVersion表示要升级到的版本,同时需要将@Database注解中的version的值修改为和endVersion相同。

以此类推,如果当前应用的数据库版本为2,想要升级到到版本3,那么代码如下。

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");
    }
};

在Migration编写完升级方案后,还需要使用addMigrations()方法将升级的方案添加到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("");执行sql语句
        }
    };

    static final Migration MIGRATION_2_3 = new Migration(2, 3) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
//            database.execSQL("");执行sql语句
        }
    };
    
Room.databaseBuilder(app,AppDatabase.class, DB_NAME)
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    .build();    

然后,在Android Studio的工具栏上依次点击【View】->【Tool windows】->【Device File Explorer】打开数据表即可查看。

数据库降级使用和升级的步骤差不多,也是使用addMigrations只是startVersion > endVersion 。当在升级或者降级的过程中出现版本未匹配到的情况的时候,默认情况下会直接抛异常出来。

当然我们也可以处理异常。升级的时候可以添加fallbackToDestructiveMigration方法,当未匹配到版本的时候就会直接删除表然后重新创建。降级的时候添加fallbackToDestructiveMigrationOnDowngrade方法,当未匹配到版本的时候就会直接删除表然后重新创建。

4.2 迁移测试

迁移通常十分复杂,并且数据库迁移错误可能会导致应用崩溃。为了保持应用的稳定性,需要开发者对迁移进行测试。为此,Room 提供了一个 room-testing 来协助完成此测试过程。

4.2.1 导出架构

Room 可以在编译时将数据库的架构信息导出为 JSON 文件。如需导出架构,请在 app/build.gradle 文件中设置 room.schemaLocation 注释处理器属性,如下所示。

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

导出的 JSON 文件代表数据库的架构历史记录。您应将这些文件存储在版本控制系统中,因为此系统允许 Room 出于测试目的创建较旧版本的数据库。

4.2.2 测试单次迁移

测试迁移之前,需要先添加测试依赖androidx.room:room-testing,并将导出的架构的位置添加为资源目录,如下所示。

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

测试软件包提供了可读取导出的架构文件的 MigrationTestHelper 类。该软件包还实现了 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.
    }
}

4.2.3 测试所有迁移

虽然可以测试单次增量迁移,但建议您添加一个测试,涵盖为应用的数据库定义的所有迁移。这可确保最近创建的数据库实例与遵循定义的迁移路径的旧实例之间不存在差异。下面的示例演示了迁移所有测试。

@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(),
                AppDatabase.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrateAll() throws IOException {
        // Create earliest version of the database.
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
        db.close();

        // Open latest version of the database. Room will validate the schema
        // once all migrations execute.
        AppDatabase appDb = Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB)
                .addMigrations(ALL_MIGRATIONS).build();
        appDb.getOpenHelper().getWritableDatabase();
        appDb.close();
    }

    // Array of all migrations
    private static final Migration[] ALL_MIGRATIONS = new Migration[]{
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
}

4.3 迁移异常处理

迁移数据库的过程中,不可避免的会出现一些异常,如果 Room 无法找到将设备上的现有数据库升级到当前版本的迁移路径,会提示IllegalStateException错误。在迁移路径缺失的情况下,如果丢失现有数据可以接受,那么在创建数据库时可以调用 fallbackToDestructiveMigration() 构建器方法。

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

fallbackToDestructiveMigration()方法会指示 Room 在需要执行没有定义迁移路径的增量迁移时,破坏性地重新创建应用的数据库表。如果只想让 Room 在特定情况下回退到破坏性重新创建,可以使用 fallbackToDestructiveMigration() 的一些替代选项,如下所示。

  • fallbackToDestructiveMigrationFrom():如果特定版本的架构历史记录导致迁移路径出现无法解决的问题可以使用此方法。
  • fallbackToDestructiveMigrationOnDowngrade():如果仅在从较高数据库版本迁移到较低数据库版本时才希望 Room 回退到破坏性重新创建可以使用此方法。

五、测试和调试数据库

5.1 测试数据库

为了测试我们创建的数据库,有时候需要在Activity中编写一些测试代码。在Android中测试数据库有两种方式。

  • 在 Android 设备上测试。
  • 在主机开发计算机上测试(不推荐)。

5.1.1 在 Android 设备上测试数据库

如需测试数据库实现,推荐的方法是编写在 Android 设备上运行的 JUnit 测试,由于执行这些测试不需要创建 Activity,因此它们的执行速度应该比界面测试速度更快。如下是一个JUnit 测试的示例。

@RunWith(AndroidJUnit4.class)
public class SimpleEntityReadWriteTest {
    private UserDao userDao;
    private TestDatabase db;

    @Before
    public void createDb() {
        Context context = ApplicationProvider.getApplicationContext();
        db = Room.inMemoryDatabaseBuilder(context, TestDatabase.class).build();
        userDao = db.getUserDao();
    }

    @After
    public void closeDb() throws IOException {
        db.close();
    }

    @Test
    public void writeUserAndReadInList() throws Exception {
        User user = TestUtil.createUser(3);
        user.setName("george");
        userDao.insert(user);
        List<User> byName = userDao.findUsersByName("george");
        assertThat(byName.get(0), equalTo(user));
    }
}

5.1.2 在主机上测试数据库

Room 使用 SQLite 支持库,该支持库提供了与 Android 框架类中的接口相对应的接口。通过此项支持,开发者可以传递该支持库的自定义实现来测试数据库查询。

5.2 调试数据库

Android SDK 包含一个 sqlite3 数据库工具,可用于检查应用的数据库。它包含用于输出表格内容的 .dump 以及用于输出现有表格的 SQL CREATE 语句的 .schema 等命令。我们可以在命令行执行 SQLite 命令,如下所示。

adb -s emulator-5554 shell
sqlite3 /data/data/your-app-package/databases/rssitems.db

更多的sqlite3命令行可以参考SQLite 网站上提供的sqlite3命令行文档

版权声明
本文为[xiangzhihong]所创,转载请带上原文链接,感谢
https://segmentfault.com/a/1190000038623117

Scroll to Top