Room component architecture principle analysis

There are several types of Android application data store: file store, SharePreference store, and SQLite database store.

  • If you need to store a large amount of data, the use of file storage will have great disadvantages. For example, if you want to modify a very small item, you have to read the contents of the entire file first, and then save all the files after modification, which is very time-consuming.
  • SharePreference is mostly used to store small amounts of data in the form of key-value pairs.
  • So the SQLite database storage scenario is still very high, but as of now Android10.0, native SQLite is not that friendly, forcing us to introduce other third party ORM libraries quite often. Because of the Jetpack architecture, the Room database component comes naturally.

1. What is Room?

  • Room is a lightweight ORM database that is essentially an SQLite abstraction layer, but is much simpler to use, similar to the Retrofit library.
  • Room annotates related functionality during development and generates impL implementation classes that respond automatically at compile time.
  • Compilation phase will have rich syntax check, error prompts.

It is much more convenient to manipulate data than to create databases directly using SQLite. We’ll take a handwritten look at how Room creates the database.

The requirements for an ORM database are not just simple and easy to use, sufficient validation, and abundant error prompts. We should pay more attention to its performance. The graph cited here shows the comparison of insert, update, and Query time between GreenDAO, ORMLite, and Room.

Obviously, all three test red rooms are lower than the other two. Since Room is a encapsulation of native SQLite, its performance is almost as good as SQLite’s, and if your APP doesn’t require the same level of data storage as wechat, Room is sufficient.

2. The use of the Room

Before we dive into the Room database, let’s recall how to create a database directly using SQLite and its current problems.

There are three obvious problems with creating a database directly using SQLite:

  • The correctness and security of SQL statements are not guaranteed, and problems can be found only at runtime.
  • Very easy to operate on the database from the main thread;
  • The conversion from database data to the class data we need is tedious.
// Table name, column name
const val TABLE_NAME = "table_cache"
const val COLUMN_NAME_KEY = "cache_key"
const val COLUMN_NAME_DATA = "cache_data"

SQL > create table SQL > create table SQL > create table SQL > create table SQL
// Even if you say that I can do it easily, but still very tedious
private const val SQL_CREATE_TABLE_CACHE =
        "CREATE TABLE $TABLE_NAME (" +
                "$ID INTEGER PRIMARY KEY," +
                "$COLUMN_NAME_TITLE TEXT," +
                "$COLUMN_NAME_SUBTITLE TEXT)"

class CacheDbHelper(context: Context) : SQLiteOpenHelper(context,     DATABASE_NAME, null, DATABASE_VERSION) {
    companion object {
        const val DATABASE_VERSION = 1
        const val DATABASE_NAME = "cache.db"
    }

    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(SQL_CREATE_TABLE_CACHE)
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        // Write corresponding SQL statements according to oldVersion and newVersion to upgrade the database
        // In practice, this is the more difficult part, because it lacks the necessary validation and is prone to error
  }

    The base class contains two important methods: getWritableDatabase() and getReadableDatabase().
    // This is our database entry
}

Copy the code

Let’s take a look at how Room creates a database.

1. Add dependencies

implementation "Androidx. Room: a room - the runtime: 2.2.5." "
kapt "Androidx. Room: a room - the compiler: 2.2.5." "
Copy the code

2. There are three prerequisites for creating a Room database

  • @entity: represents the standard in the database;
  • @dao: data manipulation object;
  • @DATABASE Database: Must be an abstract class that extends RoomDatabase. Add the tables associated with the database to the annotations. Contains abstract methods for classes marked with @DAO annotations.

The first step is to define the tables of the database

