Search code examples
androidandroid-jetpack-composecompose-recomposition

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?


Solution

  • 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: