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

Android RoomのautoGenerateの挙動

以下の定義のテーブルに対してINSERTをすることでデータがどのように更新されていくかを確認する。

@Entity(tableName = "Sample")
data class SampleEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String
)

環境は以下の通り

利用しているコード(検証用アプリ)はこちら

github.com

autoGenerate=trueとは

公式の説明から以下のことがわかる PrimaryKey  |  Android Developers

  • SQLiteが一意のIDを生成する
  • データの型はINTEGERになる
  • INSERT時に0またはNULLは「値がセットされてない」とみなされる

Shema確認

RoomDatabaseexportSchema = true設定し、コンパイルオプションでroom.schemaLocationを指定した状態でビルドをすると、スキーマJSON形式でファイル出力される。上記SampleテーブルのCREATE文は以下のようになっている。

CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (
    `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` TEXT NOT NULL
)

idカラムがAUTOINCREMENT指定されている。

AUTOINCREMENT

公式を読む。

sqlite.org

  • INTEGER PRIMARY KEYで修飾されるカラムはROWIDエイリアスである
  • ROWIDまたはINTEGER PRIMARY KEYに対する値が明示的にセットされていない場合にはSQLiteが未使用の数値を自動で割り当てる
    • この数値は通常利用されている数値の最大値 + 1 になる
    • この挙動はAUTOINCREMENTがあってもなくても同じである
  • AUTOINCREMENTが指定されていると「DBのライフサイクルを通してIDの再利用をしない」というようなアルゴリズムになる
    • つまり「過去に削除されたレコードがもっていたIDの使用を避ける」ということである

Summaryを読めばだいたいのことがわかる(この記事ではROWIDの説明は省略)。

INSERT

次のようなDaoメソッドを用意して、SampleEntityidを未設定(デフォルト値である0)でINSERTを行ってみる。

@Insert
suspend fun insertIncrementId(sampleEntity: SampleEntity)

すると、idが1, 2,3.....と順に振られてレコードがinsertされていくのがわかる。期待通りの動作だ。

複数のデータをinsertする場合はどうだろうか。

@Insert
suspend fun updateIncrementId(sampleEntity: List<SampleEntity>)

レコードを3つずつinsertしてもidは順にincrementされていくのがわかる

また一度全レコードをDELETEした後にinsertすれば、「DBのライフサイクルを通してIDの再利用をしない」ことも確認できる。

@Query("DELETE FROM Sample")
suspend fun deleteAll()

なおidを明示的に指定してINSERTした場合の挙動はOnConflictStrategyに従うがここでは深入りしない。

クエリの確認

Roomアノテーションプロセッサでビルド時にコードを生成する。@InsertアノテーションからはINSERTクエリが生成される。

INSERT OR ABORT INTO `Sample` (`id`,`name`) VALUES (nullif(?, 0),?);

OR ABORTの部分はOnConflictStrategyによって変わるが、だいたいこのようなクエリになる。生成されるのはJavaのコードなので、nullIf(?, 0)の部分はInteger(正確にはKotlinのInt)がnullの場合を考慮していると思われる。

所感

  • 主キーになるような明示的なカラムがなく、一意の整数値IDを付与したい場合には有効
    • また何らかの理由でinsert順を保持したい場合にも有効かもしれない
  • 一方で、INTEGER PRIMARY KEY AUTOINCREMENTなカラムがあると他にPrimaryKeyを追加することはできないため、明に主キーになるカラムがあるのであればそちらを使うべきだと思われる
    • 逆言えば、このときAUTOINCREMENTは使えなくなるので、必要があれば何らかの手段で代替する必要があるだろう