0

Link to my code: https://github.com/tylerwilbanks/word-game-android

After around maybe 10 seconds after startup, if you tap keyboard buttons quickly, suddenly the app performance will tank with 5-10 second frame processes.

The issue does not seem to be excessive re-composition, 1 composition will suddenly take a very long time whereas others just before it were very quick.

Once the slow frame begins, the application remains in this state until relaunch.

Any insights would be greatly appreciated.

MainActivity:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        GuessWordValidator.initValidWords(this)
        viewModel.setupGame()
        setContent {
            WordGameTheme {
                DailyWordScreen(
                    state = viewModel.state.collectAsStateWithLifecycle(),
                    onEvent = viewModel::onEvent
                )
            }
        }
    }
}

ViewModel:

class DailyWordViewModel(application: Application) : AndroidViewModel(application) {

    private val _state = MutableStateFlow(DailyWordState())
    private val context = getApplication<Application>().applicationContext

    val state
        get() = _state.stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000L),
            DailyWordState()
        )
...
}

ViewModel.onEvent():

fun onEvent(event: DailyWordEvent) {
        when (event) {
            is DailyWordEvent.OnCharacterPress -> {
                val currentGuess = _state.value.currentGuess
                currentGuess?.getLetterForInput?.let { guessLetter ->
                    guessLetter.updateCharacter(event.character)
                    _state.update {
                        it.copy(
                            currentGuess = currentGuess,
                            currentWord = currentGuess.displayWord
                        )
                    }
                }
            }

            DailyWordEvent.OnDeletePress -> {
                val currentGuess = _state.value.currentGuess
                currentGuess?.getLetterToErase?.let { guessLetter ->
                    guessLetter.updateCharacter(' ')
                    _state.update {
                        it.copy(
                            currentGuess = currentGuess,
                            currentWord = currentGuess.displayWord
                        )
                    }
                }
            }
...

The profiler shows the very long frames but no indicator as to why. Memory usage stays level.

I've tried updating the state variable in the viewModel directly without using coroutines, no avail. I also tried explicitly using viewModel.launch(Dispatchers.IO) {} to make sure I'm not holding up the main thread with too many allocated jobs.

The only thing I can think that is causing the issue is .collectAsStateWithLifecycle in the composable is getting backed up with all the inputs and gets stuck.

I really don't know how else to send state down into the composable if collecting the StateFlow causes issues with input.

Getting this in my logcat: Skipped 741 frames! The application may be doing too much work on its main thread.

Edit 1: There is no way that compose is getting bogged down by collecting state. I've noticed that I don't even have to spam the keyboard anymore, it will begin skipping frames after waiting around 10 seconds after application launch, and then typing a few letters at a normal pace.

Edit 2: I've noticed in the profiler that everytime i type a letter, the app's memory consumption goes up appx. 1.2 mb and never goes back down.

Edit 3: Thanks to Tenfour04, this issue has been solved! The reason for this was that my DailyWordState object I was sending down into my composables was Mutable. I changed this to contain a list Immutable domain objects. My DailyWordState was still flagged as Mutable because List<Any> is considered mutable because you can convert a MutableList to a List. I remedied this by adding @Immutable annotation to my DailyWordState object. This works great now, however now my task is to learn how to structure my data without the need for the @Immutable annotation.

1 Answers1

1

I see a potential culprit here:

val state
    get() = _state.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000L),
        DailyWordState()
    )

Two things about this that don't make sense:

  • Using stateIn in a custom getter, so you are creating a new StateFlow every time state is accessed, and each of these is subscribed to _state. So this is proliferating many StateFlows that are doing duplicate work. Aside from all the unnecessary work that this is creating, it's possible you're triggering some kind of infinite recomposition loop because the state property is not stable but you're trying to collect it in your composable.

  • Using stateIn on a StateFlow. _state is already a StateFlow. This just creates another StateFlow that duplicates everything the source StateFlow is doing for no reason, and requiring SharingStarted and default values that might contradict what the source has, which could lead to weird bugs.

You should change it to:

val state = _state.asStateFlow()

This wraps the _state in a read-only version, and since there's no custom getter, it does it only once. This is lighter weight than stateIn since it just passes through collection. Compose should recognize also that the property is stable, so it won't recompose unnecessarily just from seeing the property being accessed.


To summarize what was found in the comments below, the model classes and the keyboard description 2D List were also (or maybe primarily) causing the issue because they were not stable or immutable.

Tenfour04
  • 83,111
  • 11
  • 94
  • 154
  • Thank you for the insight! I've made this change and I'm still running into the same issue. I must have something inefficient somewhere else. Maybe my composables are doing too much processing? – Tyler Wilbanks Aug 10 '23 at 15:10
  • You should read [this part of the Compose documentation](https://developer.android.com/jetpack/compose/performance/stability). Your domain classes are not stable or immutable, so Compose has to recompose almost everything every time anything at all changes. Lists aren't stable either. You're using them all over so you might want to use the immutable collections library. – Tenfour04 Aug 10 '23 at 15:34
  • Thank you! I just pushed up my code to use immutable objects in my state object. Still getting the same slowdown/freeze issue. Not sure what I am doing wrong still. – Tyler Wilbanks Aug 10 '23 at 18:32
  • You still need to annotate the `DailyWordState` class with `@Immutable` because it has Lists in it. Compose cannot tell if read-only Lists are truly immutable, so you have to promise it that they really are not going to have their contents change, which would be possible if they are upcasted MutableLists. Also `keyboardRows` is a List of Lists, so that is also unstable. You may need to wrap it in a class that is marked `@Immutable` to prevent your whole keyboard from being recomposed every time anything changes. Not sure if you can put it into an `object` to simplify this. – Tenfour04 Aug 10 '23 at 18:47
  • However, I'm kind of stumped because I don't think the recompositions that would occur from unstable classes could cause seconds-long recompositions. – Tenfour04 Aug 10 '23 at 18:48
  • Yes! This was the issue. I added the `@Immutable` annotation to my `DailyWordState`, but the issue would still occur, but took longer to trigger than before. Finally I put my `keyboardRows` into a `data class` and added the `@Immutable` annotation, and now everything runs smoothly! Thank you so much! Now my goal is to learn how I can structure my data to avoid using the `@Immutable` annotation. – Tyler Wilbanks Aug 10 '23 at 20:31
  • They suggest in that link from my comment above to use the kotlinx immutable collections library for ImmutableList. Compose specifically recognizes that interface. – Tenfour04 Aug 10 '23 at 20:35
  • I shall use that! Thank you so much! – Tyler Wilbanks Aug 10 '23 at 20:42