Reputation: 1321
While studying through the Jetpack Compose sample project, I saw @Stable
and @Immutable
annotations. I've been looking through the Android documentation and GitHub about those annotations, but I don't understand.
From what I understand, if use @Immutable
, even if the state is changed, recomposition should not occur. However, as a result of the test, recomposition proceeds.
What exactly do @Stable
and @Immutable
annotations do in Jetpack Compose?
Upvotes: 63
Views: 26487
Reputation: 67268
Both are contract for telling Compose compiler to use structural equality(==)
when checking inputs of a function to compare with previous values when recomposition happens in scope this function is in.
When a class is stable its equals
function is used to determine whether that function should be recomposed or not if there is a recomposition in that scope. Functions with unstable params get recomposed regardless of equals.
Consider this stable class for instance
data class MyItem(val title: String) {
// override fun equals(other: Any?): Boolean {
// return false
// }
}
When you click the button you trigger recomposition in MyFunction
scope. Data classes use parameters in primary constructor to implement equals. Since title of MyItem
doesn't change MyItemRow
doesn't get recomposed.
@Preview
@Composable
fun MyFun() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
var counter by remember { mutableIntStateOf(0) }
val myItem by remember { mutableStateOf(MyItem("Hello world")) }
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { counter++ }
) { Text("Increase counter") }
Text("Counter: $counter, myItem: ${myItem.title}")
MyItemRow(myItem = myItem)
}
}
@Composable
fun MyItemRow(myItem: MyItem) {
SideEffect { println("MyItemRow is recomposing...") }
Text(
modifier = Modifier
.fillMaxWidth()
.border(4.dp, getRandomColor())
.padding(16.dp),
text = myItem.title
)
}
If you return false from equals with
data class MyItem(val title: String) {
override fun equals(other: Any?): Boolean {
return false
}
}
you will see that recomposition will happen even if MyItem's input is immutable and has never changed.
Now change MyItem
as
data class MyItem(var title: String)
which makes it unstable, in this case any time MyFun is recomposed so will MyItemRow
.
If you add @Stable
or @Immutable
, MyItem becomes stable and its equals
function is used for comparison.
@Stable
data class MyItem(var title: String) {
// override fun equals(other: Any?): Boolean {
// return false
// }
}
You can uncomment equals to see that to observe stable class equals is in use.
The difference between 2 annotations is semantics only.
When a data class has immutable params but which are unstable such as List or Context it's suggested to use
@Immutable
data class ListWrapper(val list:List<String>)
to wrap an unstable class with stable class.
And if a class has mutable params like ScrollState use @Stable
.
Upvotes: 1
Reputation: 8865
The definition
@Immutable
is an annotation to tell Compose compiler that this object is immutable for optimization, so without using it, there will be unnecessary re-composition that might get triggered.
@Stable
is another annotation to tell the Compose compiler that this object might change, but when it changes, Compose runtime will be notified.
It might not make sense if you read up to here. So more explanation...
The Compose metrics report
When you generate the compose metrics report, it will mark things as stable
or unstable
, for unstable
objects, Compose compiler cannot tell if the object is modified, so it has to trigger recomposition regardlessly. Here's two snippets of how the report looks like:
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SomeClass1(
stable modifier: Modifier? = @static Companion
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun SomeClass2(
stable modifier: Modifier? = @static Companion
stable title: String
unstable list: List<User>
stable onClicked: Function1<User>, Unit>
)
skippable is desired!
In the case of SomeClass1
, it is marked as skippable
, because all of it's parameters are marked stable
. For SomeClass2
, it doesn't get marked as skippable
, because it has a property list
that is unstable
.
When it's marked as skippable
, it is a good thing, because Compose compiler can skip recomposition whenever possible and it's more optimized.
when will it fail to be marked as skippable?
Usually compose compiler is smart enough to deduce what is stable
and what is unstable
. In the cases where compose compiler cannot tell the stability are mutable objects, e.g. a class that contains var
properties.
class SomeViewState {
var isLoading: Boolean
}
Another case where it will fail to decide the stability would be for classes like Collection
, such as List
, because even the interface is List
which looks immutable, it can actually be a mutable list. Example:
data class SomeViewState {
val list: List<String>
}
@Composable
fun ShowSomething(data: SomeViewState) {
}
Even though the Composable above accepts SomeViewState
where all it's property is val
, it is still unstable
. You might wonder why? That's because on the use side, you can actually use it with a MutableList
, like this:
ShowSomething(SomeViewState(mutableListOf()))
For this reason, the compiler will have to mark this as unstable
.
So in cases like this, what we want to achieve is to make them stable
again, so they are optimized.
@Stable and @Immutable
There are 2 ways to make it stable
again, which are using @Stable
and @Immutable
.
Using @Stable
, as mentioned above, it means that the value can be changed, but when it does change, we have to notify Compose compiler. The way to do it is through using mutableStateOf()
:
@Stable
class SomeViewState {
var isLoading by mutableStateOf(false)
}
Using @Immutable
, it means that you will always make a new copy of the data when you pass into the Composable, in other wards, you make a promise that your data is immutable. From the example above:
@Immutable
data class SomeViewState {
val list: List<String>
}
@Composable
fun ShowSomething(data: SomeViewState) {
}
After annotating with @Immutable
, on your use side, you should make sure to make a new list instead of mutating your list directly.
Example DO:
class ViewModel {
val state: SomeViewState = SomeViewState(listOf())
fun removeLastItem() {
val newList = state.list.toMutableList().apply {
removeLast()
}
state = state.copy(
list = newList
)
}
}
Example DON'T:
class ViewModel {
val state: SomeViewState = SomeViewState(mutableListOf())
fun removeLastItem() {
state.list.removeLast() // <=== you violate your promise of @Immutable!
}
}
For deeper understanding, you can read this links:
Upvotes: 112
Reputation: 364780
The compiler treats both identically but
@Immutable
is a promise that the value will never change.@Stable
is a promise that the value is observable and if it does change listeners are notified.Upvotes: 50