Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

java.lang.IllegalStateException when using State in Android Jetpack Compose

I have ViewModel with Kotlin sealed class to provide different states for UI. Also, I use androidx.compose.runtime.State object to notify UI about changes in state.

If error on MyApi request occurs, I put UIState.Failure to MutableState object and then I get IllegalStateException:

 java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
        at androidx.compose.runtime.snapshots.SnapshotKt.readError(Snapshot.kt:1524)
        at androidx.compose.runtime.snapshots.SnapshotKt.current(Snapshot.kt:1764)
        at androidx.compose.runtime.SnapshotMutableStateImpl.setValue(SnapshotState.kt:797)
        at com.vladuken.compose.ui.category.CategoryListViewModel$1.invokeSuspend(CategoryListViewModel.kt:39)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

ViemModel code:

@HiltViewModel
class CategoryListViewModel @Inject constructor(
    private val api: MyApi
) : ViewModel() {

    sealed class UIState {
        object Loading : UIState()
        data class Success(val categoryList: List<Category>) : UIState()
        object Error : UIState()
    }

    val categoryListState: State<UIState>
        get() = _categoryListState
    private val _categoryListState =
        mutableStateOf<UIState>(UIState.Loading)

    init {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                //this does not work
                _categoryListState.value = UIState.Error
            }
        }
    }

}

I tried to delay setting UIState.Error - and it worked, but I don't think it is normal solution:

viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                //This works 
                delay(10)
                _categoryListState.value = UIState.Error
            }
        }

I observe State object in Composable function as follows:

@Composable
fun CategoryScreen(
    viewModel: CategoryListViewModel,
    onCategoryClicked: (Category) -> Unit
) {
    when (val uiState = viewModel.categoryListState.value) {
        is CategoryListViewModel.UIState.Error -> CategoryError()
        is CategoryListViewModel.UIState.Loading -> CategoryLoading()
        is CategoryListViewModel.UIState.Success -> CategoryList(
            categories = uiState.categoryList,
            onCategoryClicked
        )
    }
}

Compose Version : 1.0.0-beta03

How to process sealed class UIState with Compose State so that it doesn't throw IllegalStateException?

like image 224
Vladuken Avatar asked Mar 31 '21 16:03

Vladuken


Video Answer


3 Answers

So, after more attempts of fixing this issue I found a solution. With help of https://stackoverflow.com/a/66892156/13101450 answer I've get that snapshots are transactional and run on ui thread - changing dispatcher helped:

viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    _categoryListState.value = UIState.Error
                }
            }
        }
like image 132
Vladuken Avatar answered Oct 17 '22 13:10

Vladuken


There's a discussion about what looks like somewhat similar issue in https://kotlinlang.slack.com/archives/CJLTWPH7S/p1613581738163700.

Some relevant parts of that discussion I think (from Adam Powell)

As for the thread-safety aspects of snapshot state, what you've encountered is the result of snapshots being transactional.

When a snapshot is taken (and composition does this for you under the hood) the currently active snapshot is thread-local. Everything that happens in composition is part of this transaction, and that transaction hasn't committed yet.

So when you create a new mutableStateOf in composition and then pass it to another thread, as the GlobalScope.launch in the problem snippet does, you've essentially let a reference to snapshot state that doesn't exist yet escape from the transaction.

The exact scenario is a little different here but I think same key issue. Probably wouldn't do it exactly this way but at least here it worked by moving contents of init in to new getCategories() method which is then called from LaunchedEffect block. FWIW what I've done elsewhere in case like this (while still invoking in init) is using StateFlow in view model and then call collectAsState() in Compose code.

@Composable
fun CategoryScreen(
    viewModel: CategoryListViewModel,
    onCategoryClicked: (Category) -> Unit
) {
    LaunchedEffect(true) {
        viewModel.getCategories()
    }

    when (val uiState = viewModel.categoryListState.value) {
        is CategoryListViewModel.UIState.Error -> CategoryError()
        is CategoryListViewModel.UIState.Loading -> CategoryLoading()
        is CategoryListViewModel.UIState.Success -> CategoryList(
            categories = uiState.categoryList,
            onCategoryClicked
        )
    }
}
like image 11
John O'Reilly Avatar answered Oct 17 '22 15:10

John O'Reilly


Three ways this can be resolved are

    1. To call the method in a launched effect block in your composable
    1. Or set the Context to Dispatchers.Main when setting value of the mutableState using withContext(Dispatchers.Main)
    1. Or change the mutable state in viewModel to mutableState flow and use collectAsState() in composable to collect it as a state.
like image 4
nweke_onyekachukwu Avatar answered Oct 17 '22 13:10

nweke_onyekachukwu