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 Flow
s 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.
Whenever Home screen is loaded, we emit
Flow
s with a list of movies for each section (top rated, action movies, animation movies)If any of the sections contain no data locally, we ask for data from remote endpoint and update local persistence with it.
Once data from remote is updated locally,
Flow
is emitted from our DAO and ourViewModel
emits new state to which UI responds by performing composition and later recomposition.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:
Only one genre ID is set up per movie while TMDB provides multiple genres per movie
Top rated movies are designated by a null genre id
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 }
According to a comment in https://issuetracker.google.com/issues/199496149:
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