4

I'm new with Compose and declarative programming, and I'm trying to understand it. For learning, after reading tutorials and watching courses, now I'm creating my first app.

I'm creating a compose desktop application with compose multiplatform which will give you the possibility to select a folder from the computer and display all the files inside that folder. I'm launching a JFileChooser for selecting a folder. When it's selected, a state var is changed and a Box is filled with Texts representing the names of the files inside that folder. These names are obtained by a function which uses the path returned by JFileChooser.

The app has a two strange behaviours. First because that screen has a TextField and if I write inside it, the Box filled with texts seems to be repainted calling again the function which search for the files (and those can be thousands slowing the app).

The second strange behaviour is that if I open again the JFileChooser to change the folder, it repaints correctly the Box getting the file names of that folder, but if I select the same folder selected previously, the Box is not repainted, and if a file is changed in that folder, it is a problem.

I think both issues are related with declarative compose logic - what might be wrong in each case?

This is the button that displays the JFileChooser:

var listRomsState by remember { mutableStateOf(false) }
Button(onClick = {
    folderChosenPath = folderChooser()
    if (folderChosenPath != "")
        listRomsState = true
}) {
    Text(text = "List roms")
}

This is the function that shows the JFileChooser

fun folderChooser(): String {
    UIManager.setLookAndFeel("com.sun.java.swing.plaf.windows.WindowsLookAndFeel");
    val f = JFileChooser()
    f.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY

    val result: Int = f.showSaveDialog(null)
    if (result == JFileChooser.APPROVE_OPTION) {
        return f.selectedFile.path
    } else {
        return ""
    }
}

Below the button that displays the file chooser is the list with the file names:

if (listRomsState) {
    RomsList(File(folderChosenPath))
}

This is the RomsList function:

@Composable
fun RomsList(folder: File) {
    Box (
        modifier = Modifier.fillMaxSize().border(1.dp, Color.LightGray)
    ) {
        LazyColumn(
            Modifier.fillMaxSize().padding(top = 5.dp, end = 8.dp)
        ){
            var romsList = getRomsFromFolder(folder)
            items(romsList.size) {
                Box (
                    modifier = Modifier.padding(5.dp, 0.dp, 5.dp, 0.dp).fillMaxWidth(),
                    contentAlignment = Alignment.CenterStart
                ) {
                    Row (horizontalArrangement = Arrangement.spacedBy(5.dp)){
                        Text(text = "" + (it+1), modifier = Modifier.weight(0.6f).background(color = Color(0, 0, 0, 20)))
                        Text(text = romsList[it].title, modifier = Modifier.weight(9.4f).background(color = Color(0, 0, 0, 20)))
                    }
                }
                Spacer(modifier = Modifier.height(5.dp))
            }
        }
    }
}

This is the function that recursively gets all the file names of a folder:

fun getRomsFromFolder(curDir: File? = File(".")): MutableList<Rom> {
    var romsList = mutableListOf<Rom>()

    val filesList = curDir?.listFiles()
    filesList?.let {
        for (f in filesList) {
            if (f.isDirectory) romsList.addAll(getRomsFromFolder(f))
            if (f.isFile) {
                romsList.add(Rom(f.name))
            }
        }
    }

    return romsList
}
halfer
  • 19,824
  • 17
  • 99
  • 186
NullPointerException
  • 36,107
  • 79
  • 222
  • 382
  • 1
    In your first issue, when you said "Box filled with texts" are you referring to the composable `RomsList(file: File) {…}` ? – z.g.y Dec 07 '22 at 10:57
  • 2
    I posted an answer, however it is a bit hard for me to tell how to fix first issue since I am not sure which composable represents the `TextField` – Steyrix Dec 07 '22 at 10:58
  • 1
    Yep, the OP's problem is a bit complex since it involves some native Java UI and kmm, but I also thought the same with his second issue – z.g.y Dec 07 '22 at 11:03
  • 1
    @z.y yes, exactly, RomsList is that Box. Also Steyrix the textfield is not related, it simply helped me to discover that if something is changed everywere in the screen, the logic that search for files in the folders gets called again. How can I avoid that to happen? It's a non desired behaviour. It slows the application a lot with no utility, and forzes the hard drive to search for files when is not necessary. – NullPointerException Dec 07 '22 at 11:05
  • 1
    Mind if I ask to include `listRomsState` what is it, how and where it is declared?, I'm so intrigued with your issue, but I can only provide limited thoughts since this is KMM + some Java native UI.. but we'll try – z.g.y Dec 07 '22 at 11:08
  • 1
    Thank you, already saw your changes, however it might take a bit or maybe not for the first issue, but I think I can provide an answer to your second issue.. – z.g.y Dec 07 '22 at 11:11
  • @z.y listRomsState is simply a remember var in the same composable function that has the button and the Box with the file names. It is used to know when a folder has been selected. I added the line to my sample code. – NullPointerException Dec 07 '22 at 11:12
  • 2
    Yep, its weird why RomList is being recomposed when you said when your'e typing in a TextField which is also part of the same composable scope.. do you have a github of this? or maybe a shorter version of all of this? – z.g.y Dec 07 '22 at 11:14
  • @z.y not at this moment but I can tell you why is being recomposed. I think it's because the textfiled is in the same composable. It's just above the button and the Box. So I understand that when it is used, it tells the composable to repaints. Then it makes the function that search for files to search for files again. Doing a lot of unusable job. If for example you write a word with 5 characters, it will search 5 times for the same files. How can that be avoided? – NullPointerException Dec 07 '22 at 11:18
  • 1
    Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/250223/discussion-between-z-y-and-nullpointerexception). – z.g.y Dec 07 '22 at 11:19
  • 1
    I also provided explanation how to avoid this in my original answer – Steyrix Dec 07 '22 at 11:20

