androidkotlinandroid-jetpack-compose

Is there a more efficient way to update Data class properties in Jetpack Compose Kotlin?


I have a data class in Jetpack Compose that represents a Car:

data class Car(
    val id: Int = 0,
    val brand: String = "",
    val model: String = "",
    val year: Int = 2020
)

In my composable, I update the brand and model properties of this data class based on user input in TextField components. Currently, I am using the copy() function each time the user types something, like this:

@Composable
fun CarScreen() {
    var car by remember { mutableStateOf(Car()) }

    Column {
        TextField(
            value = car.brand,
            onValueChange = { newBrand ->
                car = car.copy(brand = newBrand) // Using `copy()`
            },
            label = { Text("Brand") }
        )

        TextField(
            value = car.model,
            onValueChange = { newModel ->
                car = car.copy(model = newModel) // Using `copy()`
            },
            label = { Text("Model") }
        )
    }
}

Concern:

I'm concerned that calling copy() on every text change may lead to performance issues, as it creates a new instance of the Car object every time the user types in a TextField. This happens with every keystroke, and in a larger app or form with many fields, this could become inefficient.

My Questions:

  1. Is calling copy() on every TextField change inefficient in terms of performance in Jetpack Compose? Would this constant object creation and recomposition cause noticeable performance degradation, especially in larger data models or frequent input scenarios?
  2. What are better approaches to handle frequent text input changes without having to use copy() all the time, while still ensuring that Compose can recompose the UI when necessary? I want to maintain a reactive UI without excessive object creation.

What if there was a way that instead of copying object we could change Data class values directly and compose automatically trigger recomposition?


Solution

  • With Views it was recommended not to instantiate new objects, especially in onDraw function that is called multiple times.

    However, in Jetpack Compose, without strong skipping, data classes with mutable params are discouraged because if composition happens in a scope functions that have unstable params, for data classes mutable params, or unstable classes from external libraries, or another module in your project that doesn't extend compose compiler it triggers recomposition for that function.

    https://developer.android.com/develop/ui/compose/performance/stability#mutable-objects

    enter image description here

    enter image description here

    @Composable
    fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
       var selected by remember { mutableStateOf(false) }
    
       Row(modifier) {
          ContactDetails(contact)
          ToggleButton(selected, onToggled = { selected = !selected })
       }
    }
    

    In function above if you use data class with mutable params when selected changes Row gets composed so does ContactDetails, even if Contact hasn't changed, because ContactDetails has unstable input.

    And there is no observable performance overhead with copying objects like car or even datas with primitive values or classes that contain primitive values or Strings. You might only want to consider if your data class contains big data such as Bitmap or Base64 format of image, and even for that case it has to be some deep copy.

    But Kotlin's copy function is shallow copy. It only copies references to objects if you don't create new instances.

    class Car(
        val id: Int = 0,
        val brand: String = "",
        var model: String = "",
        val year: Int = 2020,
    )
    
    
    data class MyListClass(val currentCar: Car, val items: List<Car>)
    
        @Preview
        @Composable
        fun TestCopy() {
            var myListClass by remember {
                mutableStateOf(MyListClass(
                    currentCar = Car(),
                    items = List(10) {
                        Car()
                    }
                ))
            }
            Button(
                onClick = {
                    val temp = myListClass
        
                    myListClass = myListClass.copy(
                        currentCar = Car(id = 1)
                    )
        
                    println("temp===myListClass ${temp === myListClass}\n" +
                            "temp car===zmyListClass car ${temp.currentCar === myListClass.currentCar}\n" +
                            "temp list===myListClass ${temp.items === myListClass.items}")
                }
            ) {
                Text("Copy...")
            }
        }
    

    Prints

     I  temp===myListClass false
     I  temp car===zmyListClass car false
     I  tem list===myListClass true
    

    However, if you still want to optimize it by not creating new holder object, you can use vars with @Stable annotation, not needed with strong skipping, which would prevent recomposition of your function if its inputs hasn't changed when another State or change of inputs of parent function triggers recomposition in parent scope.

    And trigger recomposition when properties of your car change such as

    Using another SnapshotMutationPolicy

    Default policy of MutableState is structuralEqualityPolicy() which checks if new value you set is == to previous one. In data classes equals is determined by parameters of primary constructor.

    @Suppress("UNCHECKED_CAST")
    fun <T> structuralEqualityPolicy(): SnapshotMutationPolicy<T> =
        StructuralEqualityPolicy as SnapshotMutationPolicy<T>
    
    private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
        override fun equivalent(a: Any?, b: Any?) = a == b
    
        override fun toString() = "StructuralEqualityPolicy"
    }
    

    By changing this policy you can trigger recomposition even with same object such as

    @Stable
    data class Car(
        val id: Int = 0,
        val brand: String = "",
        var model: String = "",
        val year: Int = 2020,
    )
    
    @Preview
    @Composable
    fun CarScreen() {
        var car by remember {
            mutableStateOf(
                value = Car(),
                policy = neverEqualPolicy()
            )
        }
    
        Column {
            TextField(
                value = car.brand,
                onValueChange = { newBrand ->
                    car = car.copy(brand = newBrand) // Using `copy()`
                },
                label = { Text("Brand") }
            )
    
            TextField(
                value = car.model,
                onValueChange = { newModel ->
    //                car = car.copy(model = newModel) // Using `copy()`
    
                    car = car.apply {
                        model = newModel
                    }
                },
                label = { Text("Model") }
            )
    
            Text("Car brand: ${car.brand}, model: ${car.model}")
        }
    }
    

    As you can see with neverEqualPolicy() policy you can trigger recomposition by assigning same object.

    It applies to any class, You can trigger recomposition by setting counter value to same value.

    Using a class with MutableStates

    This approach is widely used in rememberXState classes and Google's Jetsnack sample.

    ScrollState , jetsnack search state

    @Stable
    class CarUiState(
        brand: String = "",
        model: String = "",
    ) {
    
        var id: Int = 0
        var year: Int = 2020
    
        var brand by mutableStateOf(brand)
        var model by mutableStateOf(model)
    }
    
    @Preview
    @Composable
    fun CarScreen() {
    
        val carUiState = remember {
            CarUiState()
        }
        Column {
            TextField(
                value = carUiState.brand,
                onValueChange = { newBrand ->
                    carUiState.brand = newBrand
                },
                label = { Text("Brand") }
            )
    
            TextField(
                value = carUiState.model,
                onValueChange = { newModel ->
                    carUiState.model = newModel
                },
                label = { Text("Model") }
            )
    
            Text("Car brand: ${carUiState.brand}, model: ${carUiState.model}, year: ${carUiState.year}")
        }
    }
    

    You simply divde you class between properties that should trigger recomposition and other properties that doesn't require, since they would also be changed when recomposition is triggered.

    To enable strong skipping in gradle file set

    composeCompiler {
        // Configure compose compiler options if required
        enableStrongSkippingMode = true
    }
    

    So changes above would be applied to classes, lambdas and functions

    Composables with unstable parameters can be skipped.

    Unstable parameters are compared for equality via instance equality (===)

    Stable parameters continue to be compared for equality with Object.equals()

    All lambdas in composable functions are automatically remembered. This means you will no longer have to wrap lambdas in remember to ensure a composable that uses a lambda, skips.