Compose sample app: UI state with Flow, offline first

June 25, 2022

In this post I will go over a simple app I’ve built in Jetpack Compose and Kotlin Flow on Android. Here is what it looks like:

For the impatient, you can get the source code at https://github.com/jshvarts/UiStatePlayground.

I took inspiration for parts of the code from the Now in Android repo. The starting point for the UI was what I previously built and wrote about in Practical Compose Slot API example. The app uses Dagger Hilt.

Here is a summary of what this post covers:

  • UI State using Flow

  • Offline First reactive repository using Flow

  • Unit tests for State and Flows

  • Some lessons in optimizing compositions

UI State

HomeViewModel defines three UI states modeling LCE (Loading, Content, Error) pattern, one for each Home section slot:

@Immutable
sealed interface TopRatedMoviesUiState {
  data class Success(val movies: List<Movie>) : TopRatedMoviesUiState
  object Error : TopRatedMoviesUiState
  object Loading : TopRatedMoviesUiState
}

@Immutable
sealed interface ActionMoviesUiState {
  data class Success(val movies: List<Movie>) : ActionMoviesUiState
  object Error : ActionMoviesUiState
  object Loading : ActionMoviesUiState
}

@Immutable
sealed interface AnimationMoviesUiState {
  data class Success(val movies: List<Movie>) : AnimationMoviesUiState
  object Error : AnimationMoviesUiState
  object Loading : AnimationMoviesUiState
}

It also keeps track of two more StateFlow streams: 1) isRefreshing to handle push to refresh state 2) isError to keep track of any error that can occur when pull to refresh fails to refresh any of the content sections.

While normally I would use SharedFlow for these one time events, I experimented with StateFlow this time in response to blog post ViewModel: One-off event antipatterns by Manuel Vivo. It worked out well, I think.

The Home screen state is represented by a wrapper data class:

data class HomeUiState(
  val topRatedMovies: TopRatedMoviesUiState,
  val actionMovies: ActionMoviesUiState,
  val animationMovies: AnimationMoviesUiState,
  val isRefreshing: Boolean,
  val isError: Boolean
)

This is how the Flows are defined:

private val topRatedMovies: Flow<Result<List<Movie>>> =
  movieRepository.getTopRatedMoviesStream().asResult()

private val actionMovies: Flow<Result<List<Movie>>> =
  movieRepository.getMoviesStream(MovieGenre.ACTION).asResult()

private val animationMovies: Flow<Result<List<Movie>>> =
  movieRepository.getMoviesStream(MovieGenre.ANIMATION).asResult()

private val isRefreshing = MutableStateFlow(false)

private val isError = MutableStateFlow(false)

The HomeUiState is produced in response to any of the Flows emitting a change:

val uiState: StateFlow<HomeUiState> = combine(
  topRatedMovies,
  actionMovies,
  animationMovies,
  isRefreshing,
  isError
) { topRatedResult, actionMoviesResult, animationMoviesResult, refreshing, errorOccurred ->

  val topRated: TopRatedMoviesUiState = when (topRatedResult) {
    is Result.Success -> TopRatedMoviesUiState.Success(topRatedResult.data)
    is Result.Loading -> TopRatedMoviesUiState.Loading
    is Result.Error -> TopRatedMoviesUiState.Error
  }

  val action: ActionMoviesUiState = when (actionMoviesResult) {
    is Result.Success -> ActionMoviesUiState.Success(actionMoviesResult.data)
    is Result.Loading -> ActionMoviesUiState.Loading
    is Result.Error -> ActionMoviesUiState.Error
  }

  val animation: AnimationMoviesUiState = when (animationMoviesResult) {
    is Result.Success -> AnimationMoviesUiState.Success(animationMoviesResult.data)
    is Result.Loading -> AnimationMoviesUiState.Loading
    is Result.Error -> AnimationMoviesUiState.Error
  }

  HomeUiState(
    topRated,
    action,
    animation,
    refreshing,
    errorOccurred
  )
}
  .stateIn(
    scope = viewModelScope,
    started = WhileUiSubscribed,
    initialValue = HomeUiState(
      TopRatedMoviesUiState.Loading,
      ActionMoviesUiState.Loading,
      AnimationMoviesUiState.Loading,
      isRefreshing = false,
      isError = false
    )
  )

This is what Result and asResult() extension functions are about:

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

sealed interface Result<out T> {
  data class Success<T>(val data: T) : Result<T>
  data class Error(val exception: Throwable? = null) : Result<Nothing>
  object Loading : Result<Nothing>
}

fun <T> Flow<T>.asResult(): Flow<Result<T>> {
  return this
    .map<T, Result<T>> {
      Result.Success(it)
    }
    .onStart { emit(Result.Loading) }
    .catch { emit(Result.Error(it)) }
}

