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:
What if there was a way that instead of copying object we could change Data class values directly and compose automatically trigger recomposition?
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
@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
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.
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
}
For changes below 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.