// Defining a table is very simple, just create a class and tag the Entity annotation with its' tableName 'property to declare the name of the table
  @Entity(tableName = "table_cache")
  class Cache {
    //1.) for a table, it must have a non-empty PrimaryKey, i.e. it must mark both PrimaryKey and NonNull annotations
    // the 'autoGenerate' attribute of the PrimaryKey annotation indicates whether the value of the PrimaryKey is automatically generated by the database
    // Since we have the primary key of the string, we want to specify its value ourselves,
    // If the primary key is of type Long or INT, it can be automatically generated by the database
    @PrimaryKey(autoGenerate = false) 
    @NonNull
    var key: String = ""
    
    //2.) The column name of this field in the database table. Default is equal to the name of this field
    @ColumnInfo(name="cache_data",defaultValue = "default value")
    var data: String? = null
    
    //3.) You can use this annotation tag if you do not want the field to map to a table column
    @Ignore
    var timeStamp:Long? =null
    
    //4.) If you want fields in an Embedded object to be mapped to fields in a database table, you can use Embedded annotations. All fields in the User object will also appear in the cache table
    // The User object must also use the Entity annotation tag and have a non-empty primary key
    @Embedded
    var user: User? = null
    
     //5.) There are many other annotations and attributes that can be used for tables in a Room database, such as indexes, foreign keys, and relational data support. But for the client side generally not use, these are enough.
  }
Copy the code

The second step is to define the database data operation object -Dao layer

  @DaoThe full name (data access object)
  interface CacheDao {
    //1.) If you want to Insert data, you just need to mark the Insert annotation and specify that if a primary key already exists at the time of inserting data, what policy will be implemented
    //REPLACE: Directly REPLACE old data
    //ABORT: ABORT the operation and roll back the transaction. Old data is not affected
    //IGNORE: Conflicts are ignored, but inserts fail
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun saveCache(cache: Cache): Long
    
    //2.) You need to write a SQL statement for the general query operation
    @Query("select * from table_cache where `key`=:primaryKey")
    fun getCache(primaryKey: String): Cache?  
   
     //3.) Advanced query operation, can obtain database data in the form of observer through LiveData, can avoid unnecessary NPE
         // More importantly, it can listen to the data changes in the database table. Once an INSERT UPDATE delete occurs.
         // Room automatically reads the latest data in the table and sends it to the UI layer to refresh the page
         // This is an important point for us to pay attention to, to see what is behind it.
    @Query("select * from table_cache")
    fun query2(a): LiveData<List<Cache>>  // RxJava Observer is also supported

   //4.) Delete data
    @Delete(entity = Cache::class)
    fun deleteCache(key: String)

    //5.) The update operation is also very simple, the corresponding row in the table is replaced with the value of the Cache object field
    @Update()
     fun update(cache: Cache)
  }
Copy the code

Third, define the database and associate superscript and data manipulation entities

// TypeConverters specifies the type conversions supported by the database. For example, DateConvert defines a DateConvert field that is converted to Long when stored in the database and converted to Date when read
    @TypeConverters(DateConvert::class) 
    @Database(entities = [Cache::class], version = 1)
    abstract class CacheDatabase : RoomDatabase() {
      //1). Create an in-memory database, where the data stored in the database is stored only in memory, and the data is lost when the process is killed
      
       val database=  Room.inMemoryDatabaseBuilder(context,CacheDatabase::class.java).build()
       //2). Create a local persistent database
       val database = Room.databaseBuilder(context, CacheDatabase::class.java, "howow_cache").
                           // Whether database operations are allowed on the main thread. Default is false.
                           // In contrast to SQLite, where there is no explicit prohibition, Room provides a specification
                          .allowMainThreadQueries()
                          // Database creation and open events are called back to this point, and the database can be manipulated again
                          .addCallback(callback)
                          // Specify the thread pool in which data is queried.
                          .setQueryExecutor(cacheThreadPool)
                          / / it is used to create supportsqliteopenhelper, default is frameworksqliteopenhelperFactory
                          // Use sqliteOpenHelper to encrypt database storage, default is not encrypted
                         .openHelperFactory()
                         // Update database 1-- 2
                         .addMigrations(migration1_2)
                         
   //3). Declare Dao as an abstract method
   kotlin  abstract val cacheDao: CacheDao
      
   // Here is a demonstration of the database from version1->version2 upgrade process
   // Note that once the database is created, only any field of any object is changed
   // The version field of the Database annotation needs to be upgraded, and the migration behavior of the upgrade needs to be specified.
   val migration1_2 = object :Migration(1.2) {override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("alter table table_cache add column cache_time LONG")}}}class DateConvert {
       // Each class can have more than one TypeConverter method, but all must have a return value, which can be null
       @TypeConverter
       fun date2Long(date: Date): Long {
             return date.time
      }
        @TypeConverter
       fun long2Date(timestamp: Long): Date {
             return Date(timestamp)
       }
   }
