Room multimaps を試す

はじめに

AndroidX RoomのVersion 2.4.0-alpha04で、multimapsという機能が追加されたようです。

developer.android.com

One-to-One や One-to-Many の関係があるテーブルをJoinした結果をMapで返すことができるようになるみたいです。面白そうなのでこれを試そうと思います。

なお上記のリリースノートではSongArtistを使ったサンプルコードが乗っていますが、実装の詳細はUnitTest実装を参照すると良さそうです。

https://android.googlesource.com/platform/frameworks/support/+/542fdb6c06373f52b6fedb163952ce6cb09188d2%5E1..542fdb6c06373f52b6fedb163952ce6cb09188d2/

環境

次の環境で検証しています。

準備

まずは適当にEntityを用意します。 One-To-OneとしてUser to Bank、One-To-ManyとしてUser to Paymentとしてみました。

@Entity(tableName = "User")
data class UserEntity(
    @PrimaryKey @ColumnInfo(name = "user_id") val id: String,
    @ColumnInfo(name = "user_name") val name: String,
    @ColumnInfo(name = "bank_id") val bankId: String
)

@Entity(tableName = "Bank")
data class BankEntity(
    @PrimaryKey @ColumnInfo(name = "bank_id") val id: String,
    val name: String
)

@Entity(tableName = "Payment")
data class PaymentEntity(
    @PrimaryKey @ColumnInfo(name = "payment_id") val id: String,
    val amount: Int,
    @ColumnInfo(name = "user_id") val userId: String
)

注意①

multimapで返すEntityに同名のカラムが存在すると、結果がおかしくなるバグが存在するようです。

https://issuetracker.google.com/issues/201306012

同名カラムが存在する場合にはワーニングを出力する方向で対応が行われそうです。 issuetrackerにもコメントされていますが、当面は、同名カラムにエイリアスをつけるなどして回避するのが良さそうです。 この記事では@ColumnInfoで重複しないカラム名を設定しています。

注意②

Entityにdata classを利用しないと、次のビルドエラーが発生します(Javaの場合には警告内容どおりにequalsとhashCodeを実装すればよさそうです)。

警告:The key of the provided method's multimap return type must implement equals() and hashCode().

肝心のmultimapのDaoメソッドはこんな感じにしてみました。

@Query("SELECT * FROM User JOIN Bank ON User.bank_id = Bank.bank_id")
suspend fun getUserAndBank(): Map<UserEntity, BankEntity>

@Query("SELECT * FROM User JOIN Payment ON User.user_id = Payment.user_id")
suspend fun getUserAndPayments(): Map<UserEntity, List<PaymentEntity>>

suspendを使うためandroidx.room:room-ktxをdependenciesに追加しています。

遊ぶ

UnitTestを書いて遊んでみます。

One-To-One(User to Bank)はこんな感じ。

// before
val bank1 = BankEntity("bank_id_1", "AAA")
val bank2 = BankEntity("bank_id_2", "BBB")
val banks = listOf(bank1, bank2)

val john = UserEntity("user_id_1", "John", "bank_id_1")
val bob = UserEntity("user_id_2", "Bob", "bank_id_2")
val andy = UserEntity("user_id_3", "Andy", "bank_id_2")
val users = listOf(john, bob, andy)

// during
bankDao.insertBanks(banks)
userDao.insertUsers(users)

// after
val userToBank = userDao.getUserAndBank()
val expected = mapOf(
    john to bank1,
    bob to bank2,
    andy to bank2
)
assertThat(userToBank).containsExactlyEntriesIn(expected)

One-To-Many(User to Payment)はこんな感じです。

// after
val bank1 = BankEntity("bank_id_1", "AAA")
val bank2 = BankEntity("bank_id_2", "BBB")
val banks = listOf(bank1, bank2)

val john = UserEntity("user_id_1", "John", "bank_id_1")
val bob = UserEntity("user_id_2", "Bob", "bank_id_2")
val andy = UserEntity("user_id_3", "Andy", "bank_id_2")
val users = listOf(john, bob, andy)