A lot of this architecture is straight from https://github.com/android/nowinandroid. It looked good to me when I browsed that repo and after trying it out, I like it even more.

On the Compose UI side, reading state is as simple as:

val uiState: HomeUiState by homeViewModel.uiState.collectAsState()

The isError is handled in a LaunchedEffect handler. Once error has been seen by the user and either dismissed by a timeout or via action taken to dismiss, we tell the ViewModel to update the state with isError = false by calling homeViewModel.onErrorConsumed()

if (uiState.isError) {
    LaunchedEffect(scaffoldState.snackbarHostState) {
      scaffoldState.snackbarHostState.showSnackbar(
        message = errorMessage,
        actionLabel = okText
      )
      homeViewModel.onErrorConsumed()
    }
  }

The isRefreshing state is used by the SwipeRefresh composable from https://google.github.io/accompanist/swiperefresh/

SwipeRefresh(
  state = rememberSwipeRefreshState(uiState.isRefreshing),
  onRefresh = { homeViewModel.onRefresh() }
) {
  // home screen content here
}

And finally movie section states are processed by each Home section composible:

TopRatedMovieList(uiState.topRatedMovies)
ActionMovieList(uiState.actionMovies)
AnimationMovieList(uiState.animationMovies)

The genre screens displaying Action and Animation movies respectively is produced by the following ViewModel. It’s just an alternative reactive implementation and it could have easily used the same pattern that HomeViewModel uses.

sealed interface GenreUiState {
  data class Success(val movies: List<Movie>) : GenreUiState
  object Error : GenreUiState
  object Loading : GenreUiState
}

data class GenreScreenUiState(
  val genreState: GenreUiState
)

@HiltViewModel
class GenreViewModel @Inject constructor(
  private val movieRepository: MovieRepository
) : ViewModel() {

  private val _uiState = MutableStateFlow(GenreScreenUiState(GenreUiState.Loading))
  val uiState = _uiState.asStateFlow()

  fun fetchMovies(genre: MovieGenre) {
    viewModelScope.launch {
      movieRepository.getMoviesStream(genre).asResult()
        .collect { result ->
          val genreUiState = when (result) {
            is Result.Success -> GenreUiState.Success(result.data)
            is Result.Loading -> GenreUiState.Loading
            is Result.Error -> GenreUiState.Error
          }

          _uiState.value = GenreScreenUiState(genreUiState)
        }
    }
  }
}

Offline First repository

Room database is used a single source of truth.

  1. Whenever Home screen is loaded, we emit Flows with a list of movies for each section (top rated, action movies, animation movies)

  2. If any of the sections contain no data locally, we ask for data from remote endpoint and update local persistence with it.

  3. Once data from remote is updated locally, Flow is emitted from our DAO and our ViewModel emits new state to which UI responds by performing composition and later recomposition.

  4. Another use case when we pull data from remote is when user pulls to refresh. Again the data from remote gets persisted into local persistence and then flows to UI from it.

The data always flows from the local persistence (in this case, Room database).

For simplicity, this sample does not handle pagination or has a retry policy (see example at Retrying network requests with Flow)

Also note that we don’t pass a Coroutine Dispatcher into any repository functions since both Room and Retrofit libraries by default perform suspend-able work on IO Dispatcher. A purist may design a repository interface to include a Dispatcher to account for future implementations that may not have this feature.

Here is our repository:

interface MovieRepository {
  fun getTopRatedMoviesStream(): Flow<List<Movie>>
  fun getMoviesStream(genre: MovieGenre): Flow<List<Movie>>
  suspend fun refreshTopRated()
  suspend fun refreshGenre(genre: MovieGenre)
}

Which is implemented by OfflineFirstMovieRepository and TestMovieRepository.

class OfflineFirstMovieRepository @Inject constructor(
  private val dao: MovieDao,
  private val api: Api
) : MovieRepository {
  // implementation
}

Here is a function emitting top rated movies stream:

override fun getTopRatedMoviesStream(): Flow<List<Movie>> {
  return dao.getTopRatedMoviesStream().map { entityMovies ->
    entityMovies.map(MovieEntity::asExternalModel)
  }.onEach {
    if (it.isEmpty()) {
      refreshTopRated()
    }
  }
}

If the DAO contains no top rated movies, we call refreshTopRated(). Note that this function returns Unit and updates local persistence with fresh data from remote. For the purpose of this demo, the data from remote is simply shuffled to demonstrate state changes.

override suspend fun refreshTopRated() {
  api.getTopRated()
    .shuffled()
    .also { externalMovies ->
      dao.deleteAndInsert(movies = externalMovies.map(Movie::asEntity))
    }
}

