Reputation: 60341
I try to understand SideEffect of Jetpack Compose.
Other than the official document, I find 3 other reference
I'm am still confused. My simple question as below
What's the difference if I do this with SideEffect
var i = 0
@Composable
fun MyComposable(){
Button(onClick = {}){
Text(text = "Click")
}
SideEffect { i++ }
}
and without SideEffect
var i = 0
@Composable
fun MyComposable(){
Button(onClick = {}){
Text(text = "Click")
}
i++
}
Code example from https://www.section.io/engineering-education/side-effects-and-effects-handling-in-jetpack-compose/
Is there a way the i++
is still triggered in one case but not the other?
How can I create a way to experiment with that?
Upvotes: 8
Views: 1258
Reputation: 81
To simply put, a SideEffect will make sure whatever is in the block will be triggered after all remember
components' onRemembered()
are called. This will make sure the block inside a SideEffect will get the most updated values.
Let's take a look at some examples. Here's a simple example that simply shows a Text :
@Composable
fun Counter(modifier: Modifier = Modifier) {
var counter by remember { mutableIntStateOf(1) }
Text(text = "Counter = $counter")
Log.d("TAG", "BEGIN : Counter = $counter")
counter++
Log.d("TAG", "END : Counter = $counter")
}
If we run this code, we will get the following output :
BEGIN : counter = 1
END : counter = 2
However, this will not trigger recomposition. So you can tell that when you do modification like this, it just act like any function, they will be called sequentially.
Now, if we place counter++
into a SideEffect :
@Composable
fun Counter(modifier: Modifier = Modifier) {
var counter by remember { mutableIntStateOf(1) }
Text(text = "Counter = $counter")
Log.d("TAG", "BEGIN : Counter = $counter")
SideEffect { counter++ }
Log.d("TAG", "END : Counter = $counter")
}
We will get a different result :
BEGIN : counter = 1
END : counter = 1
What's more important is that this will trigger recomposition. If we didn't add a limit to counter
inside SideEffect, you will see the number keep on increasing until it reaches Int.MAX_VALUE
and crash.
If we wrap the second Log into a SideEffect, then we would get the same result as the first try :
BEGIN : counter = 1
END : counter = 2
So you can see the different between calling in and outside of a SideEffect.
Now, let's dive into the source code to get a better understanding.
A SideEffect is similar to remember
, however it will be stored as a Operation.SideEffect and not Operation.Remember :
// Operation.kt
object SideEffect : Operation(objects = 1) {
inline val Effect get() = ObjectParameter<() -> Unit>(0)
override fun objectParamName(parameter: ObjectParameter<*>) = when (parameter) {
Effect -> "effect"
else -> super.objectParamName(parameter)
}
override fun OperationArgContainer.execute(
applier: Applier<*>,
slots: SlotWriter,
rememberManager: RememberManager
) {
rememberManager.sideEffect(getObject(Effect))
}
}
Upon calling SideEffect {}
, a Operation.SideEffect will be stored inside a sideEffects: MutableListOf<() -> Unit>()
in RememberEventDispatcher :
// Composition.kt
private class RememberEventDispatcher(
private val abandoning: MutableSet<RememberObserver>
) : RememberManager {
// ...
override fun sideEffect(effect: () -> Unit) {
sideEffects += effect
}
// ...
}
The SideEffect operator will be triggered in CompositionImpl.applyChangesInLocked(ChangeList)
after dispatching all remember observers :
manager.dispatchRememberObservers()
manager.dispatchSideEffects()
If you were to do any modifications outside of SideEffect, it will happen before applyChangesInLocked
is called.
Next, let's take a look at the differences between calling counter++
outside and inside a SideEffect.
If we tried to modify counter
outside a SideEffect, we will find candidate.snapshotId == id
is true
in the following code, which will avoid snapshot.recordModified(state)
being triggered. This will also avoid recomposition to occur due to the change in counter
:
internal fun <T : StateRecord> T.overwritableRecord(
state: StateObject,
snapshot: Snapshot,
candidate: T
): T {
if (snapshot.readOnly) {
// If the snapshot is read-only, use the snapshot recordModified to report it.
snapshot.recordModified(state)
}
val id = snapshot.id
if (candidate.snapshotId == id) return candidate
val newData = sync { newOverwritableRecordLocked(state) }
newData.snapshotId = id
snapshot.recordModified(state)
return newData
}
However, if we modify it inside a SideEffect, then candidate.snapshotId == id
will return false. This will record the modification, which at the end will trigger recomposition to occur.
As for why candidate.snapshotId == id
have different result, all I can say is that's how internal takes care of it.
Upvotes: 1
Reputation: 60341
The SideEffect
function is a scope of code triggered outside of the Compose Function. I found a way to differentiate them.
If I run it as below
@Composable
fun TrySideEffect() {
var timer by remember { mutableStateOf(0) }
Box(contentAlignment = Alignment.Center) {
Text("Time $timer")
}
Thread.sleep(1000)
timer++
}
The above code will only show 0
. The timer++
has no impact, as it was changed while it is being composed, given it's part of the composable function.
However, if we use SideEffect
as shown below, given it is not part of the compose function, the timer++
will trigger this, and this will make the composable function recompose again and again (Given SideEffect
is being called on each Composable). This will make the Text show 0, 1, 2, 3, 4...
@Composable
fun TrySideEffect() {
var timer by remember { mutableStateOf(0) }
Box(contentAlignment = Alignment.Center) {
Text("Time $timer")
}
SideEffect {
Thread.sleep(1000)
timer++
}
}
Additional Info
To make it a little interesting, if I put on the below code, then the text will display 0, 2, 4, 6 ... (given the first ++timer
will happen without composing, and the ++timer
that happen in the SideEffect
will trigger it)
@Composable
fun TrySideEffect() {
var timer by remember { mutableStateOf(0) }
Box(contentAlignment = Alignment.Center) {
Text("Time $timer")
}
SideEffect {
Thread.sleep(1000)
timer++
}
Thread.sleep(1000)
timer++
}
Another interesting note, comparing SideEffect
with LaunchEffect
If we use LaunchEffect
, the number will only increment once, i.e. from 0 to 1. This is because unlike SideEffect
, LaunchEffect
only triggered on the first recomposition, and not change on the subsequent recomposition (unless we change the key1
value, so it will be triggered upon change of key1
value.).
@Composable
fun TrySideEffect() {
var timer by remember { mutableStateOf(0) }
Box(contentAlignment = Alignment.Center) {
Text("Time $timer")
}
LaunchEffect(key1 = Unit) {
delay(1000) // or Thread.sleep(1000)
timer++
}
}
Upvotes: 7