Room + LiveData で observe し直さずにクエリを変える

Room + LiveData を使い、特定のクエリで DB に Observer を登録しておくと insert した時などに新しいデータが自動で Observer に流れてくるので便利です。

@Entity(tableName = "articles")
data class Article(
    @PrimaryKey @ColumnInfo(name = "id") val id: Int = 0,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "description") val description: String,
    @ColumnInfo(name = "starts_at") val startsAt: String,
    @ColumnInfo(name = "ends_at") val endsAt: String
)
@Dao
abstract class ArticleDao {

    @Query(
    """
        SELECT
            *
        FROM
            articles
        WHERE
            starts_at <= :date
            AND :date <= ends_at
    """
    )
    abstract fun selectAvailableArticles(date: String): LiveData<List<Article>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    abstract suspend fun insertArticles(articles: List<Article>)
}
class MainViewModel(private val dao: ArticleDao) : ViewModel() {

    fun fetchArticles(): LiveData<List<Article>> {
        // DateTimeUtils.now().formatString() は現在時刻を String で返す
        return articleRepository.selectAvailableArticles(ZonedDateTimeUtils.now().formatString())
    }

    fun insertArticles(articles: List<Article>) {
        dao.insertArticles(articles)
    }
}
class MainActivity : AppCompatActivity() {

    // 省略

    override fun onCreate(savedInstanceState: Bundle?) {
        // 省略
        viewModel.fetchArticles().observe(this, Observer { articles ->
            // データの更新があったら新しい articles が渡される
        })
        
        // insert すると上の Observer にデータが来る
        viewModel.insertArticles(listOf(Article(/* 省略 */)))
    }
}

今回コンテンツに表示期間があるアプリを想定して Articlestarts_atends_at を作りました。
また、 ArticleDao を見るとクエリする時に渡す時刻によってクエリ結果が違うことがわかります。

viewModel.artciles.observe() を呼び出すとメソッドを呼び出した時刻でクエリが実行され、その時点で表示できる Article 一覧が取得できます。
しかし、この実装だと画面更新が必要になった場合 Observer を一度 remove して再度 observe する必要があります。

class MainActivity: AppCompatActivity() {

    private var articles: LiveData<List<Article>>? = null
    
    override fun onCreate(savedInstanceState: Bundle?) {
        articles = viewModel.fetchArticles()
        articles?.observe(this, Observer { })
    }
    
    fun updateArticles() {
        articles?.removeObservers(this)
        articles = viewModel.fetchArticles()
        articles?.observe(this, Observer { })
    }
}

この実装でも想定する挙動は実装できるので問題ないのは事実です。
しかし、グローバル変数が増えますし一度削除して observe し直すので面倒だし美しくないです。

Transformations.switchMap を使う

こういった observe し直さずにクエリパラメータを変えて新しいデータを取得したい場合、パラメータを LiveData にし Transformations.switchMap を使ってクエリを実行しなおするようにすると解決できます。

class MainViewModel(private val dao: ArticleDao) : ViewModel() {

    private val queryTime = MutableLiveData<ZonedDateTime>(ZonedDateTimeUtils.now())

    val availableArticles: LiveData<List<Article>> = Transformations.switchMap(queryTime) {
        articleRepository.fetchAvailableArticles(it)
    }

}
class MainActivity: AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        viewModel.availableArticles.observe(this, Observer { articles ->
    }
    
    fun update() {
        viewModel.queryTime.value = ZonedDateTimeUtils.now()
    }
}

こうすることで、クエリパラメータを変更するだけで新しいデータを取得できるようになりました。

サンプルコード

0.5秒ごとにクエリを実行し直して RecylerView に表示するサンプルを作りました。
詳しい実装は github を見てください。

github.com

参考資料

stackoverflow.com