Copy the code

3. Multiple table operations

Physical associations between tables

@Entity(foreignKeys = @ForeignKey(entity = User.class, parentColumns = "id", childColumns = "user_id"))
public class Book {
    @PrimaryKey
    public int bookId;

    public String title;

    @ColumnInfo(name = "user_id")
    public int userId;
}
Copy the code

Create nested objects

public class Address {
    public String street;
    public String state;
    public String city;

    @ColumnInfo(name = "post_code")
    public int postCode;
}

@Entity
public class User {
    @PrimaryKey
    public int id;

    public String firstName;

    @Embedded
    public Address address;
}
Copy the code

Pass parameter set

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}
Copy the code

Observable queries

@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;
       publicString petName; }}Copy the code

4. Support Rxjava

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

4. Returns a Cursor

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

5.Room database migration

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

3. Implementation principle of data library creation

Abstract design of Room data

We know that the Room database is an abstract design of SQLite. Room’s architecture can be divided into three layers:

  • The third layer is the abstract interface layer, which abstracts the original CAPABILITIES of SQLite into the form of interfaces. SupportSqliteOpenHelper, for example, defines methods to get database objects and interfaces for method callbacks after the database is opened.
  • So its implementation in a layer of FrameworkSqliteOpenHelper, this layer is directly depend on the SQLite to implement the corresponding ability. FrameworkSqliteOpenHelper in the Room has a role which forms a connecting link between the preceding and database architecture. It is the bridge between Room and SQLite.
  • In addition, Room also defines an abstract database interface called SupportSqliteDatabase, which also defines methods for adding, deleting, modifying, and committing transactions. This class also relies directly on SQLiteDatabase to implement its capabilities.

As you can see, Room actually ADAPTS common database operations in the form of an abstract interface. One day SQLite will no longer be needed, and you’ll just need to replace the implementation layer here.

Neither the underlying interface nor the upper Room encapsulation needs to be touched. We don’t have to move the application layer. The Room implementation layer is to liberate us to help us complete the function by generating corresponding implementation classes in the form of annotations + compile-time processor. The overall design of Room is such a concept.

Database Creation Process

This process is relatively simple, I will not post code line by line explanation, can compare to the following, read the source code:

4. Realization principle of Room with LiveData to monitor data changes and automatically refresh the page

Room with LiveData lazy loading

OnActive is triggered the first time an Observer is registered with LiveData, triggering lazy loading of the first data. The data is loaded in RefreshRunnable. When the data is first loaded, an Observer is registered with InvalidationTracker that monitors table changes. When the table changes, RefreshRunnable is triggered again to load the latest data.

Database change monitoring

Add three deletion operation before the start to write the operating table in a table, and the state set to 1, after the completion of the operation will trigger InvalidationTracker. EndTranstions. Then query all tables whose data has changed.

It then calls back to each RoomTracklingLiveData to perform refreshRunnable again to reload the data and send it to the OBSERVER refresh page in the UI layer.

How was LiveData created?

