admin管理员组

文章数量:1390399

I'm working on a Jetpack Compose app where I fetch book data from a server using a BooksViewModel. The UI should update based on the state (Loading, Success, or Error). However, the UI only shows the loading state and does not transition to Success or Error. Even when I disable the internet, the UI does not show an error message or a snackbar.

Here’s my BookScreen composable:

@Composable
internal fun BookScreen(
    onBackPressed: () -> Unit,
    viewModel: BooksViewModel = koinViewModel()
) {

    val uiState by viewModel.booksState.collectAsStateWithLifecycle()
    val snackBarHostState = remember { SnackbarHostState() }

    BackHandler(onBack = onBackPressed)

    Scaffold(
        snackbarHost = { SnackbarHost(snackBarHostState) }
    ) { contentPadding ->
        Log.e("BookScreen", "BookScreen: $uiState")
        when (uiState) {
            is BooksUiState.Error -> {
                LaunchedEffect(key1 = uiState) {
                    Log.e("BookScreen", "BookScreen: Error")
                    snackBarHostState.showSnackbar("Unknown Error")
                }
            }

            is BooksUiState.Loading -> {
                Log.e("BookScreen", "BookScreen: Loading")
                LoadingOverlay(isLoading = (uiState as BooksUiState.Loading).isLoading) {}
            }

            is BooksUiState.Success -> {
                Log.e("BookScreen", "BookScreen: Success")
                BookContent(
                    books = (uiState as BooksUiState.Success).books,
                    contentPadding = contentPadding
                )
            }
        }
    }
}

@Composable
private fun BookContent(
    books: List<BookApiModel>,
    contentPadding: PaddingValues,
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 16.dp, vertical = 16.dp)
            .consumeWindowInsets(contentPadding)
            .imePadding(),
        contentPadding = contentPadding
    ) {
        items(items = books, key = { it.id }) { book ->
            Text(text = book.title, color = Color.White)
        }
    }
}

This is my BooksViewModel:

class BooksViewModel(private val getBooksUseCase: GetBooksUseCase) : ViewModel() {

    val booksState: StateFlow<BooksUiState> = getBooksFlow()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = BooksUiState.Loading(true)
        )

    private fun getBooksFlow(): Flow<BooksUiState> = flow {
        val result = getBooksUseCase()
        result.onSuccess { books ->
            Log.e("BooksViewModel", "getBooksFlow: $books")
            emit(BooksUiState.Success(books = books))
        }.onFailure { error ->
            Log.e("BooksViewModel", "getBooksFlow: $error")
            emit(BooksUiState.Error(error = error))
        }
    }.catch { error ->
        Log.e("BooksViewModel", "getBooksFlow: $error")
        // Handle any unexpected exception during the flow execution
        emit(BooksUiState.Error(error = error))
    }.onCompletion {
        Log.e("BooksViewModel", "getBooksFlow: onCompletion")
        // Ensure loading is set to false, even if cancelled or an error happens.
        emit(BooksUiState.Loading(isLoading = false))
    }

  
}

I have verified that:

  • The API is working fine.
  • The Success and Error states should be emitted.
  • Even disabling the internet does not trigger an error state.

Why is the UI not updating to Success or Error and only showing the loading state? What could be wrong with my state management, and how can I fix it?

UPDATE

  • Adding Log when api success

      2025-03-12 13:30:49.882 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=true)
      2025-03-12 13:30:49.882 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
      2025-03-12 13:30:54.081 14615-14615 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: [BookApiModel(id=100, title=Code Complete: A Practical Handbook of Software Construction, price=2954, currencyCode=EUR, author=Mike Riley), BookApiModel(id=200, title=The Pragmatic Programmer, price=3488, currencyCode=EUR, author=Andrew Hunt and Dave Thomas), BookApiModel(id=300, title=iOS Forensic Analysis, price=4604, currencyCode=EUR, author=Sean Morrisey), BookApiModel(id=400, title=Ghost in the Wires: My Adventures as the World's Most Wanted Hacker, price=1493, currencyCode=EUR, author=Kevin Mitnick), BookApiModel(id=500, title=Handling Unexpected Errors, price=1399, currencyCode=GBP, author=Charles R. Ash), BookApiModel(id=600, title=Android Application Development For Dummies, price=1979, currencyCode=USD, author=Donn Felker)]
      2025-03-12 13:30:54.082 14615-14615 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: onCompletion
      2025-03-12 13:30:54.088 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=false)
      2025-03-12 13:30:54.088 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
    
  • When Api failes

      2025-03-12 13:39:04.844 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=true)
      2025-03-12 13:39:04.844 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
      2025-03-12 13:39:04.938 15004-15004 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: java.UnknownHostException: Unable to resolve host "tpbookserver.herokuapp": No address associated with hostname
      2025-03-12 13:39:04.939 15004-15004 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: onCompletion
      2025-03-12 13:39:04.952 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=false)
      2025-03-12 13:39:04.952 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
    

