Kotlin Flow returned from Room does not update when an insert is performed from another Fragment/ViewModel
Asked Answered
N

7

24

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.

Narceine answered 16/11, 2020 at 21:33 Comment(7)
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.Marleenmarlen
@Narceine did u find any solution yet?Diandre
I'm afraid I wasn't able to find a solution using Kotlin Flow, I ended up just using LiveData instead.Narceine
Make sure that the suspend function in your room database return flow , for example : Flow<List<Data>>Retrospect
hi there, did you find the solution?Gibun
@AlekseyKhokhrin Unfortunately notNarceine
Try observing in an IO scopeStaphylorrhaphy
S
19

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

Subchaser answered 26/4, 2021 at 16:1 Comment(1)
That won't work I'm afraid, the database was already a SingletonNarceine
L
2

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.

Longer answered 8/3, 2023 at 16:32 Comment(0)
C
1

It seems that you need @Volatile for the database instance, otherwise it is cached.

Centric answered 11/2, 2024 at 15:16 Comment(0)
I
0

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

Inexpiable answered 7/3, 2022 at 15:52 Comment(0)
S
0

You need to observe it on the IO thread

viewModelScope.launch(Dispatchers.IO) {
    achievementRepository.getAllAchievements()
                .collect { values ->
                    _count.postValue(values.count())
                }
        }
Staphylorrhaphy answered 22/4, 2024 at 17:55 Comment(0)
A
-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"))
        }
    }
}
Attune answered 21/4, 2022 at 14:9 Comment(0)
M
-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 {}

Mok answered 18/11, 2020 at 6:4 Comment(6)
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<Int?>(null) val count = achievementRepository.allAchievements.map { it.count() }.asLiveData() } ` ` class AchievementRepository ... { val allAchievements = achievementDao.getAllAchievements() } ` (1/3)Narceine
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)Narceine
Even when returning LiveData from the DAO query and eliminating Flow all together, the same problem occurs. (3/3)Narceine
"If the Lifecycle object is not in an active state, then the observer isn't called even if the value changes." -- 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.Mok
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 updatedNarceine
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.Mok

© 2022 - 2025 — McMap. All rights reserved.