21

I have a Room database that returns a Flow of objects. When I insert a new item into the database, the Flow's collect function only triggers if the insert was performed from the same Fragment/ViewModel.

I have recorded a quick video showcasing the issue: https://www.youtube.com/watch?v=7HJkJ7M1WLg

Here is my code setup for the relevant files:

AchievementDao.kt:

@Dao
interface AchievementDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(achievement: Achievement)

    @Query("SELECT * FROM achievement")
    fun getAllAchievements(): Flow<List<Achievement>>
}

AppDB.kt:

@Database(entities = [Achievement::class], version = 1, exportSchema = false)
abstract class AppDB : RoomDatabase() {

    abstract fun achievementDao(): AchievementDao
}

AchievementRepository.kt:

class AchievementRepository @Inject constructor(appDB: AppDB) {

    private val achievementDao = appDB.achievementDao()

    suspend fun insert(achievement: Achievement) {
        withContext(Dispatchers.IO) {
            achievementDao.insert(achievement)
        }
    }

    fun getAllAchievements() = achievementDao.getAllAchievements()
}

HomeFragment.kt:

@AndroidEntryPoint
class HomeFragment : Fragment() {

    private val viewModel: HomeViewModel by viewModels()

    private lateinit var homeText: TextView

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_home, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        bindViews()
        subscribeObservers()
    }

    private fun bindViews() {
        homeText = requireView().findViewById(R.id.txt_home)
        requireView().findViewById<ExtendedFloatingActionButton>(R.id.fab_add_achievement).setOnClickListener {
            AddAchievementBottomSheet().show(parentFragmentManager, "AddAchievementDialog")
        }
        requireView().findViewById<ExtendedFloatingActionButton>(R.id.fab_add_achievement_same_fragment).setOnClickListener {
            viewModel.add()
        }
    }

    private fun subscribeObservers() {
        viewModel.count.observe(viewLifecycleOwner, { count ->
            if(count != null) {
                homeText.text = count.toString()
            } else {
                homeText.text = resources.getString(R.string.app_name)
            }
        })
    }
}

HomeViewModel.kt:

class HomeViewModel @ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
        ViewModel() {

    private val _count = MutableLiveData<Int>(null)
    val count = _count as LiveData<Int>

    init {
        viewModelScope.launch {
            achievementRepository.getAllAchievements()
                .collect { values ->
                    // FIXME this is only called when inserting from the same Fragment
                    _count.postValue(values.count())
                }
        }
    }

    fun add() {
        viewModelScope.launch {
            achievementRepository.insert(Achievement(0, 0, "Test"))
        }
    }
}

AddAchievementBottomSheet.kt:

@AndroidEntryPoint
class AddAchievementBottomSheet : BottomSheetDialogFragment() {

    private val viewModel: AddAchievementViewModel by viewModels()
    private lateinit var addButton: MaterialButton

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.dialog_add_achievement, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        addButton = requireView().findViewById(R.id.btn_add_achievement)
        addButton.setOnClickListener {
            viewModel.add(::close)
        }
    }

    private fun close() {
        dismiss()
    }
}

AddAchievementBottomSheetViewModel.kt:

class AddAchievementViewModel @ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
        ViewModel() {

    fun add(closeCallback: () -> Any) {
        viewModelScope.launch {
            achievementRepository.insert(Achievement(0, 0, "Test"))
            closeCallback()
        }
    }
}

build.gradle (app):

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
}

android {
    compileSdkVersion 30

    defaultConfig {
        applicationId "com.marcdonald.achievementtracker"
        minSdkVersion 23
        targetSdkVersion 30
        versionCode 1
        versionName "1.0.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    // Kotlin
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.0'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.0'
    implementation 'androidx.core:core-ktx:1.3.2'

    // Android
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation "androidx.activity:activity-ktx:1.1.0"
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

    // Navigation
    implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1'
    implementation 'androidx.navigation:navigation-ui-ktx:2.3.1'

    // Testing
    testImplementation 'junit:junit:4.13.1'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

    // Dagger Hilt
    implementation 'com.google.dagger:hilt-android:2.29.1-alpha'
    implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
    kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
    kapt 'com.google.dagger:hilt-android-compiler:2.29.1-alpha'

    // Timber for logging
    implementation 'com.jakewharton.timber:timber:4.7.1'

    // Room
    implementation 'androidx.room:room-runtime:2.2.5'
    implementation 'androidx.room:room-ktx:2.2.5'
    kapt 'androidx.room:room-compiler:2.2.5'
    androidTestImplementation 'androidx.room:room-testing:2.2.5'
}

build.gradle (project):

buildscript {
    ext.kotlin_version = "1.4.10"
    repositories {
        google()
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:4.2.0-alpha16'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath 'android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0'
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.29.1-alpha'
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        maven { url 'https://jitpack.io' }
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

I'm not sure if my understanding of Kotlin Flow is to blame or whether my setup is incorrect in some way, but I'd appreciate some help with the issue.

Marc
  • 310
  • 1
  • 3
  • 6
  • Your call will only execute once you initialize the HomeViewModel. If you want to be notified about any database update for the respective entity, you'd need to call the collect function again. One solution would be to retrieve a LiveData from room directly and observe to that one. That way you'd receive all changes that happen to that database entity or rather to that table. – J. Hegg Nov 16 '20 at 21:45
  • @Marc did u find any solution yet? – anshul Apr 08 '21 at 14:09
  • I'm afraid I wasn't able to find a solution using Kotlin Flow, I ended up just using LiveData instead. – Marc Apr 08 '21 at 19:22
  • Make sure that the suspend function in your room database return flow , for example : Flow> – Taki Oct 14 '21 at 22:39
  • hi there, did you find the solution? – Aleksey Khokhrin Feb 22 '22 at 13:28
  • @AlekseyKhokhrin Unfortunately not – Marc Feb 22 '22 at 18:41
  • I think that this [article](https://medium.com/firebase-tips-tricks/how-to-read-data-from-room-using-kotlin-flow-in-jetpack-compose-7a720dec35f50) will help you implement Room with Flow. – Alex Mamo May 18 '22 at 12:26

5 Answers5

12

Make sure you use the same instance of your RoomDatabase. Add a @Singleton where you provide AppDB might do the trick.

Wärting
  • 1,086
  • 1
  • 12
  • 19
1

If you wrap your insert statement inside of withTransaction block it should work fine.

appDB.withTransaction {
    achievementDao.insert(achievement)
}

In case this doesn't work you might be using two database instances, and you should make sure that you are calling enableMultiInstanceInvalidation() where you build the database with Room.databaseBuilder.

I had the same problem and after hours of investigating the conclusion was that this might be a bug in the InvalidationTracker from Android Room causing update notifications to be skipped. There is also similar bug report https://issuetracker.google.com/issues/154040286 which was fixed.

Aleksandar Ilic
  • 1,521
  • 16
  • 19
0

Try calling subscribeObservers() in the onStart() lifecycle.

voxobscuro
  • 2,132
  • 1
  • 21
  • 45
-2

You need to add this dependency:

implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"

Then don't collect the Flow in your ViewModel. Instead map it to your needs and expose it as LiveData like this:

class HomeViewModel @ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
        ViewModel() {

    val count: LiveData<Int> = achievementRepository
          .getAllAchievements()
          .map {it.size}
          .asLiveData()
   
    fun add() {
        viewModelScope.launch {
            achievementRepository.insert(Achievement(0, 0, "Test"))
        }
    }
}
StefanTo
  • 971
  • 1
  • 10
  • 28
-3

Flow is a cold stream which means you have to manually call Flow.collect{} to get the data.

To continuously observe changes in the database,

Option 1) convert Flow to Livedata,

val count: LiveData<Int> = achievementRepository.getAllAchivements().map {
    it.count()
}.asLiveData()

Checkout the solution code in Google Codelab, "Android Room with a View - Kotlin"

Option 2) convert Flow to StateFlow which is a hot stream that you can observe on with StateFlow.collect {}

Eric Cen
  • 3,616
  • 1
  • 13
  • 17
  • Hi, thanks for your answer, unfortunately after trying both of those options the problem persists. Currently I have it set up converting the Flow returned from the DAO into a LiveData similar to how you and the Codelab showed. ` class HomeViewModel ... { private val _count = MutableLiveData(null) val count = achievementRepository.allAchievements.map { it.count() }.asLiveData() } ` ` class AchievementRepository ... { val allAchievements = achievementDao.getAllAchievements() } ` (1/3) – Marc Nov 18 '20 at 15:57
  • However entries added from the dialog still do not show up immediately. If I add one from the dialog, and then add from the same fragment immediately after, the number jumps up by 2. E.g: Displays 1 -> Add from same fragment -> Displays 2 -> Add from dialog -> Displays 2 -> Add from same fragment -> Displays 4 (2/3) – Marc Nov 18 '20 at 15:58
  • Even when returning LiveData from the DAO query and eliminating Flow all together, the same problem occurs. (3/3) – Marc Nov 18 '20 at 16:01
  • "If the Lifecycle object is not in an active state, then the observer isn't called even if the value changes." -- https://developer.android.com/topic/libraries/architecture/livedata instead of using .asLiveData(), you can make one-time request by changing the init{} block in HomeViewModel class to a method, getCount(), for example, then call this method in onResume(), HomeFragment. – Eric Cen Nov 18 '20 at 20:38
  • Unfortunately this also doesn't work because the HomeFragment doesn't change state when the dialog is opened, so the onResume() method isn't called whenever the Dialog is closed and the value doesn't get updated – Marc Nov 19 '20 at 13:58
  • you are right about the onResume(); it isn't called after the bottomsheet diloag fragment. I was making the assumption about your response after trying option 1. – Eric Cen Nov 20 '20 at 05:58