val payment1 = PaymentEntity("payment_id_1", 100, "user_id_1")
val payment2 = PaymentEntity("payment_id_2", 100, "user_id_2")
val payment3 = PaymentEntity("payment_id_3", 200, "user_id_2")
val payment4 = PaymentEntity("payment_id_4", 100, "user_id_3")
val payment5 = PaymentEntity("payment_id_5", 200, "user_id_3")
val payments = listOf(payment1, payment2, payment3, payment4, payment5)

// during
bankDao.insertBanks(banks)
userDao.insertUsers(users)
paymentDao.insertPayments(payments)

// after
val userToPayment = userDao.getUserAndPayments()
val expected = mapOf(
    john to listOf(payment1),
    bob to listOf(payment2, payment3),
    andy to listOf(payment4, payment5)
)
assertThat(userToPayment).containsExactlyEntriesIn(expected)

期待通りです。

さいごに

multimap、使い所はそこそこあるような気がします。JOINしたEntityの情報すべてが欲しい状況で、新たにデータクラスを作成するのではなくてmultimapを使えばいい場面もあるかもしれません。

またRoom 2.4.0-alpha05では MapInfo というアノテーションが追加されていて、multimapが使える幅が増えているようです。

利用したコードはこちらにおいています。記事中では省略しましたが、EntityにはforeignKeysを設定し、データ不整合が発生しないようにしています。

github.com

TestParameterInjectorをKotlinで使う

はじめに

TestParameterInjectorという、Junit4でParameterized Testをいい感じに書けるライブラリがある。

github.com

READMEなどはJavaをターゲットに記述されているが、Kotlinでも使える。

Gradle + Kotlinで使う

適当にGradleのプロジェクトを作成する。build.gradleに以下を追加。

dependencies {
    testImplementation 'com.google.testparameterinjector:test-parameter-injector:1.4'
}

あとはREADMEに書いてあるコードをそのままKotlinで書けばOK

@RunWith(TestParameterInjector::class)
class SampleTestParameterInjector {

    @Test
    fun test(@TestParameter enabled: Boolean) {
        assertThat(enabled).isTrue()
    }
}

これだけでenabledtrue, falseの2パターンのテストが実行される。 今回は特に関係ないが、アサーションライブラリはTruthを利用している。

truth.dev

Booleanだけではなくenumも全パターン網羅してくれる。

また、

@Test
fun testCombination(
    @TestParameter isPhone: Boolean, 
    @TestParameter isAndroid: Boolean
){
    ...
}

このように書くと、isPhone(true, false) * isAndroid(true, false) 計4つのテストが実行される。

プロパティに対して@TestParameterを使う場合にはプロパティをlateinit varで宣言しておくと良さそうだ。

@TestParameter lateinit var value: MyEnum

enum class MyEnum {
    VALUE_A,
    VALUE_B,
    VALUE_C
}

valueVALUE_A, VALUE_B, VALUE_Cそれぞれの状態で、各テストケースが実行されるようになる。

@TestParametersというパラメータもあって、

@Test
@TestParameters(
    value = [
        "{age: 17, expectIsAdult: false}",  //  パターン1
        "{age: 22, expectIsAdult: true}"   //  パターン2
    ]
)
fun personIsAdult(age: Int, expectIsAdult: Boolean) {
    ...
}

これで2パターンのパラメータでテストが実行される。

なお、この@TestParemetersをつけたテストを実行した際に次のようなエラーが発生する場合がある。

ava.lang.IllegalStateException: No parameter name could be found for public final void SampleTestParameterInjector.personIsAdult(int,boolean), which likely means that parameter names aren't available at runtime. Please ensure that the this test was built with the -parameters compiler option.

-parametersオプションが必要らしいので、build.gradleに以下を追加する。

