androidandroid-layoutandroid-jetpack-composecompose-recomposition

Dumb recomposition in Android Compose


Consider this minimal code snippet (in Kotlin):

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import java.time.LocalDateTime
import java.util.*

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {

            var time by remember {
                mutableStateOf("time")
            }
            Column(modifier = Modifier.clickable { time = LocalDateTime.now().toString() }) {
                Text(text = UUID.randomUUID().toString())
                Text(text = time)
            }
        }
    }
}

Examining the above code, from a logical perspective, one expects that upon clicking the Column, since only the parameter time changes, only the lower time Text composable would be redrawn. This is because recomposition skips as much as possible.

However, one finds that the upper Text composable is being redrawn too (the displayed UUID keeps changing).

  1. Why is this?

Notice that the non-idempotence of my Column composable should have no bearing unless the redraw is dumb.


Solution

  • You can try running this block of code

    @Composable
    fun IdempotenceTest() {    
        var time by remember {
            mutableStateOf("time")
        }
        Column(
            modifier = Modifier.clickable {
                time = LocalDateTime.now().toString()
            }
        ) {
            Text(text = getRandomUuid())
            TestComposable(text = returnSameValue())
            Text(text = time)
        }
    }
    
    @Composable
    fun TestComposable(text: String) {
        SideEffect {
            Log.d(TAG, "TestComposable composed with: $text")
        }
        Text(text = text)
    }
    
    private fun getRandomUuid(): String {
        Log.d(TAG, "getRandomUuid: called")
        return UUID.randomUUID().toString()
    }
    
    private fun returnSameValue(): String {
        Log.d(TAG, "returnSameValue: called")
        return "test"
    }
    

    If you check the logs, you will see that everytime the state changes, the compiler tries to re-call the smallest enclosing lamda/function where the state's value is being read. Hence, the IdempotenceTest function (in my example and setContent{} lamda in yours) will get re-executed which will call the getRandomUuid and returnSameValue function and based on the value returned by these, it will decide whether or not to re-compose the elements which depend on these return values. If you want to prevent a calculation from happening again and again, wrap it in a remember{} block.

    Now if you were to use a Button for example instead of a Column, you would have seen that only the content lamda of the same would have been executed. The reason why this happens is that Column is an inline function while Button in itself uses Surface inside it which is non-inline. Therefore the content of Column{} is copied inside the enclosing function block thus leading to the entire IdempotenceTest being invalidated for re-composition.

    As a side-note, the Composable functions must be side-effect free to ensure idempotence. You can read more here.

    To read more about recomposition scope, you can refer to the blog posts here and here.