5

In order to store state in jetpack compose I have so far used the the following pattern:

 private val _largeDataClass:MutableState<LargeDataClass> = mutableStateOf(LargeDataClass())
 val largeDataClass :State<LargeDataClass> = _largeDataClass

then I display some or all of the properties of this class in my composition. When the user changes a property of this data class I need to update the state and I do it in the following way:

fun onUserEvent(somePropertyChange:String){
       _largeDataClass.value=largeDataClass.value.copy(someProperty = somePropertyChange)
}

I got this approach from the following post. It works and has the benefit of keeping my codebase relatively small (as there might be 20+ different properties in LargeDataClass that I dont need to declare individually as mutable state) BUT, if I am not mistaken, following this approach will trigger the recomposition of my entire screen even if the user just types a single letter in one of my many TextFields. As all my composables display some property of largeDataClass and they have just been notified that its value has changed.

My first question is wether I am right in this last assumption. Will my current way of holding state negatively affect my apps performance because I am forcing the screen to redraw itself completely constantly? Or are there some optimizations, that I'm unaware of, in compose that prevent this from happening and render my appoach safe?

my second question: I would really love it if there was a way of turning a data class, say:

data class Student(
    val key: String = "s0",
    val firstName: String = "",
    val lastName: String = "")

into an equivalent state holder class (something similar to the following)

class StudentState(s:Student){
        val key= mutableStateOf(s:Key),
        val firstName= mutableStateOf(s.firstName),
        val lastName= mutableStateOf(s.lastName)}

(Ideally without having to explicitly write such a class myself every time) Does this exist already? is there a way of using reflection or the like to achieve this for a generic data class?

I am still learning to deal with state in jetpack compose and I want to get it right. It seems to me that tracking the properties of my data classes individually either in the ViewModel or in a State Holder class is the right thing to do, but on the other hand this makes my code a lot longer and it just feels like I am doing a lot of stuff twice and my code becomes less readable and maintainable. Any insights are much appreciated

quealegriamasalegre
  • 2,887
  • 1
  • 13
  • 35
  • Did you found an answer to the question if its negatively affecting the performance? – HavanaSun May 14 '22 at 19:22
  • @TheUseracc awd not yet. I know that copying an object just doesnt work propperly when LargeDataClass contains lists or maps, as `copy()` will only generate shallow copies of an object, meaning that if you add a new element to a map inside a LargeDataClass object and then copy it recomposition will not be triggered on that map even if you copy the entire object. this behaviour leads me to think that while performance implications of copying an object might be small or inexistent, the "right" approach is to hold the state of every element of an object that might change as state separately – quealegriamasalegre May 18 '22 at 16:28
  • My answer bellow is a bit overkill but personally I found that it made my code a lot cleaner (at the expense of having to write my own annotation processor that is XD) – quealegriamasalegre May 18 '22 at 16:33
  • I'm also curious why not simply use mutableStateOf in data classes without any extra wrappers? But the problem could be with parcelization, saving etc. – Psijic Feb 23 '23 at 17:44

3 Answers3

1

You could use reflection to create mutableStates for the members of any class like so:

fun createStateMap(baseObject: Any) = with(baseObject::class.memberProperties) {
    map { it.name }.zip(map { mutableStateOf(it.getter.call(baseObject)) }).toMap()
}

And then use it like this:

class MyViewModel : ViewModel() {
    private val student = Student(firstName = "John", lastName = "Doe")
    val studentStateMap = createStateMap(student)
}


 @Composable
fun MyComposable(viewModel: MyViewModel) {
    val student = viewModel.studentStateMap
    Button(
        onClick = { student["firstName"]?.value = "Jack" }
    ) {
        Text(text = student["firstName"]?.value.toString())
    }
}

I wouldn't use it, it's not typesafe, it's messy and ugly, but it's possible.

jpegoraro
  • 315
  • 2
  • 10
  • I also had this idea by either implementing an interface on the data class of interest or doing it via a static method like you are proposing. And indeed it does what I asked in my second question, all be it at the cost of transparency. But I think at the and I will rather create an annotation that creates a state version of my data classes automatically on build, complete with methods that can take the original data class as an input and, conversely, produce an instance of this class on execution. there is a lot of code involved but once i am done it should be cleaner. what do you think? – quealegriamasalegre Feb 19 '22 at 21:33
  • any thougts on the first question? – quealegriamasalegre Feb 19 '22 at 21:34
0

An Annotation to store data class @AsState

Well I am still not sure about wether it is fine to simply .copy(changedValue = "...") a large data class or if this is inefficient because it triggers unecessary recompositions.

In any case just to be safe (and cause I think its cleaner) I wrote an AnnotationProcessor that takes my data classes and creates both a mutable and immutable version of the class holding all properties as state. It supports both lists and maps but is shallow (meaning that it wont repeat the same process for nested classes). Here an example of the class with the annotation and the result.

please let me know if you consider this to be usefull in order to track state more cleanly when a data class is displayed/edited in a composable (and also if you dont)

The original class

@AsState
data class Test(val name:String, val age:Int, val map:HashMap<String,Int>, val list:ArrayList<String>)

The mutable verion of the class with a custonm constructor and rootClass getter

public class TestMutableState {
  public val name: MutableState<String>

  public val age: MutableState<Int>

  public val map: SnapshotStateMap<String, Int>

  public val list: SnapshotStateList<String>

  public constructor(rootObject: Test) {
    this.name=mutableStateOf(rootObject.name) 
    this.age=mutableStateOf(rootObject.age) 
    this.map=rootObject.map.map{Pair(it.key,it.value)}.toMutableStateMap()
    this.list=rootObject.list.toMutableStateList()
  }

  public fun getTest(): Test = Test(name = this.name.value,
  age = this.age.value,
  map = HashMap(this.map),
  list = ArrayList(this.list),
  )
}

The immutable version that can be public in the ViewModel

public class TestState {
  public val name: State<String>

  public val age: State<Int>

  public val map: SnapshotStateMap<String, Int>

  public val list: SnapshotStateList<String>

  public constructor(mutableObject: TestMutableState) {
    this.name=mutableObject.name
    this.age=mutableObject.age
    this.map=mutableObject.map
    this.list=mutableObject.list
  }
}

TL;DR

Next I am pasting the source code for my annotation processor so you can implement it. I basically followed this article and implemented some of my own changes based on arduous googling. I might make this a module in the future so that other people can more easily implement this in their projects i there is any interest:

Annotation class

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
public annotation class AsState

Annotation processor

@AutoService(Processor::class)
class AnnotationProcessor : AbstractProcessor() {
    companion object {
        const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
    }

    override fun getSupportedAnnotationTypes(): MutableSet<String> {
        return mutableSetOf(AsState::class.java.name)
    }

    override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latest()