class CacheDao_Impl extends CacheDao{
      public LiveData<List<Cache>> query2() {
          final String _sql = "select * from table_cache";
          final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 0);
          return __db.getInvalidationTracker().createLiveData(new String[]{"table_cache"}, false.new Callable<List<Cache>>() {
                public List<Cache> call(a){
                   // This callback is triggered when the first observation is registered with LiveData
                   // RoomDatabase is used to query table_cache for latest data}}}Copy the code

RoomTrackingLiveData data loading:

class RoomTrackingLiveData extends LiveData{
      RoomTrackingLiveData(RoomDatabase database,InvalidationLiveDataContainer container,  boolean inTransaction,Callable<T> computeFunction,String[] tableNames) {
           mDatabase = database;
           mInTransaction = inTransaction;
          // Two things are done in the RoomTrackingLiveData constructor
          // The callback from the previous step is saved and will be called when the data needs to be loaded
          mComputeFunction = computeFunction;
          mContainer = container;
      
           // The next step is to build an observer to listen for changes to the table data
           // once the onInvalidated method is triggered, mInvalidationRunnable is triggered, which in turn triggers the RefreshRunnable at the latest data
           mObserver = new InvalidationTracker.Observer(tableNames) {
                      @Override
                      public void onInvalidated(@NonNull Set<String> tables) { ArchTaskExecutor.getInstance().executeOnMainThread(mInvalidationRunnable); }}; }}protected void onActive(a) {
          super.onActive();
          mContainer.onActive(this);
          // The first time an observer is registered, this is triggered to load the first data
          getQueryExecutor().execute(mRefreshRunnable);
     }

    final Runnable mRefreshRunnable = new Runnable() {
         @WorkerThread
         @Override
         public void run(a) {
            // Multi-threaded synchronization is guaranteed through AtomicBoolean CAS, and observers are registered with InvalidationTracker
             if (mRegisteredObserver.compareAndSet(false.true)) {
                 mDatabase.getInvalidationTracker().addWeakObserver(mObserver);
             }
             boolean computed;
             do {
                 computed = false;
                 // compute can happen only in 1 thread but no reason to lock others.
                 if (mComputing.compareAndSet(false.true)) {
                     // as long as it is invalid, keep computing.
                     try {
                         T value = null;
                         while (mInvalid.compareAndSet(true.false)) {
                             computed = true;
                             try {
                                 CacheDao_Impl = query2; callback = query2; callback = query2
                                 value = mComputeFunction.call();
                             } catch (Exception e) {
                                 throw new RuntimeException("Exception while computing database"
                                       + " live data.", e); }}if (computed) {
                            // Load the latest data and send it to the observerpostValue(value); }}finally {
                         mComputing.set(false); }}}while(computed && mInvalid.get()); }};Copy the code

Table data change monitoring

Insert, delete, and update all execute __db.beginTransaction() in the same way. _db.endTransaction() is executed after the operation is executed;

Each of these methods inserts a record into a table in the Room library that records the name of the current operation. And query a collection of all tables whose data is considered to have changed.

class CacheDao_Impl extends CacheDao{
     private final RoomDatabase __db;
     public CacheDao_Impl(RoomDatabase __db) {
        this.__db = __db;
     }
    @Override
     public void insert(final Cache cache) {
        __db.assertNotSuspendingTransaction();
        __db.beginTransaction();
        try {
        __insertionAdapterOfCache.insert(cache);
        __db.setTransactionSuccessful();
      } finally{ __db.endTransaction(); }}@Override
    public void delete(final Cache cache) {
       __db.assertNotSuspendingTransaction();
       __db.beginTransaction();
      try {
        __deletionAdapterOfCache.handle(cache);
        __db.setTransactionSuccessful();
      } finally{ __db.endTransaction(); }}@Override
    public void update(final Cache cache) {
      __db.assertNotSuspendingTransaction();
      __db.beginTransaction();
      try {
        __updateAdapterOfCache.handle(cache);
        __db.setTransactionSuccessful();
      } finally{ __db.endTransaction(); }}}Copy the code

RoomDatabase

The mInvalidationTracker object is created in the Constructor of RoomDatabase and the beginTransaction and endTransaction events are distributed to it. This enables InvalidationTracker to implement table state logging and notification of table status updates.

abstract class RoomDatabase{
      public RoomDatabase(a) {
        // Build the database InvalidationTracker in the constructor
        // A table used to track data changes
        mInvalidationTracker = createInvalidationTracker();
        // This is an abstract method implemented in a subclass new InvalidationTracker(this,... ,"table_cache","table_user"); The names of all the tables in the database are passed
    }
     public void beginTransaction(a) {
        assertNotMainThread();
        SupportSQLiteDatabase database = mOpenHelper.getWritableDatabase();
        // InvalidationTracker is then notified to update the status of the table whose data is about to change
        mInvalidationTracker.syncTriggers(database);
        database.beginTransaction();
      }

     public void endTransaction(a) {
        mOpenHelper.getWritableDatabase().endTransaction();
        if(! inTransaction()) {// Then inform InvalidationTracker to find out which tables count as having changedmInvalidationTracker.refreshVersionsAsync(); }}}Copy the code

InvalidationTracker implements table state logging and query

The add, delete and modify operations of each table will be recorded in this class, and all table records with state changes will be queried after the operation is completed. The Observer registered in RoomTrackingLiveData is then notified to reload the table data, so that data changes can be automatically queried for the latest data to update the UI.

class InvalidationTracker{
   public InvalidationTracker(RoomDatabase database, ... ,String... TableNames // Array of database tables [table_cache,table_user] {
             mDatabase = database;
             mObservedTableTracker = new ObservedTableTracker(tableNames.length);
        }
//InvalidationTracker is created with all the tables in the database known
// The syncTriggers method will traverse each table to see if the obSever has registered data changes
// If so, enable state logging for the table, that is, write records to room_table_modiFICATION_log
// The table is like this:
// select * from 'table_id', 'invalidated' column
// table_cache 1 // if the value is 1, the data is considered changed.
                                   // table_id is the Id of the table (1,2,3)
// table_cache2 0
// table_cache3 0
// So the so-called automatic monitoring of database data changes is implemented in this way. There is nothing mysterious about it.
private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) {
        writableDb.execSQL(
                "INSERT OR IGNORE INTO " + UPDATE_TABLE_NAME + " VALUES(" + tableId + ", 0)");
        final String tableName = mTableNames[tableId];
        StringBuilder stringBuilder = new StringBuilder();
        for (String trigger : TRIGGERS) {
            stringBuilder.setLength(0);
            stringBuilder.append("CREATE TEMP TRIGGER IF NOT EXISTS ");
            appendTriggerName(stringBuilder, tableName, trigger);
            stringBuilder.append(" AFTER ")
                    .append(trigger)
                    .append(" ON `")
                    .append(tableName)
                    .append("` BEGIN UPDATE ")
                    .append(UPDATE_TABLE_NAME)
                    .append(" SET ").append(INVALIDATED_COLUMN_NAME).append("= 1")
                    .append(" WHERE ").append(TABLE_ID_COLUMN_NAME).append("=").append(tableId)
                    .append(" AND ").append(INVALIDATED_COLUMN_NAME).append("= 0")
                    .append("; END"); writableDb.execSQL(stringBuilder.toString()); }}public void refreshVersionsAsync(a) {
        if (mPendingRefresh.compareAndSet(false.true)) {
        // Schedule the mRefreshRunnable task from the thread pool
         mDatabase.getQueryExecutor().execute(mRefreshRunnable);
        }
    }
}
   Runnable mRefreshRunnable = new Runnable() {
        @Override
        public void run(a) {...// Select * from the room_table_modiFICATION_log table where the value of 1 is used and all the invalidated columns are read.
            Set<Integer> invalidatedTableIds = checkUpdatedTable();
            if(invalidatedTableIds ! =null && !invalidatedTableIds.isEmpty()) {
                synchronized (mObserverMap) {
                    for (Map.Entry<Observer, ObserverWrapper> entry : mObserverMap) {
                    // The table_id is used to find the table_name. The Observer registered in each RoomTrackingLiveData is notified
                    // To implement the table data changes automatically query the latest data update UI abilityentry.getValue().notifyByTableInvalidStatus(invalidatedTableIds); }}}}Copy the code

5. To summarize

  • Do you use a combination of Room and LiveData? What are its advantages? How does the combination of Room+LiveData monitor the changes of standard data and automatically load the latest data refresh page? The answers to these two questions are self-evident if you read the text carefully.
  • Students doing business architecture design, if the layered, each class should only do what unstable balance, you can refer to three layer design of the Room and FrameworkSqliteOpenHelperd responsibilities