TRY ANDROID DEV

Android アプリ開発のコーディングネタ。あとエンジニアとしての活動

Android Architecture Components: Room の再復習

背景

  • 急遽RoomとPagingの再復習をする必要に駆られたので頑張る
  • まずはRoomから

Room

RoomはQLiteをラップして利用できるコンポーネント

以下のCodeLabsを行う。 codelabs.developers.google.com

Gradleの設定

以下の設定をbuild.gradleに記載する

// Room components
implementation "android.arch.persistence.room:runtime:$rootProject.roomVersion"
kapt "android.arch.persistence.room:compiler:$rootProject.roomVersion"
androidTestImplementation "android.arch.persistence.room:testing:$rootProject.roomVersion"

// Lifecycle components
implementation "android.arch.lifecycle:extensions:$rootProject.archLifecycleVersion"
kapt "android.arch.lifecycle:compiler:$rootProject.archLifecycleVersion"

// Coroutines
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

build.gradleに記載するimplementationについて復習しておく。

compile : 依存関係を追加する。ただしBプロジェクトをcompileで指定したAプロジェクトは、BがCライブラリに依存していた場合、AもCライブラリに依存することになる。deplicated。
implementation : 依存関係を追加する。ただしBプロジェクトをcompileで指定したAプロジェクトは、BがCライブラリに依存していた場合でもAはCライブラリに依存しない(AはCライブラリを利用できない)
api : compileと同じ動作をする。

なぜcoroutinesのdependenciesをapiにしているのかは謎だ。。 あとCodeLabsで利用しているバージョンは0.29.1と古いものだったので最新の1.0.1にしておく。

Entityを作る

ここでいうEntityはSQLの文脈。Entityはテーブルの行を表しており、以下のようなクラスにAnnotation Processingで表すことができる。

@Entity(tableName = "word_table")
data class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

各要素について記載します。

  • @Entity

    • テーブル内の行を指定するAnnotation。クラスの名前と別にしたい場合はtableNameを指定する
  • @PrimaryKey

    • すべてのエンティティに主キーを設定する必要がある
  • @ColumnInfo

    • メンバー変数の名前と異なる名前にしたい場合はnameで指定する(なくても良い)

注意事項として、全てのフィールドはpublicもしくはgetterを設定する必要があるようです。

Daoを作る

Data Access Objectを作成する。要するにテーブルに対してアクセスするオブジェクト。

@Dao
interface WordDao {

    @Query("SELECT * from word_table ORDER BY word ASC")
    fun getAllWords(): List<Word>

    @Insert
    fun insert(word: Word)

    @Query("DELETE FROM word_table")
    fun deleteAll()
}

各要素について記載します。

  • @Dao
    • Data Access Objectであることを指定する。interfaceに対して指定すること。(このinterfaceから自動的にDataAccessObjectを作ってくれる)
  • @Query
    • このAnnotationを設定したメソッドを実行すると指定したSQLが実行される
  • @Insert
    • メソッドの引数に指定したEntityを挿入する。競合した場合の振る舞いをonConflictで指定できる。
    • 詳細は次のURLを参照: developer.android.com

LiveDataと連携する

データベース変更された際にUIに通知したいよね。RoomはLiveDataを戻り値に設定してUIに通知できるんです。

  @Query("SELECT * from word_table ORDER BY word ASC")
   fun getAllWords(): LiveData<List<Word>> // LiveDataを設定する

RoomDatabaseを作る

RoomはSQLiteデータベースをラッピングする仕組みです。SQLiteOpenHelperのようなSQLiteを利用する時に必要だったタスクを肩代わりしてくれるようです。 Daoを利用してSQLiteにクエリを発行します。またSQLiteステートメントコンパイル時チェックをしてくれるようです。素敵!

@Database(entities = [Word::class], version = 1)
abstract class WordRoomDatabase : RoomDatabase() {
    abstract fun wordDao(): WordDao

    companion object {
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    WordRoomDatabase::class.java,
                    "Word_database" // テーブル名
                ).build()
                INSTANCE = instance
                return instance
            }
        }
    }
}

