はじめに
まずこのような社員名簿アプリを想像します。
名前昇順に部署名と一緒に社員一覧が並んでいます。(名前はFakeitで作成)
編集画面を開き保存を行うと、一覧画面で変更内容が反映されます。アプリを作っていればありがちな仕様だと思います。
実現方法としては編集画面から 書き換えた
という情報を持って一覧画面に戻ってきた時に一覧情報を再読み込みする作りが昔からよく行われていたと思います。しかし実プロダクトにおいては編集または閲覧できる画面が多くデータ同士の関係も複雑で、注意深く作らないと更新抜けが発生したりすることもあります。そこでそのような情報をSQLiteに格納して、Roomの監視可能クエリを使うことで、一覧画面や集計画面などの閲覧系画面の更新をシンプルで抜けなく作ることができます。
この記事は
編集操作を一覧画面や集計画面に抜けなく反映することに便利なRoomの更新監視について、やり方が4種類あったので紹介します。
- LiveData
- RxJava
- Coroutines Flow
- Coroutines Flow + asLiveData
※ 2020年11月29日にCoroutines Flow + asLiveDataを増やしました。LiveDataを返却するメソッドをモック化したときの単体テストのやり方をコメント欄で教えて頂いたためです。
さらにその4種類について、実プロダクトにおいてどれを採用すべきかを以下の観点で比較検討します。
- 記述のシンプルさ
- ずっと監視し続けてリークする事故を防げるかという観点も含む
- 単体テストの書きやすさ
この記事ではすべてのソースコードを掲載していません。今回の検証に使用したすべてのソースコードはこちらのGithubリポジトリにあります。
また単体テストはInstrumentation TestではなくLocal Unit Testを想定しています。
SQLiteデータベースの準備
使用ライブラリを設定します。
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// Kotlin Coroutine対応
implementation "androidx.room:room-ktx:$room_version"
// RxJava対応
implementation "androidx.room:room-rxjava2:$room_version"
まず部署と社員を表すデータクラスを作成します。部署1 - 社員多の関係です。
@Entity
data class Division(@PrimaryKey(autoGenerate = true) val id: Long, val name: String)
@Entity(
foreignKeys = [ForeignKey(
entity = Division::class,
parentColumns = arrayOf("id"), childColumns = arrayOf("divisionId")
)]
)
data class Member(@PrimaryKey(autoGenerate = true) val id: Long, val name: String, val divisionId: Long)
社員を部署と一緒に取り出すためのデータを定義します。
data class MemberWithDivision(
@Embedded val member: Member,
@Relation(
parentColumn = "divisionId",
entityColumn = "id"
)
val division: Division
)
必要なDAOを定義します。
@Dao
interface MemberDao {
@Insert
suspend fun insert(division: Division)
@Insert
suspend fun insert(member: Member)
@Query("SELECT * FROM member ORDER BY id LIMIT 1")
suspend fun firstMember(): Member?
@Query("SELECT * FROM division ORDER BY id")
suspend fun listDivisions(): List<Division>
@Query("SELECT * FROM member WHERE id=:id")
suspend fun get(id: Long): MemberWithDivision?
@Update
suspend fun update(member: Member)
/**
* LiveDataで監視する
*/
@Query("SELECT * FROM member ORDER BY member.name")
fun listMembersLiveData(): LiveData<List<MemberWithDivision>>
/**
* RxJavaで監視する
*/
@Query("SELECT * FROM member ORDER BY member.name")
fun listMembersRxFlowable(): Flowable<List<MemberWithDivision>>
/**
* Coroutines Flowで監視する
*/
@Query("SELECT * FROM member ORDER BY member.name")
fun listMembersCoroutineFlow(): Flow<List<MemberWithDivision>>
}
Roomのデータベースを作成します。
@Database(entities = [Member::class, Division::class], version = 1)
abstract class MemberDatabase : RoomDatabase() {
abstract fun memberDao(): MemberDao
}
単体テストの時にモック化できるように、インターフェースとクラスでラップします。この記事ではモック化にMockKを使います。
interface MemberLocalDataStore {
/**
* 初期データ作成
*/
suspend fun makeFixture()
suspend fun listDivisions(): List<Division>
suspend fun get(id: Long): MemberWithDivision?
suspend fun update(member: Member)
fun listMembersLiveData(): LiveData<List<MemberWithDivision>>
fun listMembersRxFlowable(): Flowable<List<MemberWithDivision>>
fun listMembersCoroutineFlow(): Flow<List<MemberWithDivision>>
}
/**
* @param db シングルトンとしてDIコンテナによって作成される
*/
class MemberLocalDataStoreImpl(private val db: MemberDatabase) : MemberLocalDataStore {
override suspend fun makeFixture() {
db.withTransaction {
val dao = db.memberDao()
if (null == dao.firstMember()) {
// データが無ければデータを作る
// 5部署作る
dao.insert(Division(0, "Sales"))
dao.insert(Division(0, "Support"))
dao.insert(Division(0, "Marketing"))
dao.insert(Division(0, "Development"))
dao.insert(Division(0, "Management"))
Fakeit.init()
// 1部署5人
dao.listDivisions().map {
for (i in 0 until 5) {
val name = Fakeit.name().firstName()
val member = Member(0, name, it.id)
dao.insert(member)
}
}
}
}
}
override fun listMembersLiveData(): LiveData<List<MemberWithDivision>> {
return db.memberDao().listMembersLiveData()
}
override fun listMembersRxFlowable(): Flowable<List<MemberWithDivision>> {
return db.memberDao().listMembersRxFlowable().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
override fun listMembersCoroutineFlow(): Flow<List<MemberWithDivision>> {
return db.memberDao().listMembersCoroutineFlow()
}
override suspend fun listDivisions(): List<Division> {
return db.memberDao().listDivisions()
}
override suspend fun get(id: Long): MemberWithDivision? {
return db.memberDao().get(id)
}
override suspend fun update(member: Member) {
db.memberDao().update(member)
}
}
LiveDataで更新監視する
アプリの作りはよくあるAAC + MVVMを想定しています。ViewModelにLiveDataを持たせて、ActivityからはLiveDataを監視しLiveDataの変化をViewの変化とします。RecyclerViewのAdapterにはGroupieを使用しています。
class MainActivity : AppCompatActivity() {
/**
* ViewModelの生成にはDIコンテナのKoinを使用
* https://insert-koin.io/
*/
private val viewModel: MainViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// RecyclerViewの設定(Groupieを使用)
val adapter = GroupAdapter<ViewHolder<*>>()
list.adapter = adapter
list.layoutManager = LinearLayoutManager(this)
list.setHasFixedSize(true)
viewModel.items.observe(this, Observer { items ->
adapter.update(items.map {
MemberGroupieItem(it) {
callEditActivity(it)
}
})
})
//略
}
}
class MainViewModel(localDataStore: MemberLocalDataStore) : ViewModel() {
val items = Transformations.map(localDataStore.listMembersLiveData()) { src ->
src.map {
MemberListItem(
it.member.id,
it.member.name,
it.division.name
)
}
}
}
class MemberGroupieItem(private val item: MemberListItem, private val onClick: () -> Unit) :
BindableItem<MemberGroupieItemBinding>() {
override fun getLayout(): Int = R.layout.member_groupie_item
override fun bind(viewBinding: MemberGroupieItemBinding, position: Int) {
viewBinding.division.text = item.divisionName
viewBinding.member.text = item.memberName
viewBinding.root.setOnClickListener {
onClick()
}
}
override fun initializeViewBinding(view: View): MemberGroupieItemBinding {
return MemberGroupieItemBinding.bind(view)
}
override fun isSameAs(other: Item<*>): Boolean {
return if (other is MemberGroupieItem) {
item.id == other.item.id
} else {
false
}
}
override fun hasSameContentAs(other: Item<*>): Boolean {
return if (other is MemberGroupieItem) {
item == other.item
} else {
false
}
}
}
これだけで、どこでSQLiteデータベースが更新されてもリスト表示は常に最新のものを表示するようになります。
謝辞
当初この記事を書いたときは、LiveDataで更新監視したときの単体テストをうまく書けなかったのですが、コメント欄でyama_6さんにやり方を教えていただきました。ありがとうございます。
まずAndroid公式のarchitecture-components-samplesにあるLiveDataTestUtilクラスを自分のプロジェクトにコピーします。それをを使ってLiveDataの値を取得します。
class MainViewModelLiveDataTest {
/**
* LiveDataを書き換えるのに必要
*/
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@MockK(relaxed = true)
lateinit var localDataStore: MemberLocalDataStore
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun test() {
// LiveDataを返却するモック実装を作る
every {
localDataStore.listMembersLiveData()
} returns MutableLiveData(
listOf(
MemberWithDivision(
Member(1L, "name1", 2L),
Division(2L, "Sales")
),
MemberWithDivision(
Member(3L, "name2", 4L),
Division(4L, "Development")
)
)
)
// テスト対象を作る
val viewModel = MainViewModelLiveData(localDataStore)
// LiveDataの値を取得する
val items = LiveDataTestUtil.getValue(viewModel.items)
// 値を確認する
items[0].id shouldBe 1L
items[0].memberName shouldBe "name1"
items[0].divisionName shouldBe "Sales"
items[1].id shouldBe 3L
items[1].memberName shouldBe "name2"
items[1].divisionName shouldBe "Development"
}
}
別の方法 jraska/livedata-testing ライブラリを使って、LiveDataの値を確認する。
DroidKaigi/conference-app-2020でも使用されているjraska/livedata-testingライブラリを使用しても、LiveDataの値を取得することが出来ます。
dependencies {
// 追加
testImplementation 'com.jraska.livedata:testing-ktx:1.1.2'
}
LiveData
に対して test()
拡張関数が実装されていて、そこから TestObserver
クラスのインスタンスを取得できます。そこにある awaitValue()
メソッドで値が決まるまで待つことが出来ます。その後 value()
メソッドで値を取得できます。
// テスト対象を作る
val viewModel = MainViewModelLiveData(localDataStore)
// LiveDataの値を取得する
val items = viewModel.items.test().awaitValue().value()
// 値を確認する
items[0].id shouldBe 1L
items[0].memberName shouldBe "name1"
items[0].divisionName shouldBe "Sales"
items[1].id shouldBe 3L
items[1].memberName shouldBe "name2"
items[1].divisionName shouldBe "Development"
評価
- 記述のシンプルさ ○
- 単体テストの書きやすさ ○
LiveDataになっていれば画面の破棄に合わせて自動で監視を解除するので簡単です。
単体テストも公式サンプル内にあるLiveDataTestUtilクラスやjraska/livedata-testingライブラリを使うことで書くことが出来ました。
RxJavaのFlowableで更新監視する
ViewModelはこうなります。画面が閉じてもずっと監視し続けること無いように作ります。
class MainViewModel(private val localDataStore: MemberLocalDataStore) : ViewModel() {
val items = MutableLiveData<List<MemberListItem>>()
var disposable: Disposable? = null
/**
* ActivityのonCreateから呼ばれる
*/
fun onCreate() {
if (disposable == null) {
val flowable = localDataStore.listMembersRxFlowable()
disposable = flowable.subscribe {
items.value = it.map { src ->
MemberListItem(
src.member.id,
src.member.name,
src.division.name
)
}
}
}
}
/**
* 画面が閉じたときに自動で呼ばれる
*/
override fun onCleared() {
// 監視解除する
disposable?.dispose()
}
}
@Test
fun onCreate() {
every {
localDataStore.listMembersRxFlowable()
} returns Flowable.just(
listOf(
MemberWithDivision(
Member(1L, "name1", 2L),
Division(2L, "Sales")
),
MemberWithDivision(
Member(3L, "name2", 4L),
Division(4L, "Development")
)
)
)
viewModel.onCreate()
// リストが更新された
viewModel.items.value?.get(0)?.id shouldBe 1L
viewModel.items.value?.get(0)?.memberName shouldBe "name1"
viewModel.items.value?.get(0)?.divisionName shouldBe "Sales"
viewModel.items.value?.get(1)?.id shouldBe 3L
viewModel.items.value?.get(1)?.memberName shouldBe "name2"
viewModel.items.value?.get(1)?.divisionName shouldBe "Development"
}
Local Unit Testでは実行スレッドを指定すると失敗するので、それを単体テスト対象のViewModelではなくDataStoreの実装に持たせています。
class MemberLocalDataStoreImpl(private val db: MemberDatabase) : MemberLocalDataStore {
// 略
override fun listMembersRxFlowable(): Flowable<List<MemberWithDivision>> {
return db.memberDao().listMembersRxFlowable().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
評価
- 記述のシンプルさ △
- 単体テストの書きやすさ ○
自分で監視の解除を行わないといけないので監視解除を忘れないように注意が必要です。単体テストは問題なく書けます。
Coroutines Flowで更新監視する
監視の解除を画面の破棄に合わせるために、 lifecycle-viewmodel-ktx
ライブラリが必要です。
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
ViewModelはこのようになります。
class MainViewModel(private val localDataStore: MemberLocalDataStore) : ViewModel() {
val items = MutableLiveData<List<MemberListItem>>()
var firstTime = true
fun onCreate() = viewModelScope.launch(Dispatchers.Main) /* 重要 */ {
if (firstTime) {
// 最初の1回だけ実行する
firstTime = false
val flow = localDataStore.listMembersCoroutineFlow().map { list ->
list.map {
MemberListItem(
it.member.id,
it.member.name,
it.division.name
)
}
}
flow.collect {
items.value = it
}
// ここは実行されない
}
}
}
重要なところはViewModel ScopeでCoroutine Scopeを作ることです。それによってActivitiyの破棄によってViewModelが破棄されると、自動的に監視が解除されます。
単体テストはこのように書けます。Local Unit Testでは実行スレッドをメインスレッドに切り替えると失敗するので、スレッドを切り替えない設定を行います。
class MainViewModelTest {
@ExperimentalCoroutinesApi
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
/**
* テスト対象
*/
private lateinit var viewModel: MainViewModel
@MockK(relaxed = true)
lateinit var localDataStore: MemberLocalDataStore
@ExperimentalCoroutinesApi
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
MockKAnnotations.init(this)
viewModel = MainViewModel(localDataStore)
}
@ExperimentalCoroutinesApi
@After
fun tearDown() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
/**
* 初回起動時の処理
*/
@ExperimentalCoroutinesApi
@InternalCoroutinesApi
@Test
fun onCreate() = runBlockingTest {
coEvery {
localDataStore.listMembersCoroutineFlow()
} returns flow {
emit(
listOf(
MemberWithDivision(
Member(1L, "name1", 2L),
Division(2L, "Sales")
),
MemberWithDivision(
Member(3L, "name2", 4L),
Division(4L, "Development")
)
)
)
}
viewModel.onCreate().join()
// リストが更新された
viewModel.items.value?.get(0)?.id shouldBe 1L
viewModel.items.value?.get(0)?.memberName shouldBe "name1"
viewModel.items.value?.get(0)?.divisionName shouldBe "Sales"
viewModel.items.value?.get(1)?.id shouldBe 3L
viewModel.items.value?.get(1)?.memberName shouldBe "name2"
viewModel.items.value?.get(1)?.divisionName shouldBe "Development"
}
}
評価
- 記述のシンプルさ △
- 単体テストの書きやすさ ○
ViewModelScopeにするべきところを間違えてGlobalScopeにすると、画面が破棄されてもずっと監視し続けるので注意が必要です。
Coroutines Flowで更新監視して、asLiveDataを使ってLiveDataに変換する
Coroutines Flowはlifecycle-viewmodel-ktx
ライブラリのasLiveDataメソッドでLiveDataに変換できます。
ViewModelはこのようになります。
class MainViewModel(private val localDataStore: MemberLocalDataStore) : ViewModel() {
/**
* Coroutines FlowをLiveDataに変換する
*/
val itemsAsLiveData = localDataStore.listMembersCoroutineFlow().map { memberWithDivisions ->
memberWithDivisions.map {
MemberListItem(
it.member.id,
it.member.name,
it.division.name
)
}
}.asLiveData()
単体テストはこのように書きます。Coroutines Flowで更新監視する場合と同様にスレッドを切り替えない設定が必要です。
class MainViewModelAsLiveDataTest {
@ExperimentalCoroutinesApi
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@MockK(relaxed = true)
lateinit var localDataStore: MemberLocalDataStore
@ExperimentalCoroutinesApi
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
MockKAnnotations.init(this)
}
@ExperimentalCoroutinesApi
@After
fun tearDown() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
@Test
fun asLiveData() {
every {
localDataStore.listMembersCoroutineFlow()
} returns flow {
emit(
listOf(
MemberWithDivision(
Member(1L, "name1", 2L),
Division(2L, "Sales")
),
MemberWithDivision(
Member(3L, "name2", 4L),
Division(4L, "Development")
)
)
)
}
// テスト対象を作る
val viewModel = MainViewModel(localDataStore)
// LiveDataから値を取得する
val items = LiveDataTestUtil.getValue(viewModel.itemsAsLiveData)
// 値を確認する
items[0].id shouldBe 1L
items[0].memberName shouldBe "name1"
items[0].divisionName shouldBe "Sales"
items[1].id shouldBe 3L
items[1].memberName shouldBe "name2"
items[1].divisionName shouldBe "Development"
}
}
評価
- 記述のシンプルさ ○
- 単体テストの書きやすさ ○
DaoでLiveDataを返却するケース同様、ViewModelでLiveDataに変換されていれば画面の破棄に合わせて自動で監視を解除するので簡単です。単体テストも同様にLiveDataTestUtilクラスやjraska/livedata-testingライブラリを使って書けます。
RoomのDaoでLiveDataを返却するか、Coroutines Flowを返却するか
さてここでRoomのDaoでLiveDataを返却するか、Coroutines Flowを返却するかを比較してみようと思います。
/**
* どっちがいいの?
*/
@Dao
interface MemberDao {
@Query("SELECT * FROM member ORDER BY member.name")
fun listMembersLiveData(): LiveData<List<MemberWithDivision>>
@Query("SELECT * FROM member ORDER BY member.name")
fun listMembersCoroutineFlow(): Flow<List<MemberWithDivision>>
}
私はCoroutines Flowの方法をオススメします。
理由としては更新監視しか行わない場合は優劣が無いのですが、何らかの理由で1回だけ取得する場合は、takeメソッドを使うことをできるからです。
@ExperimentalCoroutinesApi
fun indexOnce() = viewModelScope.launch {
localDataStore.listMembersCoroutineFlow().take(1).map { list ->
list.map {
MemberListItem(
it.member.id,
it.member.name,
it.division.name
)
}
}.collect {
items.value = it
}
// ここは実行される。
}
まとめ
FlowとasLiveDataを使って更新監視するのが良いと思います。しかし長く続いているプロダクトだと2020年になってもJavaが残っていて、工数の都合上ViewModelをKotlinへ書き換える時間がとれないこともあると思います。そのときはLiveDataも良いと思います。どちらも単体テスト可能です。
補足
説明しなかったこと
この記事では主題の説明をシンプルにするために実プロダクトにおいては考慮すべき以下の観点を説明していません。
- 読み込み中はプログレスを表示する
- 編集画面から戻ってきた時は、スクロール位置を編集対象のアイテムの位置にする
- 画面回転やプロセスキルからの復帰が行われていても、スクロール位置を保存する
それらは冒頭でも紹介した調査のために作ったアプリで実装していますので、必要に応じてご参照ください。
監視を解除していることの確認
画面が破棄されたときにForeground Serviceを起動し、そこからSQLiteデータベースへの更新を行ったときに、コールバックが呼ばれないことを確認しています。
override fun onDestroy() {
super.onDestroy()
if (isFinishing) {
val intent = Intent(this, DisposeTestService::class.java)
startService(intent)
}
}
class DisposeTestService : Service() {
companion object {
const val ONGOING_NOTIFICATION_ID = 1
}
private val dataStore: MemberLocalDataStore by inject()
override fun onStart(intent: Intent?, startId: Int) {
setUpForegroundService()
// 2秒ごとに10回データベースを更新する
val random = Random()
GlobalScope.launch(Dispatchers.Main) {
repeat(10) {
delay(2000)
val number = random.nextInt(1000)
dataStore.update(Member(2, "W%03d".format(number), 2))
}
stopForeground(true)
}
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
/**
* Foreground Serviceの設定
*/
private fun setUpForegroundService() {
// 通知チャンネルID
val id = getString(R.string.channel_id)
// 通知チャンネルを作る
val notificationManager =
getSystemService(AppCompatActivity.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getString(R.string.channel_name)
val notificationChannel = notificationManager.getNotificationChannel(id)
// 無ければ作る
if (notificationChannel == null) {
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
}
// PendingIntentを作る
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0)
val notification = NotificationCompat.Builder(this, id)
.setSmallIcon(R.drawable.ic_edit_gray_16dp)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_message))
.setContentIntent(pendingIntent)
.build()
startForeground(ONGOING_NOTIFICATION_ID, notification)
}
}
class MainViewModel(private val localDataStore: MemberLocalDataStore) : ViewModel() {
val items = MutableLiveData<List<MemberListItem>>()
fun onCreate() = viewModelScope.launch(Dispatchers.Main) {
val flow = localDataStore.listMembersCoroutineFlow()
flow.collect {
items.value = it.map { src ->
MemberListItem(
src.member.id,
src.member.name,
src.division.name
)
}
// 画面を閉じた後にこのデバッグメッセージが発生しなければ、適切に監視が解除されている。
Log.d("ObserveRoom", "flow.collect")
}
}
}