gradle.projectsEvaluated {
    tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
        kotlinOptions.freeCompilerArgs += ['-java-parameters']
    }
}

こちらのRedditのスレッドが参考になった。

A few tips for TestParameterInjector Library : androiddev

Room auto-migrations を試す

Room 2.4.0-alpha01 で(個人的に)待望の auto-migrations 機能がリリースされました。

SQLiteスキーマを変更する場合には、これまでMigrationクラスを実装する必要がありましたが、auto-migrationsによりマイグレーションの更新クエリがビルド時に自動的に生成されるようになります。

medium.com

サンプル実装

このようなテーブルをベースにします。

@Entity
data class Books(
    @PrimaryKey val title: String,
    val author: String
)

kaptでコードが生成されさえすればいいので、Databaseは最低限の実装で。

@Database(
    version = 1,
    exportSchema = true,
    entities = [
        Books::class
    ]
)
abstract class AppDatabase : RoomDatabase()

ビルドすると/app/build/generated/source/kapt/debug配下にAppDatabase_Impl.javaが生成されます。まだ auto-migration の指定をしていないので以下のような実装が見えます。

@Override
protected List<Migration> getAutoMigrations() {
  return Arrays.asList();
}

カラム追加

Booksテーブルにカラムを追加します。

 @Entity
 data class Books(
     @PrimaryKey val title: String,
-    val author: String
+    val author: String,
+    @ColumnInfo(defaultValue = "0") val page: Int
 )

auto-migrationでカラム追加を行う場合、デフォルト値を指定してあげないと以下のようなビルドエラーが発生しました(Kotlinのデフォルト引数でも同様でした)。

New NOT NULL column'page' added with no default value specified. Please specify the default value using @ColumnInfo.

AppDatabaseのバージョンをインクリメントして、autoMigrationsを指定します。

 @Database(
-    version = 1,
+    version = 2,
     exportSchema = true,
     entities = [
         Books::class
+    ],
+    autoMigrations = [
+        AutoMigration(from = 1, to = 2)
     ]
 )
 abstract class AppDatabase : RoomDatabase()

ビルドすると先ほど空リストを返していた実装が以下のようになっています。

@Override
protected List<Migration> getAutoMigrations() {
  return Arrays.asList(new AutoMigration_1_2_Impl());
}

Migrationクラスはこんな実装でした。

@SuppressWarnings({"unchecked", "deprecation"})
class AutoMigration_1_2_Impl extends Migration {
  public AutoMigration_1_2_Impl() {
    super(1, 2);
  }

  @Override
  public void migrate(@NonNull SupportSQLiteDatabase database) {
    database.execSQL("ALTER TABLE `Books` ADD COLUMN `page` INTEGER NOT NULL DEFAULT 0");
  }
}

カラム名変更

Roomが自動で変更検知できないマイグレーションの例としてカラム名変更を見てみます。

 @Entity
 data class Books(
-    @PrimaryKey val title: String,
+    @PrimaryKey val name: String,
     val author: String,
     @ColumnInfo(defaultValue = "0") val page: Int
 )

AppDatabaseはこのように変更します。

     autoMigrations = [
-        AutoMigration(from = 1, to = 2)
+        AutoMigration(from = 1, to = 2),
+        AutoMigration(from = 2, to = 3, spec = AppDatabase.BooksMigrationSpec::class)
     ]
 )
-abstract class AppDatabase : RoomDatabase()
+abstract class AppDatabase : RoomDatabase() {
+
+    @RenameColumn(tableName = "Books", fromColumnName = "title", toColumnName = "name")
+    class BooksMigrationSpec : AutoMigrationSpec
+}

ビルドするとAutoMigration_2_3_Impl.javaが生成されます。migrationメソッドだけ抜き出すとこんな感じです。