3 Answers3

3

The important mechanism you need to get used to is recomposition. I am not sure how it works in Compose Multiplatform, but in android recompositions depend on state changes. When composable function contains some kind of state, it automatically listens for it changes and gets recomposed on mutations.

During recomposition your UI elements of a composable, which is being recomposed, get drawn again with new values to represent actual states.

So, explaining your strange behaviours:

The app has a two strange behaviours. First because that screen has a TextField and if I write inside it, the Box filled with texts seems to be repainted calling again the function which search for the files (and those can be thousands slowing the app).

This happens because you are changing the state - the text value of a text field. So the recomposition happens. The solution is to move all logic, that does need to get called again to separate composable. The explanation is present here

The second strange behaviour is that if I open again the JFileChooser to change the folder, it repaints correctly the Box getting the file names of that folder, but if I select the same folder selected previously, the Box is not repainted, and if a file is changed in that folder, it is a problem.

This is the case, when recomposition is needed but does not happen. This happens because the composable RomsList does not contains folder state and therefore does not recompose automatically on folder change.

You probably should not pass folder as a simple parameter. You should remember it as a state.

val folderState by remember { mutableStateOf(folder) }

However, since your folder comes to the composable from another function, one of the solutions is to create such state in the caller function and mark the function as @Composable. Recompositions are able to go downwards, so all nested composables of a composable will be recomposed on latter's recompositions.

Steyrix
  • 2,796
  • 1
  • 9
  • 23
  • Hi Steyrix, about the first issue, everytime something is touched in the screen, the logic that search for files in the folders gets called again. How can I avoid that to happen? It's a non desired behaviour. It slows the application a lot with no utility, and forzes the hard drive to search for files when is not necessary. About the second issue.. I'm really trying to understand you, but it is being very hard to do it. – NullPointerException Dec 07 '22 at 11:15
  • 1
    @NullPointerException does it happen even if something not clickable is touched? – Steyrix Dec 07 '22 at 11:18
  • 2
    "I am not sure how it works in Compose Multiplatform, but in android recompositions depend on state changes" works exactly the same, it's the same compose compiler and runtime under the hood :) – Jakoss Dec 07 '22 at 11:39
  • 1
    @Jakoss yeah, should have assumed something like this. Thank you :) – Steyrix Dec 07 '22 at 11:41
3

I created a very simple composable that's identical to your compose structure based on our discussion.

Consider this code:

@Composable
fun MyTvScreen() {

    Log.e("MyComposableSample", "MyTvScreen Recomposed")

    var fileName by remember {
        mutableStateOf("")
    }
    val someFile = File("")


    Column {

        TextField(
            value = fileName,
            onValueChange = {
                fileName = it
            }
        )

        RomsList(file = someFile)
    }
}

@Composable
fun RomsList(file: File) {
    Log.e("MyComposableSample", "RomsList Recomposed $file")
}

when you run this, and when you typed anything in the TextField, both composable will re-compose and produces this log output when you type something on the textfield

E/MyComposableSample: MyTvScreen Recomposed   // initial composition 
E/MyComposableSample: RomsList Recomposed     // initial composition

// succeeding re-compositions when you typed something in the TextField
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: RomsList Recomposed 
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: RomsList Recomposed

From this article I run a command and found that java.io.File is not a stable type. And your RomsList composable is not skippable, meaning, everytime the parent composable re-composes it will also re-compose RomsList

restartable fun RomsList( 
    unstable file: File 
)

Now that we know File is not a @Stable type and we have no control over its API, I wrapped it in a custom data class like this, and modified the call-sites

@Stable
data class FileWrapper(
    val file: File
)

So modifying all the codes above using FileWrapper.

@Composable
fun MyTvScreen() {

    ...
    val someFile = FileWrapper(File(""))

    Column {

        TextField(
           ...
        )

        RomsList(fileWrapper = someFile)
    }
}

@Composable
fun RomsList(fileWrapper: FileWrapper) {
    Log.e("MyComposableSample", "RomsList Recomposed ${fileWrapper.file}")
}

Produces the log output below.

E/MyComposableSample: MyTvScreen Recomposed // initial composition
E/MyComposableSample: RomsList Recomposed   // initial composition

// succeeding re-compositions when you typed something in the TextField
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed
E/MyComposableSample: MyTvScreen Recomposed

And the running the gradle command, the report was this, RomsList is now skippable with a stable parameter, so when its parent composable recomposes RomsList will get skipped.

restartable skippable fun RomsList( 
  stable fileWrapper: FileWrapper 
)

For your second issue, would you mind trying to replace the mutableList withmutableStateList()? which creates an instance of a SnapshotStateList?, this way any changes to the list will guarantee an update to a composable that reads it

fun getRomsFromFolder(curDir: File? = File(".")): MutableList<Rom> {
    var romsList = mutableStateListOf<Rom>() // here
...
z.g.y
  • 5,512
  • 4
  • 10
  • 36
0

Finally I disscovered some things with the help of Steyrix, z.y and some other guys.

First, as noted here, https://developer.android.com/jetpack/compose/mental-model#recomposition composable functions only execute their code if their parameters have been changed, so that is the cause of issue 2.

Also, the main problem of both issues is that I was executing the logic that retrieves all the files inside a folder in a wrong place. It was being executed in a composable function, and that's a problem, because it will be executed each time is recomposed. So moving the logic to the onclick, just after the result of the file chooser has been received solved both issues.

Also, now I understand much more things.

Thank you guys!

NullPointerException
  • 36,107
  • 79
  • 222
  • 382