3

In the official documentation, a List is always recomposed, even if its collection is actually the same, because "this is an Interface, and its implementation can be mutable", and this is indeed how it behaves (if you use Layout Inspector to extract the recomposition count).

Okay, here's another example.

interface MyImage {
    object None : MyImage
    data class Res(@DrawableRes var id : Int) : MyImage
    data class Remote(val url : String) : MyImage
}

And there is some Composable that receives MyImage.

@Composable
fun MyImage(image : MyImage) {
    println("My Image ReCompose!!!!!")
    if (image is MyImage.Remote) {
        Image(painter = rememberAsyncImagePainter(model = image.url), contentDescription = null)
    }
    else if (image is MyImage.Res) {
        Image(painter = painterResource(id = image.id), contentDescription = null)
    }
}

parameters are received as interfaces, not implementations, so if we follow the example of List, this means that Recomposition should always run even if the implementations are actually the same (since nothing tells us otherwise, such as @Stable).

@Preview
@Composable
fun MyComposableTest() {

    val imgFlow = MutableSharedFlow<MyImage>()
    val img by imgFlow.collectAsState(MyImage.Remote("MY IMAGE URL"))

    LaunchedEffect(true) {
        var i = 0
        while (true){
            delay(500L)
            imgFlow.emit(MyImage.Remote("MYIMAGE URL"))
        }
    }

    Column {
        MyImage(img)
    }
}

The above function expects to be recomposed every 0.5 seconds, but it doesn't actually work that way (it recomposes whenever equals changes, which is the normal expected behavior).

Is Compose only handled separately for Collections like List, Set, or am I doing something wrong with Recomposition for interfaces?

Thracian
  • 43,021
  • 16
  • 133
  • 222
H.Kim
  • 525
  • 4
  • 14

1 Answers1

1

Interfaces or Classes are not unstable by default, if they are from another module and not annotated with @Stable or @Immutable they are unstable. Easiest way to test is to create a module and a data class with immutable param and observe that this becomes unstable in your app module.

class LibraryModel(val value: Int)

Also classes with mutable params in any module are unstable.

In your case

stable class None {
  <runtime stability> = Stable
}
unstable class Res {
  stable var id: Int
  <runtime stability> = Unstable
}
stable class Remote {
  stable val url: String
  <runtime stability> = Stable
}

only unstable class is Res

But stability means a function should not be recomposed when there is a recomposition triggered in a scope that contains it unless its inputs change.

In your example you are trying to trigger recomposition with the same value which is not possible with structuralMutationPolicy.

I made a sample to make it clearer.

Created 2 functions, one is stable while other is not

@Composable
fun MyImage(image: MyImage) {
    println("MyImage() recomposing!!!!!, image: $image")
}

@Composable
fun MyImageRes(image: MyImage.Res) {
    println("MyImageRes() recomposing!!!!!, image: $image")
}

With skippable statuses as

restartable skippable fun MyImage(
  image: MyImage
)
restartable fun MyImageRes(
  unstable image: Res
)

And a counter to trigger recomposition show that unstable class is recomposed while the first one doesn't

@Preview
@Composable
fun MyComposableTest() {


    var counter by remember {
        mutableStateOf(0)
    }

    val imgFlow =  MutableSharedFlow<MyImage>()

    val img = imgFlow.collectAsState(MyImage.Remote("MY IMAGE URL"))

    LaunchedEffect(true) {
        while (true) {
            delay(500L)
            imgFlow.emit(MyImage.Res(R.drawable.ic_launcher_background))
        }
    }

    Column {

        Button(onClick = { counter++}) {
            Text(text = "Increase Counter")
        }
        Text(text = "Counter: $counter")
        
        MyImage(image = img.value)

        (img.value as? MyImage.Res)?.let {
            MyImageRes(it)
        }
    }
}

When you click the button you will see that MyImageRes is recomposed and MyImage is not even though neither of them read counter. This is how stability effects recomposition of a function when the scope it's in is recomposed even when its inputs doesn't change.

But recomposition can be triggered with same values or referential equality as well based on which SnapshotMutationPolicy you choose or implementation of your own.

val img = remember {
    mutableStateOf<MyImage>(
        value = MyImage.Remote("MY IMAGE URL"),
        policy = neverEqualPolicy()
    )
}

triggers recomposition each time value is set but as in the previous example input changes trigger recomposition only for the unstable(ImageRes) function.

@Preview
@Composable
fun MyComposableTest() {

    val img = remember {
        mutableStateOf<MyImage>(
            value = MyImage.Remote("MY IMAGE URL"),
            policy = neverEqualPolicy()
        )
    }

    LaunchedEffect(true) {
        while (true) {
            delay(500L)
            img.value = MyImage.Res(R.drawable.ic_launcher_background)
        }
    }

    Column {
        MyImage(image = img.value)

        (img.value as? MyImage.Res)?.let {
            MyImageRes(it)
        }
    }
}
Thracian
  • 43,021
  • 16
  • 133
  • 222