@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
  database.execSQL("CREATE TABLE IF NOT EXISTS `_new_Books` (`name` TEXT NOT NULL, `author` TEXT NOT NULL, `page` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))");
  database.execSQL("INSERT INTO `_new_Books` (author,name,page) SELECT author,title,page FROM `Books`");
  database.execSQL("DROP TABLE `Books`");
  database.execSQL("ALTER TABLE `_new_Books` RENAME TO `Books`");
  callback.onPostMigrate(database);
}
  1. rename後のカラム名_new_Booksテーブルを作成
  2. 既存データを移行
  3. 既存のBooksテーブルを削除
  4. _new_BooksBooksにrename

という流れになっています。これはhttps://sqlite.org/lang_altertable.html6. Making Other Kinds Of Table Schema Changesで解説されている方法と同じです。

実装

以下はコードリーディングのメモです。

だいたい次のようなステップでauto-migrationsのコード生成が実現されているようです。

  1. Databaseに指定したautoMigrations情報を取得
  2. それぞれのmigrationについて対象のスキーマの差分を検出し、差分の種類に応じたAutoMigrationに変換、通知する
  3. AutoMigrationの情報からjavapoetを使ってMigrationクラスを生成

autoMigrations情報を取得

AutoMigrationを生成

  • ↑のステップで得た情報を渡してAutoMigrationProcessorprocessメソッドを呼び出します
  • 先に(指定されていれば)AutoMigrationSpecを処理し、それぞれに対してAutoMigrationを作っていきます
  • これらの情報も考慮しつつ、対象スキーマの差分検出が行われます
    • 検出処理の実装はSchemaDiffer
    • 差分検出の実装は複雑です
    • 結果はSchemaDiffResultにまとめられて通知されます 
      • このクラスのプロパティを見ると、auto-migrationがスキーマの差分をどのように分類して処理をしているかがわかります
      • カラム名変更の場合はAutoMigration.ComplexChangedTableが作られます

Migrationクラスを生成

  • AutoMigrationWriterで行われます
  • SchemaDiffResultを見てjavapoetで頑張る実装です
  • migrationメソッドの生成はaddMigrationStatementsメソッドで行われています
  • AutoMigrationWriterを呼び出しているのはDatabaseProcessingStep

さいごに

stableリリースが待ち遠しいです。

使ったサンプルコードはこちらに置いています。

github.com

Dagger2.31以降のViewModelのDIについてメモ

Daggerを使ってViewModelをDIするには@Multibindingsを使う方法を始めとしていくつか考えられますが、ここでは@MultibindingsSubComponentなどは使わずに、ファクトリークラスを自分で用意する方法を、Dagger2.31で導入されたAssitedInjectも絡めてメモしておきます。

なお、この記事ではHilt, Dagger-Androidは扱いません。

AssistedInjectを使わない実装

以下のようなカスタムファクトリークラスを自分で用意します。

class BookViewModel(
    private val bookId: String,
    private val bookRepository: BookRepository
) : ViewModel() {
    ... ...

    class Factory @Inject constructor(
        private val bookRepository: BookRepository
    ) {
        fun create(
            val bookId: String
        ): BookViewModel { 
            return SampleViewModel(bookId, bookRepository)
        }
    }
}

UI側では以下のようにしてBookViewModelを生成できます。

@Inject
lateinit var bookViewModelFactory: BookViewModel.Factory
private val bookViewModel by viewModels<BookViewModel>(
    factoryProducer = {
        viewModelFactory {
            bookViewModelFactory.create("12345")
        }
    }
)

ここでは簡単のためbookIdとして12345という固定値を渡していますが、実際にはSafe Argsなどから取得した値を渡すことなどが多いかと思います。

なお、viewModelFactoryは次のようなメソッドです。

fun <T : ViewModel> viewModelFactory(
    factory: () -> T
): ViewModelProvider.Factory {
    return object : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return factory() as T
        }
    }
}

AssistedInjectを使った実装

上記の実装もいいのですが実装量は増えますし、例えばViewModelの依存するクラスが増減した場合にはファクトリークラスの修正も必要になってしまいます。