I'm working on a Jetpack Compose app where I fetch book data from a server using a BooksViewModel. The UI should update based on the state (Loading, Success, or Error). However, the UI only shows the loading state and does not transition to Success or Error. Even when I disable the internet, the UI does not show an error message or a snackbar.

Here’s my BookScreen composable:

@Composable
internal fun BookScreen(
    onBackPressed: () -> Unit,
    viewModel: BooksViewModel = koinViewModel()
) {

    val uiState by viewModel.booksState.collectAsStateWithLifecycle()
    val snackBarHostState = remember { SnackbarHostState() }

    BackHandler(onBack = onBackPressed)

    Scaffold(
        snackbarHost = { SnackbarHost(snackBarHostState) }
    ) { contentPadding ->
        Log.e("BookScreen", "BookScreen: $uiState")
        when (uiState) {
            is BooksUiState.Error -> {
                LaunchedEffect(key1 = uiState) {
                    Log.e("BookScreen", "BookScreen: Error")
                    snackBarHostState.showSnackbar("Unknown Error")
                }
            }

            is BooksUiState.Loading -> {
                Log.e("BookScreen", "BookScreen: Loading")
                LoadingOverlay(isLoading = (uiState as BooksUiState.Loading).isLoading) {}
            }

            is BooksUiState.Success -> {
                Log.e("BookScreen", "BookScreen: Success")
                BookContent(
                    books = (uiState as BooksUiState.Success).books,
                    contentPadding = contentPadding
                )
            }
        }
    }
}

@Composable
private fun BookContent(
    books: List<BookApiModel>,
    contentPadding: PaddingValues,
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 16.dp, vertical = 16.dp)
            .consumeWindowInsets(contentPadding)
            .imePadding(),
        contentPadding = contentPadding
    ) {
        items(items = books, key = { it.id }) { book ->
            Text(text = book.title, color = Color.White)
        }
    }
}

This is my BooksViewModel:

class BooksViewModel(private val getBooksUseCase: GetBooksUseCase) : ViewModel() {

    val booksState: StateFlow<BooksUiState> = getBooksFlow()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = BooksUiState.Loading(true)
        )

    private fun getBooksFlow(): Flow<BooksUiState> = flow {
        val result = getBooksUseCase()
        result.onSuccess { books ->
            Log.e("BooksViewModel", "getBooksFlow: $books")
            emit(BooksUiState.Success(books = books))
        }.onFailure { error ->
            Log.e("BooksViewModel", "getBooksFlow: $error")
            emit(BooksUiState.Error(error = error))
        }
    }.catch { error ->
        Log.e("BooksViewModel", "getBooksFlow: $error")
        // Handle any unexpected exception during the flow execution
        emit(BooksUiState.Error(error = error))
    }.onCompletion {
        Log.e("BooksViewModel", "getBooksFlow: onCompletion")
        // Ensure loading is set to false, even if cancelled or an error happens.
        emit(BooksUiState.Loading(isLoading = false))
    }

  
}

I have verified that:

  • The API is working fine.
  • The Success and Error states should be emitted.
  • Even disabling the internet does not trigger an error state.

Why is the UI not updating to Success or Error and only showing the loading state? What could be wrong with my state management, and how can I fix it?

