One thing I found in the stable version to be care...
# compose
l
One thing I found in the stable version to be careful of is the fact that compose treats two lists with the same values as equal, even if the references aren't the same. I was having my viewModel modify the original list, then set the listState.value equal to ArrayList(oldList). This did not trigger a recompose. Code showing this with a basic array in thread.
Copy code
@Composable
fun TestCompose() {
    val oldList = remember {
        arrayListOf(1,2,3)
    }
    val listState = remember { mutableStateOf(oldList)}

    Column {
        Text(text = listState.value.size.toString())
        Button(onClick = { oldList[1] = 3; listState.value = arrayListOf(1,3,3) }) {
            Text("Modify")
        }
        LazyColumn {
            itemsIndexed(listState.value) { _, value ->
                Text("value: $value")
            }
        }
    }
}
c
Not sure (not an expert) but wouldn't this be a use case for mutableStateListOf()?
l
arrayListOf(1,3,3) would be replaced with ArrayList(oldList) in real code. Just wanted to make it more clear what the value is at the time and show that it's not getting the oldList's reference. It's a new arrayList.
I switched to mutableStateListOf via expect/actual in the end, but I would like to get it working with Flow. I'm working in KMM right now, which doesn't have mutableStateListOf, and iOSMain Kotlin doesn't have access to SwiftUI to make an iOS equivalent since it is interop with Obj-C, not Swift, so Flow would be more ideal in my specific case.
c
I believe this is working exactly as it should.
mutableStateOf
has a configurable
SnapshotMutationPolicy
which defaults to
structuralEqualityPolicy
(checking whether the two lists are
==
). Since you change
oldList
before
listState
, it will see the new value as equal to the old value, and the UI will not update.
Copy code
Button(onClick = { 
    oldList[1] = 3 // oldList is now [1,3,3]. listState is backed by that variable, so internally it is also [1,3,3], but the change could not be detected
    listState.value = arrayListOf(1,3,3) // when you try to update this, the backing list is already [1,3,3], so the update is ignored
}) { ... }
You can use a
referentialEqualityPolicy
instead which should treat it as you expect (recomposing when
!==
)
👍 3
l
That makes sense. I assumed it used referential equality. Thank you.
Is there any way to change the policy for collectAsState() on a Flow? I don't see it as a parameter.
c
From the docs:
Every time there would be new value posted into the Flow the returned State will be updated
I believe
collectAsState
is ulimately just posting to a
mutableStateOf
internally, so it is likey using referential equality. The flow itself would be responsible for conflating (or not) those updates. Note that
StateFlow
does conflate updates with structural equality, but there it would be the Flow, not Compose, preventing the updates
And just generally, Compose encourages you to describe what the UI looks like, not how you got there. If you’re having to think about when the UI is actually recomposing, you’re likely doing something wrong. This is why it’s so important to either use immutable data structures or the Snapshot APIs like
mutableStateOf
,
mutableStateListOf
, etc. to ensure that nothing even could get changed without Compose knowing about it. Assuming you’re using the data correctly, Compose should just work, and you shouldn’t have to think any more about it
☝🏻 1
☝️ 1
l
I'm thinking through the best way to handle this. I have a list of some data type that I get from the DB. The user can modify this data through the UI. Right now, I have compose notify the viewModel of any UI interactions (button press, textfield change, etc), and the viewModel translates those to requests to the repository to change data. The data uses SharedFlow, so in theory, any changes get sent back down to the collectAsState. Does the repository need to instantiate a new list, and apply changes to it?
I agree for Android projects that mutableStateListOf is the best way to handle this, but for KMM apps, mutableStateListOf isn't much of an option for the reasons I mentioned in an earlier message. I would like to be able to use Compose for the Android UI in KMM, without having to sacrifice being able to share the repo and viewModel.
c
Generally, that list coming from the DB should be immutable. Can’t directly change the contents of the list from the VM, or of any item in the list. So when the user makes as update, you persist that change to the DB, and then re-query the list. Compose will figure out the rest, and intelligently recompose only the specific items in the UI that changed value. Both Room and SqlDelight allow you to run a query that returns a
Flow
, and the libraries are internally tracking all changes. So just observe your query as a flow and make updates directly to the database from the VM, the query should emit the new results afterward automatically
l
I did not know about the Flow in SQLDelight. I'll look into this. I still have some reservations about querying from a DB every time I need data because of a bad DB library a few years back that didn't seem to cache in memory. What does the performance hit look like to re-query a DB vs access an ArrayList in SQLDelight?
c
Unless you’re dealing with huge lists, it’s not going to be noticeable. sqlite itself doesn’t have any actual change-detection APIs, so even when SqlDelight and Room offer Flow APIs, they’re just re-running the query for you whenever the library detects a change. And if the list is huge, you’d probably want to use the Android Paging library instead of querying the entire list at once
l
Got it. So the best solution is to use a Flow to observe the SQLDelight query for small lists, and the Paging library on larger lists? Just get rid of the ArrayList cache I've been using completely?
c
Yeah, I’d do away with the in-memory cached list, unless you need to dome some additional computation on it from the VM. But even then, the overall design should be the same, except that the VM is observing the DB, making its updates, and writing to a different Flow that the UI then reads from. The data in the VM shouldn’t be the source of truth for what’s displayed in the UI, the DB should be. So any real changes should be made to the DB, and whatever is on the UI should be reflective of what’s in the DB, not in a cache
This is the general idea for how I like to structure my VMs, loosely based on the MVI design pattern. Even though the VM is technically holding onto the list of records, it’s not really a cache per-se, it’s just holding onto the current state of the UI and all the data that makes it up. It’s not cached for performance or anything, it’s held in memory so that the entire UI state is managed from a single location, and any updates to it are handled and tracked properly, so the UI will always be in sync with the data
Copy code
class MyViewModel(
    val db: AppDatabase
) { 

    class State(
        private val originalList: List<Record> = emptyList(),
        val searchTerm: String = "",
    ) { 
        val displayedList: List<Record> = originalList
            .filter { it.matchesSearchTerm(searchTerm) }
    }
    
    private val _state = MutableStateFlow(State())
    val state: Flow<State> get() = _state
    
    suspend fun initialize() {
        db.observeRecords().collect { updatedResults ->
            _state.value = _state.value.copy(
                originalList = updatedResults
            )
        }
    }
    
    suspend fun searchTermUpdated(searchTerm: String) {
        // this change will cause `state` to re-emit with the current 
        // list, but filtered by the new search term
        _state.value = _state.value.copy(
            searchTerm = searchTerm
        )
    }

    suspend fun deleteRecord(record: Record) {
        // this change will cause `observeRecords` to re-emit, which will 
        // then get filtered by the current search term
        db.delete(record.id)
    }
}
l
That's interesting. I'll have to try to restructure my project to use this. It would be nice if there were a good way to force the DB to replay the flow so I could use flow features to handle the searchString.
c
Oh, you can definitely do that too, for example by keeping a
State
object and
zip
-ing the DB flow with that State flow.
Copy code
class MyViewModel(
    val db: AppDatabase
) { 
    class State(
        val searchTerm: String = "",
    )
    
    private val _state = MutableStateFlow(State())
    
    fun initialize(): Flow<List<Record>> {
        return db.observeRecords()
            .zip(_state) { records, state ->
                records.filter { it.matchesSearchTerm(state.searchTerm) }
            }
    }
    suspend fun searchTermUpdated(searchTerm: String) {
        // this change will cause `state` to re-emit with the current 
        // list, but filtered by the new search term
        _state.value = _state.value.copy(
            searchTerm = searchTerm
        )
    }
    suspend fun deleteRecord(record: Record) {
        // this change will cause `observeRecords` to re-emit, which will 
        // then get filtered by the current search term
        db.delete(record.id)
    }
}
Personally, I would’t recommend the second approach as the first is more easily generalizable to a variety of use-cases. Once you need to observe multiple reactive sources (multiple DB queries, or listening to changes from a websocket, using a hierarchy of VMs where the parent State flows into the child, etc), it becomes harder to manage all that. But if all those reactive sources ultimately just dump their stuff into a single
State
class which is all the UI sees, it’s easy to reason about any given screen in the same way and adapt it to those different situations
e
IIRC SqlDelight has a paging integration
l
It looks like I need to learn a lot more about Flows. It looks like a really great feature.
👆 2
c
I just realized I used the wrong operator in the above snippet.
zip
combines pairs of elements from two flows at the same index.
.combine
is actually what it should be https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html