AssitedInjectを使うとこの問題が解決できます。AssitedInjectはもともとSquareが開発しているライブラリですが、最近Dagger本体に導入されました。

github.com

dagger.dev

DaggerのAssitedInjectを使って書き換えると次のようになります(UI側の実装は変わらないので省略します)。

class BookViewModel @AssistedInject constructor(
    @Assisted private val bookId: String,
    private val bookRepository: BookRepository
) : ViewModel() {
    ... ...

    @AssistedFactory
    interface Factory {
        fun create(bookId: String): BookViewModel
    }
}

かなりシンプルになりました。AssistedInjectの詳しい使い方については上記リンクを参照してください。

UIからデータを渡さない場合

上記はUI側からViewModelコンストラクターに何かしらの値を渡すケースでしたが、値を渡さないケースも同じように実装できます。

class BookViewModel @AssistedInject constructor(
    private val bookRepository: BookRepository
) : ViewModel() {
    ... ...

    @AssistedFactory
    interface Factory {
        fun create(): BookViewModel
    }
}

SquareのAssistedInjectを利用しても同様の実装ができますが、ビルド時に以下のような警告が出ます。

Assisted injection without at least one @Assisted parameter can use @Inject", targetConstructor

一方、DaggerのAssistedInject@Assistedがないケースでの利用も想定されているようで、警告は出ません。

まとめ

  • カスタムファクトリークラスを用意してViewModelをDIする方法を紹介しました
  • Dagger2.31で導入されたAssistedInjectを使うことで実装をよりシンプルにできます
  • なお、SavedStateHandlerを扱うViewModelのDI方法については模索中です

使ったコードはこちらに置いています。

github.com

Room Daoの戻り値 Flowable と Flow の違い

Android Architecture ComponentのRoomのDao で、RxJavaのFlowableを戻り値とした場合と、Kotlin CoroutinesのFlowを戻り値とした場合の挙動差異についてメモします。

前提

検証は以下の環境で行っています。

検証コードはこちらです。

GitHub - n-seki/SampleCompareRxCoroutinesRoom: Sample App to compare rx and coroutines room behavior

Flowable と Flow

以下のようなDaoメソッドを考えます。

@Query("SELECT * FROM Books WHERE id = :id")
fun findById(id: Long): Flowable<BookEntity>

idが一致するBooksテーブルのレコードを1件取得していて、テーブルデータに変更があった場合にはFlowableに新しいデータが通知されるようなメソッドです。 (idPrimaryKeyとしているため重複は考えません)

これをKotlin CoroutinesのFlowに書き換えます。FlowableFlowに変わっただけで、挙動的な差異はないように見えます。

@Query("SELECT * FROM Books WHERE id = :id")
fun findById(id: Long): Flow<BookEntity>

が、Booksテーブルにidが一致するレコードが存在しない場合、

  • Flowable -> データは何も通知されない
  • Flow -> nullが通知される

と、異なる挙動となります。

RxJavaではnullが扱えないことを考えると当然の結果ではありますが、なぜこんな差異が生まれるのを確認しておきます。

Roomが生成するコード

テーブルから取得したデータをFlowableに流す処理を見ます。