UPDATE

  • Adding Log when api success

      2025-03-12 13:30:49.882 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=true)
      2025-03-12 13:30:49.882 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
      2025-03-12 13:30:54.081 14615-14615 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: [BookApiModel(id=100, title=Code Complete: A Practical Handbook of Software Construction, price=2954, currencyCode=EUR, author=Mike Riley), BookApiModel(id=200, title=The Pragmatic Programmer, price=3488, currencyCode=EUR, author=Andrew Hunt and Dave Thomas), BookApiModel(id=300, title=iOS Forensic Analysis, price=4604, currencyCode=EUR, author=Sean Morrisey), BookApiModel(id=400, title=Ghost in the Wires: My Adventures as the World's Most Wanted Hacker, price=1493, currencyCode=EUR, author=Kevin Mitnick), BookApiModel(id=500, title=Handling Unexpected Errors, price=1399, currencyCode=GBP, author=Charles R. Ash), BookApiModel(id=600, title=Android Application Development For Dummies, price=1979, currencyCode=USD, author=Donn Felker)]
      2025-03-12 13:30:54.082 14615-14615 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: onCompletion
      2025-03-12 13:30:54.088 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=false)
      2025-03-12 13:30:54.088 14615-14615 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
    
  • When Api failes

      2025-03-12 13:39:04.844 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=true)
      2025-03-12 13:39:04.844 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
      2025-03-12 13:39:04.938 15004-15004 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: java.UnknownHostException: Unable to resolve host "tpbookserver.herokuapp": No address associated with hostname
      2025-03-12 13:39:04.939 15004-15004 BooksViewModel          com.vivek.bookapp                  E  getBooksFlow: onCompletion
      2025-03-12 13:39:04.952 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading(isLoading=false)
      2025-03-12 13:39:04.952 15004-15004 BookScreen              com.vivek.bookapp                  E  BookScreen: Loading
    
Share Improve this question edited Mar 12 at 13:38 Vivek Modi asked Mar 12 at 12:27 Vivek ModiVivek Modi 7,48320 gold badges103 silver badges232 bronze badges 2
  • Hi. Have you tried adding some logs to verify where the problem actually is? I mean, add logs at the lines where you have comments in getBooksFlow() and also in the Scaffold content. We could determine whether the issue is with emitting to the flow, converting it to state, or consuming it. – Dawid Czopek Commented Mar 12 at 13:18
  • Hey @DawidCzopek I added a logcat that shows my getBooksFlow function is working fine but ui doesn't works perfectly to show success or fail. – Vivek Modi Commented Mar 12 at 13:36
Add a comment  | 

1 Answer 1

Reset to default 1

In getBooksFlow() you use the flow builder to create a Flow. A Flow represents a series of values that are emitted over time.

You only emit a single value, either a BooksUiState.Success or a BooksUiState.Error, or, when an exceptions occurs, another BooksUiState.Error (emitted by the appended catch).

You then transform the flow to emit BooksUiState.Loading after it completes. A Flow completes when its last value was emitted/collected, so whatever the first value was (Success or Error), it is immediately followed by a Loading.

So, when getBooksFlow() is called it returns a Flow that always emits two values. You then convert that flow to a StateFlow (by calling stateIn). A StateFlow only saves the last value and throws away all previous values. After all, they are old and represent a state in the past, but the StateFlow is only interested in the current state. And the latest value emitted by the underlying flow is BooksUiState.Loading, so that's also what the value of booksState always will be.1

The culprit is identified now, onCompletion always immediately overwrites the expected value. A simple solution is to just remove the entire block since it is unclear what you even want to achieve with it. You commented the code block with this:

// Ensure loading is set to false, even if cancelled or an error happens.

But an error cannot happen (you already caught everything), and the flow can only get cancelled when the StateFlow decides to cancel its upstream flow because it isn't needed anymore (maybe because the user navigated to another screen). Well, when it isn't needed anymore you don't need to emit anything.


Although your initial problem should be fixed by removing the entire onCompletion block, this whole setup looks a bit strange.

For example, the GetBooksUseCase is only ever executed once when the view model is created, there is no way to execute it again, like when the user want's to refresh.

On the other hand, it actually will be automatically updated in some - likely unintended - corner cases: When the StateFlow isn't collected anymore for at least 5 seconds (f.e. when the user navigated to another screen or simply sent the app to the background), the upstream flow is stopped. When the collection is then resumed (f.e. the user navigated back to the screen or the app is in the foreground again) the StateFlow will restart the upstream flow, executing the entire flow builder's lambda again, including GetBooksUseCase. This behavior is configured by SharingStarted.WhileSubscribed(5000).

This doesn't seem like it is intended, so you might want to change this entire approach. The common solution is to decouple retrieving the data and displaying it in the UI by saving it in a database first. The UI will only read the data from the database, allowing you refresh the data by simply calling GetBooksUseCase and replacing the old data in the database with the new data. This also has the welcome side-effect that your app will also work when the user is offline, since you still have the data available from the last refresh. This paradigm is also called Offline First.


1 Since flows are asynchronous it may be that, for a fraction of a millisecond, the StateFlow will actually have the value Success or Error before it is immediately overwritten by Loading, but that will most likely not be enough for the Compose runtime to run a recomposition for the Success or Error case. Since it will immediately recompose again with Loading, you will, at best, only see a short flicker of the Success or Error case.

本文标签: androidUI State Not Updating Properly After Fetching Data in Jetpack ComposeStack Overflow