Ji Sungbin
Ji Sungbin

Reputation: 1321

What do the @Stable and @Immutable annotations mean in Jetpack Compose?

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

Answers (3)

Thracian
Thracian

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

I&#39;m a frog dragon
I&#39;m a frog dragon

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

Gabriele Mariotti
Gabriele Mariotti

Reputation: 364780

The compiler treats both identically but

  • using @Immutable is a promise that the value will never change.
  • using @Stable is a promise that the value is observable and if it does change listeners are notified.

Upvotes: 50

Related Questions