Similar set of functions is used to load movies for a given genre.

override fun getMoviesStream(genre: MovieGenre): Flow<List<Movie>> {
  return dao.getGenreMoviesStream(genre.id).map { entityMovies ->
    entityMovies.map(MovieEntity::asExternalModel)
  }.onEach {
    if (it.isEmpty()) {
      refreshGenre(genre)
    }
  }
}

override suspend fun refreshGenre(genre: MovieGenre) {
  api.getMoviesForGenre(genre.id)
    .shuffled()
    .also { externalMovies ->
      dao.deleteAndInsert(
        genre.id, externalMovies.map { it.asEntity(genreId = genre.id) }
      )
    }
}

And for reference, here is MovieDao where movies are provided as Flow

@Dao
interface MovieDao {
  @Query(value = "SELECT * FROM movie WHERE genreId is null")
  fun getTopRatedMoviesStream(): Flow<List<MovieEntity>>

  @Query(value = "SELECT * FROM movie WHERE genreId = :genreId")
  fun getGenreMoviesStream(genreId: String): Flow<List<MovieEntity>>

  @Insert(onConflict = OnConflictStrategy.IGNORE)
  suspend fun insertOrIgnoreMovies(movies: List<MovieEntity>): List<Long>

  @Transaction
  suspend fun deleteAndInsert(genreId: String? = null, movies: List<MovieEntity>) {
    if (genreId != null) {
      deleteMoviesForGenre(genreId)
    } else {
      deleteMovies()
    }
    insertOrIgnoreMovies(movies)
  }
}

As you can see, for this sample, the data model is greatly simplified:

  1. Only one genre ID is set up per movie while TMDB provides multiple genres per movie

  2. Top rated movies are designated by a null genre id

  3. In a real app, I would utilize relation database capabilities to set up relationships between movies, their genres, top ratings.

Unit tests

Similar to Now in Android, we use Cash App’s Turbine to unit test code with Flow. Turbine is a small testing library for kotlinx.coroutines Flow

Here is a sample unit test. I really like the simplicity and readability Turbine provides.

@Test
fun `when uiHomeState is initialized then shows correct state`() = runTest {
  viewModel.uiState.test {
    val initialState = awaitItem()
    assertEquals(TopRatedMoviesUiState.Loading, initialState.topRatedMovies)
    assertEquals(ActionMoviesUiState.Loading, initialState.actionMovies)
    assertEquals(AnimationMoviesUiState.Loading, initialState.animationMovies)
    assertFalse(initialState.isRefreshing)
    assertFalse(initialState.isError)
  }
}

And another one:

@Test
fun `when movie ui states emit success then home uiState emits success for each`() =
  runTest {
    viewModel.uiState.test {
      moviesRepository.sendTopRatedMovies(testInputTopRatedMovies)
      moviesRepository.sendActionMovies(testInputActionMovies)
      moviesRepository.sendAnimationMovies(testInputAnimationMovies)

      // skip loading state
      awaitItem()

      val uiState = awaitItem()
      assertTrue(uiState.topRatedMovies is TopRatedMoviesUiState.Success)
      assertTrue(uiState.actionMovies is ActionMoviesUiState.Success)
      assertTrue(uiState.animationMovies is AnimationMoviesUiState.Success)
    }
  }
}

Optimizing recompositions

I have not spent too much debugging recompositions yet, but one thing I wanted to confirm is that emitting isError = true state during pull to refresh, should not recompose all the movie sections. The movie data was not re-emitted when device is offline and pull to refresh fails. So those grids with movie posters should not recompose.

When I tried debugging it though, I did see those sections being recomposed. It was time to re-read the excellent Composable metrics blog post by Chris Banes. It believe that my movie states did not satisfy smart recomposition because they use a List of movies.

sealed interface TopRatedMoviesUiState {
  data class Success(val movies: List<Movie>) : TopRatedMoviesUiState
  object Error : TopRatedMoviesUiState
  object Loading : TopRatedMoviesUiState
}
A List is not inferred as immutable because, for example, mutableListOf() is also a List. List restricts the receiver from modifying the list (e.g. it is read-only) but does not imply the list is immutable as it can change at any time through a MutableList typed reference.

Marking this and other movie UI states as @Immutable (these states are legitimately immutable) did the trick. We could have used @Stable instead to have the same effect.

@Immutable
sealed interface TopRatedMoviesUiState {
  data class Success(val movies: List<Movie>) : TopRatedMoviesUiState
  object Error : TopRatedMoviesUiState
  object Loading : TopRatedMoviesUiState
}

See the full source code at https://github.com/jshvarts/UiStatePlayground

Previous
Previous

Animation in Jetpack Compose

Next
Next

Test Coroutine Scheduler