    override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        roundEnv.getElementsAnnotatedWith(AsState::class.java)
            .forEach {
                if (it.kind != ElementKind.CLASS) {
                    processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "Only classes can be annotated")
                    return true
                }
                processAnnotation(it)
            }
        return false
    }

    @OptIn(KotlinPoetMetadataPreview::class, com.squareup.kotlinpoet.DelicateKotlinPoetApi::class)
    private fun processAnnotation(element: Element) {
        val className = element.simpleName.toString()
        val pack = processingEnv.elementUtils.getPackageOf(element).toString()
        val kmClass = (element as TypeElement).toImmutableKmClass()

        //create vessel for mutable state class
        val mutableFileName = "${className}MutableState"
        val mutableFileBuilder= FileSpec.builder(pack, mutableFileName)
        val mutableClassBuilder = TypeSpec.classBuilder(mutableFileName)
        val mutableConstructorBuilder= FunSpec.constructorBuilder()
            .addParameter("rootObject",element.asType().asTypeName())
        var helper="return ${element.simpleName}("

        //create vessel for immutable state class
        val stateFileName = "${className}State"
        val stateFileBuilder= FileSpec.builder(pack, stateFileName)
        val stateClassBuilder = TypeSpec.classBuilder(stateFileName)
        val stateConstructorBuilder= FunSpec.constructorBuilder()
            .addParameter("mutableObject",ClassName(pack,mutableFileName))

        //import state related libraries
        val mutableStateClass= ClassName("androidx.compose.runtime","MutableState")
        val stateClass=ClassName("androidx.compose.runtime","State")
        val snapshotStateMap= ClassName("androidx.compose.runtime.snapshots","SnapshotStateMap")
        val snapshotStateList=ClassName("androidx.compose.runtime.snapshots","SnapshotStateList")


        fun processMapParameter(property: ImmutableKmValueParameter) {
            val clName =
                ((property.type?.abbreviatedType?.classifier) as KmClassifier.TypeAlias).name
            val arguments = property.type?.abbreviatedType?.arguments?.map {
                ClassInspectorUtil.createClassName(
                    ((it.type?.classifier) as KmClassifier.Class).name
                )
            }
            val paramClass = ClassInspectorUtil.createClassName(clName)
            val elementPackage = clName.replace("/", ".")
            val paramName = property.name

            arguments?.let {
                mutableClassBuilder.addProperty(
                    PropertySpec.builder(
                        paramName,
                        snapshotStateMap.parameterizedBy(it), KModifier.PUBLIC
                    )
                        .build()
                )
            }
            arguments?.let {
                stateClassBuilder.addProperty(
                    PropertySpec.builder(
                        paramName,
                        snapshotStateMap.parameterizedBy(it), KModifier.PUBLIC
                    )
                        .build()
                )
            }

            helper = helper.plus("${paramName} = ${paramClass.simpleName}(this.${paramName}),\n")

            mutableConstructorBuilder
                .addStatement("this.${paramName}=rootObject.${paramName}.map{Pair(it.key,it.value)}.toMutableStateMap()")

            stateConstructorBuilder
                .addStatement("this.${paramName}=mutableObject.${paramName}")
        }

        fun processListParameter(property: ImmutableKmValueParameter) {
            val clName =
                ((property.type?.abbreviatedType?.classifier) as KmClassifier.TypeAlias).name
            val arguments = property.type?.abbreviatedType?.arguments?.map {
                ClassInspectorUtil.createClassName(
                    ((it.type?.classifier) as KmClassifier.Class).name
                )
            }
            val paramClass = ClassInspectorUtil.createClassName(clName)
            val elementPackage = clName.replace("/", ".")
            val paramName = property.name

            arguments?.let {
                mutableClassBuilder.addProperty(
                    PropertySpec.builder(
                        paramName,
                        snapshotStateList.parameterizedBy(it), KModifier.PUBLIC
                    )
                        .build()
                )
            }
            arguments?.let {
                stateClassBuilder.addProperty(
                    PropertySpec.builder(
                        paramName,
                        snapshotStateList.parameterizedBy(it), KModifier.PUBLIC
                    )
                        .build()
                )
            }

            helper = helper.plus("${paramName} = ${paramClass.simpleName}(this.${paramName}),\n")

            mutableConstructorBuilder
                .addStatement("this.${paramName}=rootObject.${paramName}.toMutableStateList()")

            stateConstructorBuilder
                .addStatement("this.${paramName}=mutableObject.${paramName}")
        }

        fun processDefaultParameter(property: ImmutableKmValueParameter) {
            val clName = ((property.type?.classifier) as KmClassifier.Class).name
            val paramClass = ClassInspectorUtil.createClassName(clName)
            val elementPackage = clName.replace("/", ".")
            val paramName = property.name

            mutableClassBuilder.addProperty(
                PropertySpec.builder(
                    paramName,
                    mutableStateClass.parameterizedBy(paramClass), KModifier.PUBLIC
                ).build()
            )
            stateClassBuilder.addProperty(
                PropertySpec.builder(
                    paramName,
                    stateClass.parameterizedBy(paramClass),
                    KModifier.PUBLIC
                ).build()
            )

            helper = helper.plus("${paramName} = this.${paramName}.value,\n")

            mutableConstructorBuilder
                .addStatement(
                    "this.${paramName}=mutableStateOf(rootObject.${paramName}) "
                )

            stateConstructorBuilder
                .addStatement("this.${paramName}=mutableObject.${paramName}")
        }

        for (property in kmClass.constructors[0].valueParameters) {
            val javaPackage = (property.type!!.classifier as KmClassifier.Class).name.replace("/", ".")
            val javaClass=try {
                Class.forName(javaPackage)
            }catch (e:Exception){
                String::class.java
            }

            when{
                Map::class.java.isAssignableFrom(javaClass) ->{ //if property is of type map
                    processMapParameter(property)
                }
                List::class.java.isAssignableFrom(javaClass) ->{ //if property is of type list
                    processListParameter(property)
                }
                else ->{ //all others
                    processDefaultParameter(property)
                }
            }
        }

        helper=helper.plus(")") //close off method

        val getRootBuilder= FunSpec.builder("get$className")
            .returns(element.asClassName())
        getRootBuilder.addStatement(helper.toString())
        mutableClassBuilder.addFunction(mutableConstructorBuilder.build()).addFunction(getRootBuilder.build())
        stateClassBuilder.addFunction(stateConstructorBuilder.build())

        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]

        val mutableFile = mutableFileBuilder
            .addImport("androidx.compose.runtime", "mutableStateOf")
            .addImport("androidx.compose.runtime","toMutableStateMap")
            .addImport("androidx.compose.runtime","toMutableStateList")
            .addType(mutableClassBuilder.build())
            .build()
        mutableFile.writeTo(File(kaptKotlinGeneratedDir))

        val stateFile = stateFileBuilder
            .addType(stateClassBuilder.build())
            .build()
        stateFile.writeTo(File(kaptKotlinGeneratedDir))
    }
}