return RxRoom.createFlowable(__db, false, new String[]{"Books"}, new Callable<BookEntity>() {
  @Override
  public BookEntity call() throws Exception {
    final Cursor _cursor = DBUtil.query(__db, _statement, false, null);
    try {
      final int _cursorIndexOfId = CursorUtil.getColumnIndexOrThrow(_cursor, "id");
      final int _cursorIndexOfTitle = CursorUtil.getColumnIndexOrThrow(_cursor, "title");
      final int _cursorIndexOfAmount = CursorUtil.getColumnIndexOrThrow(_cursor, "amount");
      final BookEntity _result;
      if(_cursor.moveToFirst()) {
        final String _tmpId;
        _tmpId = _cursor.getString(_cursorIndexOfId);
        final String _tmpTitle;
        _tmpTitle = _cursor.getString(_cursorIndexOfTitle);
        final double _tmpAmount;
        _tmpAmount = _cursor.getDouble(_cursorIndexOfAmount);
        _result = new BookEntity(_tmpId,_tmpTitle,_tmpAmount);
      } else {
        _result = null;
      }
      return _result;
    } finally {
      _cursor.close();
    }
  }

Flowの場合にはRxRoom.createFlowableの部分がCoroutinesRoom.createFlowに変わりますが、ロジック自体に差異はありません。

ここから、クエリ結果がnullの場合にも処理は中断しないことが分かります。RxRoom.createFlowableはこんな実装になっていて、クエリを実行するCallableMaybeに変換しflatMapMaybeの戻り値にしています。

https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-master-dev/room/rxjava2/src/main/java/androidx/room/RxRoom.java?autodive=0%2F%2F%2F%2F#68

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public static <T> Flowable<T> createFlowable(final RoomDatabase database,
        final boolean inTransaction, final String[] tableNames, final Callable<T> callable) {
    Scheduler scheduler = Schedulers.from(getExecutor(database, inTransaction));
    final Maybe<T> maybe = Maybe.fromCallable(callable);
    return createFlowable(database, tableNames)
            .subscribeOn(scheduler)
            .unsubscribeOn(scheduler)
            .observeOn(scheduler)
            .flatMapMaybe(new Function<Object, MaybeSource<T>>() {
                @Override
                public MaybeSource<T> apply(Object o) throws Exception {
                    return maybe;
                }
            });
}

ここでやっていることは、テーブル(ここではBooksテーブル)に変更があったときにMaybeを介してCallableの処理(ここではfindByIdクエリ処理)を実行して結果を流すFlowableの作成です。

Maybe.fromCallableでは、callableの実行結果がnullの場合にはMaybeObserver::onSuccessではなく、MaybeObserver::onCompleteが呼ばれます。これによりnullがFlowableに流れないため、後続処理ではnullが流れてくることを考慮する必要はありません。

一方のCoroutinesRoom.createFlowは以下のような実装になっています。

https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-master-dev/room/ktx/src/main/java/androidx/room/CoroutinesRoom.kt#99

@JvmStatic
fun <R> createFlow(
    db: RoomDatabase,
    inTransaction: Boolean,
    tableNames: Array<String>,
    callable: Callable<R>
): Flow<@JvmSuppressWildcards R> = flow {
    // Observer channel receives signals from the invalidation tracker to emit queries.
    val observerChannel = Channel<Unit>(Channel.CONFLATED)
    val observer = object : InvalidationTracker.Observer(tableNames) {
        override fun onInvalidated(tables: MutableSet<String>) {
            observerChannel.offer(Unit)
        }
    }
    observerChannel.offer(Unit) // Initial signal to perform first query.
    val flowContext = coroutineContext
    val queryContext = if (inTransaction) db.transactionDispatcher else db.queryDispatcher
    withContext(queryContext) {
        db.invalidationTracker.addObserver(observer)
        try {
            // Iterate until cancelled, transforming observer signals to query results to
            // be emitted to the flow.
            for (signal in observerChannel) {
                val result = callable.call()
                withContext(flowContext) { emit(result) }
            }
        } finally {
            db.invalidationTracker.removeObserver(observer)
        }
    }
}

ロジックの大枠は同じで、テーブルに変更があったらcallableを実行して、その結果をFlowに流しています。callableの実行結果(つまりクエリ実行結果)がnullの場合にはnullをFlowに流すことになります。

しかしDaoのメソッドの戻り値型はFlow<BookEntity>と定義されているので、例えば後続処理で以下のような実装をしているとnull参照エラーが発生します。

return booksDao.findById("id").map { it.title }  // it.titleでitがnullのために例外が発生します

Flowcatchオペレーターを使えばアプリ全体のクラッシュは回避できますがFlowはキャンセルされるので、このあとテーブルにレコードがインサートされてもデータは流れてきません。何にせよ、意図した挙動ではないでしょう。

修正

クエリ結果がnullになる可能性があるのならば、nullを許容する実装をするべきなので、上記Flowを使ったDaoメソッドは以下のようにすると実態と合うと思います。

@Query("SELECT * FROM Books WHERE id = :id")
fun findById(id: Long): Flow<BookEntity?>

その上で、後続処理でFlow::mapNotNullFlow::filterNotNullでnullを除外する、nullの場合には適切な例外処理を行うなどすれば良いでしょう。

まとめ

  • RxJavaはそもそもnullを扱えないため、DaoのメソッドでFlowableを戻り値型にしている場合にはnullのことを考える必要はない(null値が流れてくることはない)
  • Flowを使った場合も一見同じ挙動になるように思えるが、nullは流れてくる
    • クエリ結果がnullになる可能性があるのならばFlow<Entity?>とnull許容するのが正しい

Database Inspectorがどのように実装されているかメモ

Android StudioDatabase Inspectorという便利な機能が入りました。 Databaseに関わるデバッグが効率良く行えるとても優れた機能ですが、実現方法が気になったので、Android Open Sourceの該当処理を探して、コードリーディングした内容をメモしてみます。

Database Inspectorとは

Android Studio上のUIで、起動中アプリのSQLiteDBの中身を表示・変更できるデバッグツールです。公式記事が詳しいです。

developer.android.com

以前だと、例えばDB Browser for SQLiteのようなDBビューアーを使ったりしていましたが、アプリ起動してそのままAndroidStudio上でデバッグが出来るDatabase Inspectorは大変便利です。

疑問に思ったこと

例えば、RoomでKotlin CoroutinesのFlowを使ってテーブルを監視している場合のことを考えます。アプリ起動中にDatabase Inspectorでテーブルの内容を更新すると、Flowに新たなデータが流れてきます。

詳細は割愛しますが、Roomでのテーブル監視にはInvalidationTrackerというクラスが利用されいます。テーブルデータの変更をアプリが検出するためにはこのInvalidationTracker(またはRoomの他のコンポーネント)が利用されるはずですが、AndroidSudio上のDatabase Inspectorと起動中アプリがどうやってやり取りを行うのかが気になりました。

結論

最初に結論を書いておくと、Database InspectorはART TIという仕組みを利用しているようです。

source.android.com

Android 8.0 以降では、ART Tooling Interface(ART TI)により特定のランタイムの内部を公開して、プロファイラとデバッガでアプリの実行時の動作に影響を与えられるようにすることが可能です。

ART TIの詳細には立ち入りませんが、どうやら起動中アプリのプロセスとやり取りすることが可能な技術のようです。

コードリーディング

ART TIが使われていることは分かりましたが、実装がどうなっているかコードを読みます。AndroidOpenSourceでなんとなくアタリをつけて検索するとSqliteInspectorというズバリなクラスが見つかります。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java

Inspectorというクラスを継承していること、コンストラクタでConnectionInspectorEnvironmentという引数を受け取っていることがわかります。

Inspector

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:inspection/inspection/src/main/java/androidx/inspection/Inspector.java

メソッドのコメントからAndroidStudioからのコマンドを受信する責務をもつことが読み取れます(onReceiveCommand)。Connectionインスタンスを持ちます。

Connection

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:inspection/inspection/src/main/java/androidx/inspection/Connection.java

メソッドが1つのみの小さな抽象クラスです。sendEventがAndroidStudioに対してコマンドを送信するメソッドになるようです。

InspectorEnvironment

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:inspection/inspection/src/main/java/androidx/inspection/InspectorEnvironment.java

Inspector機能に必要なものを提供するinterfaceのようです。ArtToolingART TIのインターフェースのように思われます。 SqliteInspectorの処理を見ると、

mEnvironment.artTooling().findInstances(SQLiteDatabase.class)

というようにSQLiteDatabaseインスタンスを取得していることからもそう想像できるのですが、残念ながらArtToolingの実装クラスを見つけることはできませんでした。

Roomの変更通知までの流れ

ART TISQLiteDatabaseを参照できそうなことはわかったので、テーブル変更通知周りの処理の流れを追ってみます。

前述しましたが、この挙動を実現するためにはInvalidationTrackerにアクセスする必要があります。SqliteInspectorRoomInvalidationRegistryというそれっぽいクラスを参照しているので、これを見てみます。

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RoomInvalidationRegistry.java;l=38?q=RoomInvalidationRegistry&sq=&ss=androidx%2Fplatform%2Fframeworks%2Fsupport

  • 予めandroidx.room.InvalidationTracker::refreshVersionsAsyncのMethodインスタンスをリフレクションで取得しておく
  • triggerInvalidationsが呼ばれるとArtTooling経由で取得したInvalidationTrackerrefreshVersionsAsyncを呼びしてあげる。

...というような実装になっています。

InvalidationTrackerについて

Roomのテーブル監視は、DBのトリガーと監視用テーブルで実現しています。「データ変更があると監視用テーブルの該当レコードにフラグを立てる」ようなトリガーが監視対象のテーブルに設定されます。

そして、然るべきタイミングで監視用テーブルを参照し、フラグが立っているテーブル一覧を取得し、それらのテーブルを監視している処理(クエリ)をinvokeする、というような実装になっています。

上に書いたInvalidationTracker::refreshVersionsAsyncが「監視用テーブルを参照し、フラグが立っているテーブル一覧を取得し、それらのテーブルを監視している処理(クエリ)をinvokeする」メソッドになります。

最後に、SqliteInspector内でRoomInvalidationRegistry::triggerInvalidationsが実行されるタイミングを見ておきます。

triggerInvalidationsのタイミング

直接の呼び出しはtriggerInvalidationメソッドからで、Database Inspectorで実行されたクエリがSELECTでないことを確認しています。これがどこから呼ばれるのか確認すると、onReceiveCommandから呼ばれるhandleQuery(case QUERY)が呼び出し元であることが分かります。

以上をすべてまとめると、以下のようになっているんじゃないかと想像されます。

  1. Database Incpector上でのデータ変更はコマンドタイプ: QUERY としてonReceiveCommandに通知される
  2. 実行されたクエリがSELECTでない場合にRoomInvalidationRegistry::triggerInvalidationsを呼ぶ
  3. ArtToolingで取得した起動中アプリのInvalidationTrackerインスタンスrefreshVersionsAsyncを呼ぶことで、アプリにデータ変更を通知する

さいごに

AndroidStudioとInspectorとの通信はgRPCぽいですが、詳しく追ってません。 ArtToolingの実装は見られなかったので一部想像で書いています。間違いなどあればコメントください。

Activity Result APIのメモ

Activity 1.2.0-alpha02, Fragment 1.3.0-alpha02Activity Result APIという便利なものが導入された。

developer.android.com

startActivityForResult, onActivityResultを置き換えるもので、stableリリースが待ち遠しい。

さて、Activity, Fragmentは2020年10月現在それぞれbeta03がリリースされている。

implementation "androidx.activity:activity-ktx:1.2.0-beta03"
implementation "androidx.fragment:fragment-ktx:1.3.0-beta03"

でもこうするとprepareCallが解決できない。

リリースノートを見る

developer.android.com

developer.android.com

Activity 1.2.0-alpha04, Fragment 1.3.0-alpha04で破壊的変更が入りprepareCallregisterForActivityResultというメソッドに変更されている。

p-rはこれ。renameの意図は不明だが、個人的には変更後のメソッドの方が具体的で理解しやすい、と思う。

さいごに

alpha版は破壊的変更が入ることが多いので、おかしいなと思ったらリリースノートを見て、時間を浪費しない。