androidkotlin-coroutineskotlin-flowstateflow

List of Stateflows not detecting changes


I am using the MVVM model and have come across a problem with Stateflow that I cannot find a solution. Perhaps I am approaching the problem in the wrong way.

Each of my Models responds to a specific input from various network inputs. I use Stateflows to pass the changes along to the Viewmodel, which keeps the list of Models and in turn passes the info to the View which displays the changes.

[note: the following is greatly simplified code]

Model

I have a model that uses Stateflow to pass changes to any observer. I'm emulating network updates with coroutines that simply increment an Int every second.
/** Model that holds an Int and increments every second to simulate network updates */
class MySimpleModel(
    startValue: Int,
    coroutineScope: CoroutineScope
) {
    private val _x = MutableStateFlow(startValue)
    val x = _x.asStateFlow()

    init {
        coroutineScope.launch {
            while (true) {
                delay(1000)
                _x.update { it + 1 }   // increment every second
                Log.d("MySimpleModel", "x -> ${x.value}")
            }
        }
    }
}

Viewmodel

This Viewmodel holds a list of Models. The number of Models is dynamic and can increase or decrease at any time. This is emulated by starting with no Models, waiting two seconds, and then adding two Models. Additional Models can be added by calling add().

And this seems be the rub. The data of the models do not update every second (even though the Log statement in MySimpleModel indicates that it has updated).

However, when a new model is added, the data does update. But just that one time; the second-by-second incrementation is ignored.

class MySimpleViewmodel : ViewModel() {

    private val _modelList = MutableStateFlow<List<MySimpleModel>>(listOf())
    val modelList = _modelList.asStateFlow()


    init {
        // make a list after 2 seconds
        viewModelScope.launch {
            delay(2000)
            _modelList.update {
                listOf(
                    MySimpleModel(3, viewModelScope),
                    MySimpleModel(78, viewModelScope)
                )
            }
        }
    }

    fun add(dataToAdd: MySimpleModel) {
        _modelList.update {
            it + dataToAdd
        }
    }
}

View

And for completeness, here is the view (also greatly simplified).
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {

            val viewmodel = MySimpleViewmodel()
            val modelList = viewmodel.modelList.collectAsStateWithLifecycle()

            FlowTest4listOfFlowsTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    SimpleMainScreen(
                        modifier = Modifier.padding(innerPadding),
                        viewmodel = viewmodel,
                        modelList.value
                    )
                }
            }
        }
    }
}


@Composable
private fun SimpleMainScreen(
    modifier: Modifier,
    viewmodel: MySimpleViewmodel,
    modelList: List<MySimpleModel>
) {
    Column(
        modifier = modifier
    ) {
        // buttons
        Row {
            Button(
                onClick = { viewmodel.add(MySimpleModel(6, viewmodel.viewModelScope)) }
            ) { Text("add") }
        }

        // the data
        modelList.forEach { model ->
            Text(text = "${model.x.value}")
        }
    }
}

Any help about what I'm doing wrong will be greatly appreciated.


Solution

  • You never converted the MySimpleModel.x flows into State objects, so you only get what their value property contains when you happen to access it. Instead, you want to observe the flows for changes.

    Before applying collectAsStateWithLifecycle(), please be aware that models should only be simple data structures (usually data classes). Your MySimpleModel seems to be more like a data source, since it generates values on its own.

    But data sources should never be accessed directly by composables. Instead, their values should be transformed by the view model into UI state. The view model's modelList should therefore be of type StateFlow<List<Int>>.

    To collect the flows from the list of MySimpleModels into a single flow you can do the following:

    val modelList: StateFlow<List<Int>> = _modelList
        .flatMapLatest { models ->
            combine(
                flows = models.map(MySimpleModel::x),
                transform = Array<Int>::toList,
            )
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5.seconds),
            initialValue = emptyList(),
        )
    

    flatMapLatest switches the _modelList flow for another flow. That flow is created by combineing all MySimpleModels' flows into a single flow, effectively transforming a List<Flow<Int>> into a Flow<List<Int>>.

    The resulting flow isn't a StateFlow anymore, so you need to make it one by calling stateIn().

    That's it, you just need to change SimpleMainScreen so the parameter modelList is of type List<Int> now and everything should work as expected.


    But while we're at it:

    You still use MySimpleModel in your composable to create a new object. That should also be encapsulated by the view model. Your composable should only pass the startValue:

    fun add(startValue: Int) {
        _modelList.update {
            it + MySimpleModel(startValue, viewModelScope)
        }
    }
    

    Also, for the same reason that your composables shouldn't directly work with data sources like your (mis-named) MySimpleModel, they should also not pass complex objects around like view models. Instead, change SimpleMainScreen to this:

    @Composable
    fun SimpleMainScreen(
        modifier: Modifier,
        add: (Int) -> Unit,
        modelList: List<Int>,
    ) {
        // ...
    
        Button(
            onClick = { add(6) }
        ) { Text("add") }
    
        // ...
    }
    

    You only needed the view model to call the add function. Instead of passing the entire view model, you just need to pass that function now:

    SimpleMainScreen(
        modifier = Modifier.padding(innerPadding),
        add = viewmodel::add,
        modelList = modelList.value,
    )