各要素について記載します。

  • @Database(entities = [Word::class], version = 1)
    • 対象のクラスがDatabaseであることを指定する。
    • entitiesにこのDatabaseのテーブルを指定する.
    • versionも指定できます。マイグレーション時に必要。
  • Databaseに指定するクラスはabstractを指定すること。
  • また、このデータベースにアクセスするDaoもabstractメソッドで指定すること。
  • @Volatile
    • これはJVM上でvolatileとして解釈されるannotationです。
    • 簡単に説明すると値のキャッシュを行わず常にメモリから取ってくることでマルチスレッド時の動作でも値が異ならないようにするための指定です。
    • synchronizedとの違いはスレッドセーフなのかそうでないのからしいです。
  • Databaseは1アプリにつき1インスタンスなのでシングルトンとします。
  • インスタンスを生成する時はRoom.databaseBuilder()を利用します。

リポジトリを作成する

リポジトリパターンのためにリポジトリを作成します。 基本的にはこの層でAPIから取ってくるかDatabaseから取ってくるかを決める感じです。

class WordRepository(private val wordDao: WordDao) {

    val allWords: LiveData<List<Word>> = wordDao.getAllWords()

    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

@WorkerThreadは「このメソッドは非同期通信する必要があるよ」ということを明示的に示すためのAnnotaionです。 Roomは非同期(UIThread以外)で利用する必要があるので、記載します。

ViewModel を作る

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private var parentJob = Job()
    private val coroutineContext: CoroutineContext
        get() = parentJob + Dispatchers.Main
    private val scope = CoroutineScope(coroutineContext)

    private val repository: WordRepository
    val allWords: LiveData<List<Word>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application, scope).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

    fun insert(word: Word) = scope.launch(Dispatchers.IO) {
        repository.insert(word)
    }

    override fun onCleared() {
        super.onCleared()
        parentJob.cancel()
    }
}

ここはViewModelなので省きます。allWordsにLiveDataを設定し、initブロックでRoomと接続してますね。

Databaseにオープン時のCallbackを設定する

Databaseのイベントに対してCallbackを設定できるようです。 ここではオープン時に今までのデータを全て消して二つの単語を登録しています。

@Database(entities = [Word::class], version = 1)
abstract class WordRoomDatabase : RoomDatabase() {
    abstract fun wordDao(): WordDao

    companion object {
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(
            context: Context,
            scope: CoroutineScope
        ): WordRoomDatabase {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    WordRoomDatabase::class.java,
                    "Word_database"
                ).addCallback(WordDatabaseCallback(scope)).build() // オープン時のコールバックを設定
                INSTANCE = instance
                return instance
            }
        }
        private class WordDatabaseCallback(
            private val scope: CoroutineScope
        ) : RoomDatabase.Callback() {

            override fun onOpen(db: SupportSQLiteDatabase) { // オープンイベント
                super.onOpen(db)
                INSTANCE?.let { database ->
                    scope.launch(Dispatchers.IO) {
                        populateDatabase(database.wordDao())
                    }
                }
            }

            // オープン時の処理
            fun populateDatabase(wordDao: WordDao) {
                wordDao.deleteAll()

                var word = Word("Hello")
                wordDao.insert(word)
                word = Word("World!")
                wordDao.insert(word)
            }
        }


    }


}

あとはinsertしたり、Observeしたり。

あとはViewModelのLiveDataをObserveして、変更が発生した場合の処理書いたり、

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.setWords(it) }
})

Roomにデータを追加したい時はinsertしたりできます。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.let {
            val word = Word(it.getStringExtra(NewWordActivity.EXTRA_REPLY))
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
               applicationContext,
               R.string.empty_not_saved,
                Toast.LENGTH_LONG).show()
    }
}

まとめ

  • Room を利用して永続化を行うと、SQLiteの厄介なタスクやStatement等を書かなくて良い
  • しかもLiveDataと接続するとDatabaseの中身が変更された際にUIに通知できる。素敵。
  • SQLコンパイル時に検証できる!素敵!