gradle annotation

plugins {
    id 'java-library'
    id 'org.jetbrains.kotlin.jvm'
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

gradle processor

plugins {
    id 'kotlin'
    id 'kotlin-kapt'
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(':annotations')
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.10"
    // https://mvnrepository.com/artifact/com.squareup/kotlinpoet
    implementation 'com.squareup:kotlinpoet:1.10.2'
    implementation "com.squareup:kotlinpoet-metadata:1.7.1"
    implementation "com.squareup:kotlinpoet-metadata-specs:1.7.1"
    implementation "com.google.auto.service:auto-service:1.0.1"
    // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-metadata-jvm
    implementation "org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.4.2"
    implementation 'org.json:json:20211205'

    kapt "com.google.auto.service:auto-service:1.0.1"
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}
quealegriamasalegre
  • 2,887
  • 1
  • 13
  • 35
0

As nobody has answered your first question:

Does using a single state object cause performance issues?

Short Answer

In most cases no, if your child composables only take the needed fields as argument and not the whole state. Compose does the necessary optimizations. Pay attention when using unstable arguments however.

Long Answer

Compose tries to avoid recomposition where possible. It only recomposes composables if the arguments have changed since the last recomposition. This means that your screen composable will get recomposed, BUT it will reuse all child composables that don't need to be updated.

In order for this to work your child composables must only take the fields they actually need and NOT the whole state object.

Example

Assume your screen looks something like this:

@Composable
fun MainScreen(screenState: MyLargeState) {
  Column {
    Text(screenState.text)
    MyButton(screenState.buttonLabel, screenState.buttonColor)
    LazyRow {
      items(items = screenState.items, key = { it.id }) {
        MyItem(it)
      }
    }
  }
}

If you now pass in a new MyLargeState object, the MainScreen will recompose as its argument has changed, and so will Text. MyButton, however, will not be recomposed, as its parameters have not changed. In order for compose to recognize that a parameter hasn't changed, it must be stable. Stable types (broadly speaking) are ones whose instances cannot be changed. Note that this doesn't include Lists. and other collections. Thus, the LazyRow will get recomposed. However, if you provide a key, I believe it will recognize the items of the list and reuse the already created MyItem composables. You could also use an ImmutableList from the kotlinx Immutable Collections package to prevent recomposition of the LazyList

danronmoon
  • 3,814
  • 5
  • 34
  • 56
Robin
  • 333
  • 1
  • 12