Debugging and fixing a *huge* Jetpack Compose performance problem in my Sudoku-solver app.
A small adjustment and a bit of extra knowledge made a night-vs-day performance difference.
In my “spare time” I’m doing an MSc in Artificial Intelligence. One of the more fun bits of coursework that I resent having not been able to spend enough time on was creating a Sudoku Solver in Python. Determined there were various things I could do to make it more efficient and cleverer, I thought I’d put it into an Android app, stretching my Jetpack Compose muscles in the process.
Without going into too much detail about the solver itself, my Python implementation kept a list of “possible values” for each cell, and every time a new value was entered into the Sudoku grid, a set of rules were followed to reduce (as much as possible) the remaining possible values throughout the entire grid as efficiently as I could manage. I represented these as a grid of 9 text views for each cell (yes, there is almost certainly a better custom way to do this, but remember, progress over perfection!).
Here, for example, is the number 1 being inserted into the grid and the remaining values for other cells being updated in response (i.e. 1 being removed from other cells in the same 3x3 square, the same column and the same row)…
After watching Aida Issayeva’s excellent talk at Droidcon New York, giving examples as to common pitfalls and how to debug, I thought I’d try Android Studio Electric Eel’s new feature that shows recomposition counts next to composables in the Layout Inspector.
And that’s when I realised that every single Text composable for remaining values (all 729 of them!) were recomposing not just when a new value was inserted, but even when I clicked on a cell and it highlighted to the purple selected-state you see above.
So why did this happen?
Stable vs unstable. Why a composable always recomposes.
I really recommend watching Aida’s talk when it becomes available online, but nevertheless, here’s how I worked out the problem.
First of all, if a composable is recomposing more than you think it should (and it is important to be careful with this), the obvious place to check is the list of parameters in the function.
In the above, we have one parameter. A List of integers — and it’s immutable, right? (No, it isn’t, but we’ll get to that later). So with a very basic understanding of Jetpack Compose, one would expect recomposition to take place only if the value changes, right?
Wrong. Compose only skips recompositions that it is absolutely certain it needs not to recompose, which is what you’d expect. And in order to determine those that can be skipped, the compiler checks to see whether the list of parameters are all those that have been marked as stable or unstable. I won’t go into too much detail here, except to say that if a parameter is marked as stable, Compose is absolutely convinced it will be informed if that value is changed and can skip recomposition with a degree of certainty.
Unstable parameters will include things like vars (if it is mutable, then it can be changed anywhere, and Compose won’t necessarily know about it, so much safer to recompose every single time to be sure).
And here’s where the surprise comes in.
Just because you have “MutableList”, doesn’t mean that “List” is immutable, silly.
List is not immutable, it is read-only. This can be shown by an example you’ll almost certainly be aware of, but have probably forgotten (like I did). In your ViewModel you’ll have things like this…
The read-only List will change if the underlying mutable list changes. Therefore it is not immutable. It is read-only because you are prevented from directly accessing write stuff on a List variable, but you cannot be sure that the values in this List variable will not change. Compose can’t be either, so it decides it’s just going to recompose any composable that takes a list (or a Set, or a Map).
Making Lists stable
There are ways of overriding stability through annotations, but that leaves an icky feeling in my stomach.
In fact, given that Lists, Sets and Maps *always* result in recomposition, it’s my personal view that they should give a compiler warning. (NB: I haven’t yet thought of a use-case where you’d absolutely want a composable to recompose every single time with lists, but if I’m wrong, please educate me in the comments)
But nevertheless, here’s how I fixed it. There’s a library called kotlinx-collections-immutable that allows you to specify explicitly that a Composable should accept an immutable list. This changes our composable function signature to…
Want to know the worst thing about this?
I was being a good Compose Boy (largely)… splitting things into trees of reusable components. But because this Composable was right at the end of the branch and always needed to recompose, it meant all its parent Composables needed to recompose too.
Essentially, the entire Sudoku grid was being redrawn every time the user selected a cell.
So, TLDR:
- Use the recomposition feature in Android Studio EE to see just how much your Composables are recomposing.
- If it’s recomposing every time, look for unstable parameters, like vars and Lists/Sets/Maps.
- Use this library and *never* let your Composable accept a mutable list again.
And this is how a small adjustment to my code and a bit of extra recomposition knowledge changed my Sudoku composable from being an unperformant nightmare that recomposed every time to actually being OK.
Any comments, thoughts, or whatever, I’d love to hear them below. :)