Re-composition and Java Static references (triggers re-composition even if not being read/observed)

Viewed 47

Edit: Apologies for the title, it should be specified (mutableStateObject triggers re-composition even if not being read/observed), please edit if necessary.

This issue I encountered comes from a use-case where I deal with java.time components and I found out it has something to do with static references, so I decided to create 2 Java classes that are being used in some composables

RootJavaClass

public class RootJavaClass {
     public static AnotherJavaClass getNewInstance() {
         return new AnotherJavaClass("Hello There");
     }
}

AnotherJavaClass

public class AnotherJavaClass {
     public String data = "";
     public AnotherJavaClass(String param) {
         data = param;
     }
 }

My Parent Scope Composable

@Composable
internal fun ParentComposable(
    modifier: Modifier = Modifier
) {

    var stateLessObject = RootJavaClass.getNewInstance()
    var mutableStateObject by remember { mutableStateOf(stateLessObject)}

    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
            mutableStateObject = it
        }

        ReadsAMutableState(mutableStateObject)
    }
}

and lastly some child composable inside the parent scope

@Composable // this composable is being re-composed unwantedly
fun DoesNotReadAnyMutableState(
    stateLessObject : AnotherJavaClass,
    onButtonClick : (AnotherJavaClass) -> Unit
) {
    Button(
        onClick = {
            onButtonClick(AnotherJavaClass("GoodBye"))
        },
    ) {
        Text(
            text = stateLessObject.data
        )
    }
}

@Composable
fun ReadsAMutableState(
    mutableStateObject: AnotherJavaClass
) {
    Text(
        text = mutableStateObject.data
    )
}

Why does the DoesNotReadAnyMutableState composable is being re-composed? even if it doesn't read the mutableState object?, this does not happen with ordinary classes even with String. This only happens when I use static referenced object, set it to be remembered as the initial value of the mutable state object and modify that object ( as you can see in the lambda callback )

Thank you in advance..

Edit: I made some changes referencing the answer from another post regarding Smart Re-composition, credits to @Thracian Smart Re-composition

I created my own non-inlined Column{..} scope

@Composable
fun MyOwnColumnScope(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        content()
    }
}

and used it this way

MyOwnColumnScope {
    DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
        mutableValue = it
    }

    ReadsAMutableState(mutableValue)
}

but it still triggers the un-wanted recomposition :(

I'm not quite sure if there's a special case within the usage of statics, or if this is a bug within the snapshot`compose` framework?

1 Answers

It's because Column doesn't create a recomposition scope. And on each recomposition you create new instance of stateLessObject and pass it to DoesNotReadAnyMutableState Composable.

When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit. Column, Row and Box are inline functions and because of that they don't create scopes.

If you change your Column into the Composable above it won't be recomposed.

@Composable
private fun MyColumn(modifier: Modifier,content: @Composable ()->Unit) {
    Column(modifier=modifier){
        content()
    }
}

I did further testing. First, when it's Kotlin class it definitely requires non-inlined function to have scoped function to not recompose DoesNotReadAnyMutableState

@Composable // this composable is being re-composed unwantedly
fun DoesNotReadAnyMutableState(
    stateLessObject: AnotherJavaClass,
    onButtonClick: (AnotherJavaClass) -> Unit
) {
    Button(
        onClick = {
            onButtonClick(AnotherJavaClass("GoodBye"))
        },
    ) {
        Text(
            text = stateLessObject.data
        )
    }
    Box(
        modifier = Modifier
            .background(getRandomColor())
            .fillMaxWidth()
            .height(20.dp)
    )
}

@Composable
fun ReadsAMutableState(
    mutableStateObject: AnotherJavaClass
) {
    Text(
        text = mutableStateObject.data
    )
}


@Composable
internal fun ParentComposable(
    modifier: Modifier = Modifier
) {

    var stateLessObject = MyData("Column")
    var mutableStateObject by remember { mutableStateOf(stateLessObject) }

    Column(
    ) {
        DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
            mutableStateObject = it
        }

        ReadsAMutableState(mutableStateObject)
    }
}

@Composable
internal fun ParentComposable2(
    modifier: Modifier = Modifier
) {

    var stateLessObject = MyData("MyColumn")
    var mutableStateObject by remember { mutableStateOf(stateLessObject) }

    MyColumn() {
        DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
            mutableStateObject = it
        }

        ReadsAMutableState(mutableStateObject)
    }
}

class MyData(val data: String)

Result

enter image description here

Then changed MyData class to one below and created another one that returns same instance

class MyData() {
    val anotherJavaClass: AnotherJavaClass
        get() = AnotherJavaClass("")
}

class MyData2() {
    val anotherJavaClass = AnotherJavaClass("")
}

And parent classes to

@Composable
internal fun ParentComposable(
    modifier: Modifier = Modifier
) {

    var stateLessObject = MyData().anotherJavaClass

    LogCompositions(" ParentComposable() ${stateLessObject.hashCode()}")

    var mutableStateObject by remember { mutableStateOf(stateLessObject) }

    MyColumn() {

        LogCompositions(" ParentComposable() MyColumn scope ${stateLessObject.hashCode()}")

        DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
            mutableStateObject = it
        }

        ReadsAMutableState(mutableStateObject)
    }
}

@Composable
internal fun ParentComposable2(
    modifier: Modifier = Modifier
) {

    var stateLessObject = MyData2().anotherJavaClass
    var mutableStateObject by remember { mutableStateOf(stateLessObject) }
    LogCompositions(" ParentComposable2() ${stateLessObject.hashCode()}")

    MyColumn() {
        LogCompositions(" ParentComposable2() scope ${stateLessObject.hashCode()}")

        DoesNotReadAnyMutableState(stateLessObject = stateLessObject) {
            mutableStateObject = it
        }

        ReadsAMutableState(mutableStateObject)
    }
}

LogComposition is

class Ref(var value: Int)

// Note the inline function below which ensures that this function is essentially
// copied at the call site to ensure that its logging only recompositions from the
// original call site.
@Composable
inline fun LogCompositions(msg: String) {
    val ref = remember { Ref(0) }
    SideEffect { ref.value++ }
    println("$msg, recomposition: ${ref.value}")
}

Result

enter image description here

When non-inlined function used it doesn't recompose parent but java class with or without static causing scope to trigger recomposition but i don't know why yet.

You can check my other answers about scoped/smart recomposition below

Jetpack Compose Smart Recomposition

Why does mutableStateOf without remember work sometimes?

How can I launch recomposition when a specified Flow changed in Jetpack Compose?

When will Jetpack Compose launch recomposition and what will be recomposition?

There are very good articles in the link i suggest you to check out.

Related