0

I have an Android ViewModel that manages a recyclerView with ImageView in each row where I'm setting a userId string in a StateFlow<String?>. I want to use that userId to retrieve the user's description as another StateFlow. I'm doing this:

class MyViewModel : ViewModel { 

    private val _myItems = MutableStateFlow<MyItem>(emptyList())
    val myItems: StateFlow<List<MyItems>
        get() = _myItems

    private val _userId = MutableStateFlow<String?>(null)

    suspend fun getMyItems() {
        viewModelScope.launch(Dispatchers.IO) {
            getMyItemsUseCase().collect {
                _myItemsStateFlow.value = it
            }
        }
    }

    fun setUserId(userId: String) {
        _userId.value = userId
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    val userDescription: StateFlow<String?> = _userId.flatMapLatest { userId ->
        userId?.let { id -> getUserDescriptionUseCase(id)} ?: emptyFlow()
    }.stateIn(viewModelScope, SharingStarted.Lazily, null)
}

In my recyclerView adapter, I'm doing this:


viewHolder.descriptionTextView
    .findViewTreeLifecycleOwner()?
    .lifecycleScope?.launch {
        viewModel.userDescription.collect { d -> 
            viewHolder.descriptionTextView.setText(d)
        }
    }
}
viewModel.setUserId(userId)

However, the problem I'm encountering is that each row is showing the same description, even though each row is a different user with a different userId.

Obviously I'm doing something wrong with the Flows... but I don't know what. Is it because the Flow is being shared by the different rows in the recyclerView?

*** UPDATE: Adding more code as requested in comments ***

data class MyItem(val userId:String, ... other params not shown)


class MyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launch {
            viewModel.getMyItems().collect { myItems ->
                (recyclerView.adapter as MyRecyclerAdapter).setData(myItems)
            }
        }
    }

}


class MyRecyclerAdapter(private val viewModel: MyViewModel) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    internal inner class ViewHolder(val binding: MyLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
        val userIdTextView = binding.userIdTextView
        val descriptionTextView = binding.descriptionTextView
    }

    override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
        data.get(posiiton).let { 
            val userId = it.userId
            viewModel.setUserId(userId)
            viewHolder.userIdTextView.text = userId
            viewHolder.descriptionTextView
                .findViewTreeLifecycleOwner()?
                .lifecycleScope?.launch {
                    viewModel.userDescription.collect { d -> 
                        viewHolder.descriptionTextView.setText(d)
               }
            }
            viewModel.setUserId(userId)
        }
     }
   
    fun setData(data: List<MyItem>) {
        this.data = data
        notifyDataSetChanged()
    }
}
VIN
  • 6,385
  • 7
  • 38
  • 77
  • Your ViewModel has only one flow of IDs, so they are all sharing the same flow. You didn’t show where your list of ids is coming from, but presumably you should be mapping that data to the user descriptions before passing the mapped list to the UI. – Tenfour04 Aug 26 '23 at 12:10
  • Or you could map them to lazy Deferreds if you like and start fetching them only when they appear on screen (in `onBindViewHolder()`) to avoid waiting for the whole list before showing any of it. – Tenfour04 Aug 26 '23 at 12:15
  • Can you show how you'd fetch and set the text in onBindViewHolder? I thought that was what I was doing – VIN Aug 26 '23 at 19:35
  • Can you add the code that shows how you get the list of items that you pass to the adapter? And what the class is that represents the items? – Tenfour04 Aug 26 '23 at 19:52
  • @Tenfour04 Added some more code as requested – VIN Aug 27 '23 at 04:05
  • Is this data backed by a database? Are you using Room? If it's from a database, really this should be done back in the DAO query synchronously. The repo should simply return the and Item class that includes the description. – Tenfour04 Aug 27 '23 at 19:38

1 Answers1

0

First I want to correct this code because it doesn't make sense, and I'm going to use the corrected version in my example(s) below.

suspend fun getMyItems() {
    viewModelScope.launch(Dispatchers.IO) {
        getMyItemsUseCase().collect {
            _myItemsStateFlow.value = it
        }
    }
}
  1. This code shouldn't be marked suspend because it doesn't do any suspending work...it starts an asynchronous coroutine.
  2. This launched coroutine shouldn't change the dispatcher to IO because it does nothing but pass a value to a MutableStateFlow. MutableStateFlow is threadsafe, and setting its value is trivial so it doesn't matter what dispatcher you do it with.
  3. It's poor design to make the UI layer have to remember to call this function to kick things off. Just make the flow using stateIn instead of MutableStateFlow, and you can eliminate this function entirely:
val myItems = getMyItemsUseCase()
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())

The problem in your behavior is because you have a single MutableStateFlow instance that you're reusing for the user ID of every row and a single StateFlow that you're reusing for the description of every row. So every row is passing in its ID, and only the latest one will be used for all of them. Two different ways I can think of to solve it:

1. Include the description in your initial database query

This is probably the correct solution, assuming you are using a database. You should do a database query that does a left join to return it all at once. (A one-to-one relationship in Room.) Your MyItem class or equivalent would have the description as a property so you wouldn't have to be collecting a separate flow per item.

2. If you really can't do that, then you can collect Flows for these, but they shouldn't be StateFlows and you need to cancel them when you start collecting new ones.

In the ViewModel, get rid of the userId and userDescription flows and related functions, and replace them with just a pass-through function:

fun getUserDescriptionForIdFlow(id: String) = getUserDescriptionUseCase(id)
class MyRecyclerAdapter(private val viewModel: MyViewModel) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    internal inner class ViewHolder(val binding: MyLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
        val scope = binding.root.findViewTreeLifecycleOwner()?
            .lifecycleScope ?: error("View must have lifecycle owner.")
        var descriptionCollectJob: Job? = null
        val userIdTextView = binding.userIdTextView
        val descriptionTextView = binding.descriptionTextView
    }

    override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
        val myItem = data[position]
        with (viewHolder) {
            userIdTextView.text = myItem.userId
            descriptionCollectJob?.cancel() // cancel any previous flow!
            descriptionTextView.text = "" // clear previous text or you'll see a flash of text from a previously scrolled away row
            descriptionCollectJob = viewModel.getUserDescriptionForIdFlow(myItem.userId)
                .onEach { descriptionTextView.text = it }
                .launchIn(scope)
        }
    }
 
    //...
}
Tenfour04
  • 83,111
  • 11
  • 94
  • 154
  • Do you mind explaining the pass through? and why we shouldn't use the stateFlow? – VIN Aug 28 '23 at 15:07
  • 1
    What do you not understand about the pass-through? The Adapter needs to be able to get the flow and doesn't have a direct reference to the use case. StateFlow shouldn't be used because you are not sharing the flow--there is only one collector. You *could* do a more complicated implementation where you do create StateFlows and put them in a map with the item IDs as keys so you can avoid reloading descriptions when something scrolls off the screen and back. But you must also then remove them from the map when the IDs are removed from the list or they'll be leaked. – Tenfour04 Aug 28 '23 at 15:15
  • Also, will canceling the job cancel the flow collection? – VIN Aug 28 '23 at 15:15
  • 1
    Yes, canceling the job cancels collection of the flow. `onEach`/`launchIn` is just a different way of writing `launch { ...collect { } }`. I prefer it because there's less nesting of code so it's easier to read. – Tenfour04 Aug 28 '23 at 15:16