https://kotlinlang.org logo
#compose
Title
# compose
m

Mehdi Haghgoo

02/17/2021, 5:08 PM
I think I have made a horrible mistake when trying to read a state from Room-backed ViewModel. Error: java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied. Code follows in thread.
This composable is responsible to show the details of the item pointed to by id.
Copy code
@Composable
fun Detail(id: Int, viewModel: CostViewModel){
    var cost by mutableStateOf(Cost(costName =  "sample",  costValue = 10.0))
    GlobalScope.launch{cost = viewModel.findCost(id)}
    Surface(modifier = Modifier.shadow(4.dp)) {
        Column {
            BasicText(stringResource(id = R.string.prodcuct_details))
            BasicText("Cost: ${cost.costName}")
            BasicText("Value: ${cost.costValue}")
            Image(
                painterResource(id = getRandomImageResource()),
                contentDescription = cost.costName
            )
        }
    }
}
j

jaqxues

02/17/2021, 5:11 PM
Add remember:
remember { mutableStateOf() }
m

Mehdi Haghgoo

02/17/2021, 5:12 PM
Here's the full error:
FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: <http://com.example.app|com.example.app>, PID: 19134
java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
at androidx.compose.runtime.snapshots.SnapshotKt.readError(Snapshot.kt:1688)
at androidx.compose.runtime.snapshots.SnapshotKt.current(Snapshot.kt:1864)
at androidx.compose.runtime.SnapshotMutableStateImpl.setValue(MutableState.kt:324)
at com.example.app.MainActivityKt.Detail$lambda-19(MainActivity.kt:550)
at com.example.app.MainActivityKt.access$Detail$lambda-19(MainActivity.kt:1)
at com.example.app.MainActivityKt$Detail$1.invokeSuspend(MainActivity.kt:324)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
remember does not help. The same error.
j

jaqxues

02/17/2021, 5:14 PM
You should be using it anyway, otherways you create a new mutableState for every single recomposition
m

Mehdi Haghgoo

02/17/2021, 5:14 PM
How do you search an item from viewModel. Do I have to make
findCost()
return LiveData?
j

jaqxues

02/17/2021, 5:17 PM
Wym search an item from viewModel? You can return anything you want. From your current usage, it looks like (suspend)
findCost
just returns a Cost object (which is fine). Although I would not use GlobalScope, but a
rememberCoroutineScope()
or maybe even sth like a
LaunchedEffect()
Besides that, I do not really see immediately what else is wrong with your code
So I suspect that the MutableState.value = xx is not thread safe
Copy code
GlobalScope.launch {
    val tmp = viewModel.findCost(id)
    withContext(Dispatchers.Main) {
        cost = tmp
    }
}
Maybe this will help, but again, not sure
m

Mehdi Haghgoo

02/17/2021, 5:22 PM
This is damn hard 😞
Yes @jaqxues your snippet worked. Why?
j

jaqxues

02/17/2021, 5:26 PM
Well if it works, then setting a state is not thread-safe. This means that if you call it on the wrong thread, it will basically not update immediately/correctly on the main thread which handles the UI and hence cause some errors. So the code snippet above first switches to the Main thread, then updates the State. When the UI is needed, the State is updated as expected and does not cause issues like this. I'm quite surprised that it isnt thread-safe though, don't worry, most things aren't this hard:)
also, think of a
Composable
function as sth that will be executed over and over again (not under your control). When not using remember, you create a new mutableState every single time the composable is refreshed. Similarly,
GlobalScope.launch
is terrible since it will launch new coroutines every time it refreshes. You should not assume anything about recomposition. Make your code safe to be executed often, and let compose handle the rest. I would use sth like
Copy code
LaunchedEffect(id) { 
    // your coroutine code
}
☝️ 1
1
m

Mehdi Haghgoo

02/17/2021, 5:31 PM
Thank you @jaqxues. So, LaunchedEffect is a Compose-friendly coroutine?
a

Adam Powell

02/17/2021, 5:32 PM
It's a composition-controlled coroutine launch, yes. You should never use
CoroutineScope.launch
from the body of a composable function. (Note that this is distinct from using
launch
in an event handler created in a composable function.)
👍 1
a

Adam Powell

02/17/2021, 5:33 PM
As for the thread-safety aspects of snapshot state, what you've encountered is the result of snapshots being transactional.
👍 1
When a snapshot is taken (and composition does this for you under the hood) the currently active snapshot is thread-local. Everything that happens in composition is part of this transaction, and that transaction hasn't committed yet.
So when you create a new
mutableStateOf
in composition and then pass it to another thread, as the
GlobalScope.launch
in the problem snippet does, you've essentially let a reference to snapshot state that doesn't exist yet escape from the transaction.
This doesn't happen for snapshot state created as part of the "global transaction" - if you're not in an explicitly created snapshot, you're in the global transaction and any reads or writes are valid; they just aren't consistent with any particular snapshot. From this state they behave just like any other object read/write.
The reason
LaunchedEffect
doesn't experience any issues around any of this is because
LaunchedEffect
doesn't actually launch a coroutine until after the composition has successfully completed and applied - after the composition snapshot commits successfully.
(At a high level, we should probably have a lint warning around `CoroutineScope.launch`/`async` called from a composable function that warns that you really want to think about the problem in a different way, ideally linking to some documentation.)
👍 4
Again though, this is just fine:
Copy code
val scope = rememberCoroutineScope()

Button(onClick = { scope.launch { doSuspendingThings() } }) {
because the launch is part of the click handler, not part of the composable function.
m

Mehdi Haghgoo

02/17/2021, 5:59 PM
So, what is the best way to use the result of a coroutine in a composable? Tried moving the UI code inside the coroutine, but it does not work!
Even when I access suspending Room functionality inside coroutine, it throws error that I "Cannot access database on the main thread since it may potentially lock the UI for a long period of time."
a

Adam Powell

02/17/2021, 7:26 PM
Copy code
var cost by remember { mutableStateOf(Cost(costName =  "sample",  costValue = 10.0)) }
LaunchedEffect(id) {
    cost = viewModel.findCost(id)
}
LaunchedEffect
runs on the composition's effect context, i.e. the UI thread. You can use
withContext
to switch dispatchers as usual.
1
m

Mehdi Haghgoo

02/17/2021, 9:11 PM
Finally solved it this way:
Copy code
val cost by viewModel.findCost(id).collectAsState(Cost(costName = "default", costValue = 1.0))
Surface(modifier = Modifier.shadow(4.dp)) {
    Column {
        BasicText(stringResource(id = R.string.prodcuct_details))
        BasicText("Cost: ${cost.costName}")
        BasicText("Value: ${cost.costValue}")
        Image(
            painterResource(id = getRandomImageResource()),
            contentDescription = cost.costName
        )
    }
}
a

Adam Powell

02/17/2021, 10:24 PM
If
findCost(id)
doesn't return the same flow object instance every time for the same id you'll likely want to spell that as
Copy code
val cost by remember(viewModel, id) {
  viewModel.findCost(id)
}.collectAsState(...)
Without the remember or some other assurance that same id+viewmodel = same flow instance,
collectAsState
will cancel the old collect and start a new one every time that recomposes
❤️ 1
31 Views