This is a series of articles about how to architecture your app that it was inspired by Google Guide to App Architecture and my personal experience.
Today we finally explore the Presentation layer of our architecture. It contains all UI-related logic and everything the user can see and interact with. This layer is also responsible for interpreting application data to the user-readable form and vice versa, transforming user interaction to the data changes in the app.
In this guide, I will demonstrate how to implement and organize a UI layer. There are a lot of libraries, frameworks, and patterns you can use to build a UI layer. Today I’m building everything based on the next tech stack: Android Fragment + Jatpack Compose + Orbit-MVI + Kotlin + Flow + Coroutines + Koin (DI). This is one of the optimal combinations. I will focus on the UI’s building blocks such as Router, Router Container, ViewModel, Screen, and Navigator.
Router
The Route is the main UI unit responsible for:
- Holding ViewModel, Navigator, and Screen composable with UI elements.
- Consuming the UI state.
- Handling Side Effects (one-time action).
- Passing user interaction to the ViewModel.
The main idea that sits behind the Router is to encapsulate and make a self-sustainable UI unit that knows how to produce and manage the UI state (implement unidirectional data flow) and navigate.
Naming conventions
The Route classes are named after the Screen name they’re responsible for.
Screen name + Route.
For example: FareListRoute
, ConformationRoute
.
@Composable | |
fun FareListRoute( | |
navigator: FareListNavigator, | |
ryderId: String, | |
viewModel: FareListViewModel = koinViewModel { parametersOf(ryderId) }, | |
scaffoldState: ScaffoldState = rememberScaffoldState(), | |
) { | |
val state by viewModel.collectAsState() | |
FareListScreen( | |
uiState = state, | |
scaffoldState = scaffoldState, | |
onFareClick = viewModel::onFareClick, | |
) | |
viewModel.RenderEffect(scaffoldState = scaffoldState, navigator = navigator) | |
} | |
@Composable | |
private fun FareListViewModel.RenderEffect( | |
scaffoldState: ScaffoldState, | |
navigator: FareListNavigator, | |
) { | |
collectSideEffect { effect -> | |
when (effect) { | |
is FareListEffect.GoToConfirmation -> { | |
navigator.goToConfirmation(ryderId = effect.ryderId, fare = effect.fare) | |
} | |
FareListEffect.ShowGeneralNetworkError -> scaffoldState.showSnackBar("Network error") | |
} | |
} | |
} |
In the code above you can see what Router looks like. The ViewModel
passed as a parameter and injected by Koin (DI). Along with it, we pass Navigator
and ryderId
as data passed from the previous screen. The one cool feature of the Koin is that you can inject ryderId
it into ViewModel
the constructor.
The Router can have more than one
ViewModel
.
I’ll cover it in the section about ViewModel
. In the Router
we collect the state that ViewModel
holds and pass it as a parameter to the Screen
.
Do not pass
ViewModel
as an argument to theScreen
composable function. Doing so couples the composable function with the ViewModel type, making it less reusable and harder to test and preview. Also, there would be no clear single source of truth that manages the ViewModel instance.
The collectAsState
is extension function ContainerHost
that ViewModel
implement from the Orbit library.
@Composable | |
fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectAsState( | |
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED | |
): State<STATE> { | |
return this.collectAsState(lifecycleState) | |
} |
lifecycleState
— The Lifecycle where the restarting collecting from this flow work will be kept alive.
RenderEffect
— another extension function (to be able to call ViewModel extension function) responsible for collecting side effects using collectSideEffect
.
@Composable | |
fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectSideEffect( | |
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, | |
action: (suspend (sideEffect: SIDE_EFFECT) -> Unit), | |
) { | |
this.collectSideEffect(sideEffect = action, lifecycleState = lifecycleState) | |
} |
The Side Effect is a one-time action often it’s navigation like GoToConfirmation
screen, show Snack Bar, Toast, and Dialog in some cases.
when (effect) { | |
is FareListEffect.GoToConfirmation -> { | |
navigator.goToConfirmation(ryderId = effect.ryderId, fare = effect.fare) | |
} | |
FareListEffect.ShowGeneralNetworkError -> scaffoldState.showSnackBar("Network error") | |
} |
The Router Container
The Fragment is the UI container for Route. It can contain DI-injected fields we don’t want to pass to the Router, and hold arguments.
Naming conventions
These classes are named after the UI component that they’re responsible for.
UI component name + Fragment.
For example FareListFragment
.
UI component name + Controller.
For example FareListController
.
class FareListFragment : Fragment(R.layout.compose_fragment) { | |
private val arguments: FareListFragmentArgs by navArgs() | |
private val navigator: FareListNavigator by inject { | |
parametersOf(findNavController(), lifecycleScope, requireContext()) | |
} | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
super.onViewCreated(view, savedInstanceState) | |
val composeView = ComposeFragmentBinding.bind(view).compose | |
setAppComposeContent(composeView) { | |
FareListRoute(navigator = navigator, ryderId = arguments.ryderId) | |
} | |
} | |
} |
As you can see the code of fragment classes is small because all UI logic is encapsulated in Route, which makes it easy to change the container implementation.
The Router shouldn’t know navigation implementation details, it should depend on
Navigator
.
That is why the injection logic of the navigator is inside the Fragment, not the Router because the navigator requires a Fragment NavController
to implement navigation logic. It keeps the Router decoupled from the container implementation and allows us to easily change container implementation, for example — using Compose navigation or Controller from Conductor lib.
Navigator
The Navigator is responsible for:
- Encapsulates navigation logic from the Router.
- Restricts navigation API for certain screens.
- Define explicit API for each screen.
If the screen has navigation to another screen, it should have its navigator class. It can be extended from the base ScreenNavigator
with a default go-back action, can contain other navigators and platform-dependent components like Fragment NavController
.
Naming conventions
The navigator classes are named after the Screen name that they’re responsible for:
Screen name + Navigator.
For example FareListNavigator
.
Let’s look at the next diagram that shows the relationship between gradle modules of our app.
Here you can see core modules Core, Shared. The feature modules such as Fare and Profile, and the App module.
For example, we have a Fare module with features and one of them has a button for navigation to the profile screen of the user. The user profile page is in the Profile module. How to implement this navigation?
For that, we need to create an interface ProfileSharedNavigator
that knows how to navigate to the user Profile page, and keep it in the Shared module.
According to our architecture, the Fare module depends on Shared, so we can use ProfileSharedNavigator
in the FareListNavigator
.
We pass ProfileSharedNavigator
to the FareListNavigator
as one of its arguments and delegate navigation calls to it.
The ScreenNavigator
is the base class that knows only how to navigate back.
The App module knows everything about everyone in the app. The main purpose of this module is to organize all dependency injection logic between all feature modules in the project.
As you can see AppNavigator
hold the real implementation of the ProfileSharedNavigator
interface. We can depend on this interface across different modules, and create real instances of it in the App module following the Dependency Inversion Principle (DIP).
State
The state file contains the UI state data class and for the Side Effects sealed class suites the best. State class can be Parcelable (optional) if you want the state to survive through the configuration changes. All properties should have a default value if it’s possible. Effect class contains one-time action on UI, like navigation, show toast, snack bar, bottom sheet, or dialog. To learn more about Effect you can read Orbit Side Effect documentation.
Naming conventions
The state classes are named after the UI component type they’re responsible for. The convention is as follows:
UI component name + State.
UI component name + Effect.
For example: FareListState
and FareListEffect
.
@Parcelize | |
data class FareListState( | |
val status: ScreenContentStatus = ScreenContentStatus.Idle, | |
val showRequestLoading: Boolean = false, | |
val fares: List<FareModel> = emptyList(), | |
) : Parcelable | |
sealed class FareListEffect { | |
data class GoToConfirmation(val ryderId: String, val fare: FareModel) : FareListEffect() | |
data object ShowGeneralNetworkError : FareListEffect() | |
} |
If your screen has different loading states, better to explicitly split it in your state class. The screen can have few content states, such as Idle
, Loading
, Refreshing
, Success
, Failure
. After the initial content loading, we might want to make a request to the server and show loading to the user, in that case better to show the loading dialog using a separate showRequestLoading
property instead of using status
a field and set ScreenContentStatus.Loading
. The point is not to try to reuse one field to cover different loading cases.
Model
The Presentation layer also has its data models that reflect models from the Domain layer but are more UI-specific. The mapping logic of the presentation model to the domain and vice versa should be placed in the ViewModel class.
@Parcelize | |
data class FareModel( | |
val description: String, | |
val price: Float, | |
) : Parcelable |
Job Offers
- The presentation layer shouldn’t expose the UI model to other layers.
- The presentation model can implement platform-specific ways of serialization such as Parcelable and Serializable.
- The presentation model should be immutable.
Naming conventions
The model classes are named after the data type that they’re responsible for:
type of data + Model.
For example: Ryder
, Fare
.
ViewModel
The ViewModel is a business logic state holder. In Android development, ViewModel is suitable for providing access to the business logic and preparing the application data for presentation on the screen. Also, process user events and transform data from the data or domain layers to screen UI state.
For the current implementation, I’m using androidx.lifecycle.ViewModel
and Orbit-MVI lib. The ViewModel holds the Orbit container and implements ContainerHost
. Check out Orbit API documentation to understand better what going on.
Naming conventions
The ViewModel
classes are named after the UI component type that they’re responsible for:
UI component name + ViewModel.
For example FareListViewModel
.
class FareListViewModel( | |
private val exceptionHandler: ExceptionHandler, | |
private val ryderId: String, | |
private val getFaresByIdUseCase: GetFaresByIdUseCase, | |
) : ViewModel(), ContainerHost<FareListState, FareListEffect> { | |
override val container: Container<FareListState, FareListEffect> = container( | |
initialState = FareListState(), | |
buildSettings = { | |
this.exceptionHandler = | |
this@FareListViewModel.exceptionHandler.asCoroutineExceptionHandler() | |
}, | |
) { | |
fetchFares() | |
} | |
private fun fetchFares() = intent { | |
reduce { state.copy(status = ScreenContentStatus.Loading) } | |
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() } | |
.onSuccess { fares -> | |
reduce { | |
state.copy( | |
status = ScreenContentStatus.Success, | |
fares = fares | |
) | |
} | |
} | |
.onFailure { | |
reduce { state.copy(status = ScreenContentStatus.Failure) } | |
postSideEffect(FareListEffect.ShowGeneralNetworkError) | |
} | |
} | |
fun onFareClick(fare: FareModel) = intent { | |
postSideEffect(FareListEffect.GoToConfirmation(ryderId, fare)) | |
} | |
} |
In the code above you can see the example of ViewModel. Let’s shed light on what is going on there.
Let’s start with the constructor. As you can see we inject the use case from the domain layer, ryderId
which we pass from the previous screen, and ExceptionHandler. The ViewModel can have multiple use cases.
If you end up in a situation where you have 10+ use cases in the ViewModel, it’s a signal for you to split your ViewModel on few smaller.
Do not try to put some use cases to wrapper classes like this:
data class UseCaseHolder( | |
private val usecase1: UseCase1, | |
private val usecase2: UseCase2, | |
private val usecase3: UseCase3, | |
) |
and then put it in your ViewModel
class FareListViewModel( | |
private val useCaseHolder: UseCaseHolder, | |
) |
The more interesting stuff going on in fetchFares
method.
private fun fetchFares() = intent { | |
reduce { state.copy(status = ScreenContentStatus.Loading) } | |
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() } | |
.onSuccess { fares -> | |
reduce { | |
state.copy( | |
status = ScreenContentStatus.Success, | |
fares = fares | |
) | |
} | |
} | |
.onFailure { | |
reduce { state.copy(status = ScreenContentStatus.Failure) } | |
postSideEffect(FareListEffect.ShowGeneralNetworkError) | |
} | |
} |
A few words about Orbit lib API. The intent
method is executed lambda on Dispatcher.Default
. The reduce
a method is executed lambda on Dispatcher.Main
. It reduces the state and updates the UI state.
Do not execute the use case in the lambda of
reduce
the method.
The executeUseCase
is an extension method of ViewModel to execute the use case and wrap its result to kotlin.Result<R>
. It allows you to use extension methods of the Result class such as onSuccess
, onFailure
. Also, pass the exception to the ViewModel handler.
suspend inline fun <R> ViewModel.executeUseCase(block: () -> R): Result<R> = | |
viewModelScope.executeUseCase(block) | |
suspend inline fun <R> CoroutineScope.executeUseCase(block: () -> R): Result<R> { | |
return runSuspendCatching(block) | |
.onFailure { e -> | |
coroutineScope { coroutineContext }.let { coroutineContext -> | |
coroutineContext[CoroutineExceptionHandler]?.run { | |
handleException(coroutineContext, e) | |
} | |
} | |
} | |
} | |
inline fun <R> runSuspendCatching(block: () -> R): Result<R> { | |
return try { | |
Result.success(block()) | |
} catch (cancellationException: CancellationException) { | |
throw cancellationException | |
} catch (e: Throwable) { | |
Result.failure(e) | |
} | |
} |
The
executeUseCase
method intended to execute only one use case.
If you face a situation when you need to execute 2+ use cases for one operation then you should consider the following options:
- Create a new use case, put all the logic there, and combine the use case you need.
- If you need to wait for results from multiple use cases and combine them:
private fun fetchData() = intent { | |
reduce { state.copy(status = ScreenContentStatus.Loading) } | |
val fetched = coroutineScope { | |
awaitAll( | |
async { fetchUser() }, | |
async { fetchFares() }, | |
).all { it } | |
} | |
reduce { | |
state.copy( | |
status = if (fetched) { | |
ScreenContentStatus.Success | |
} else { | |
ScreenContentStatus.Failure | |
} | |
) | |
} | |
} | |
private suspend fun SimpleSyntax<FareListState, FareListEffect>.fetchFares(): Boolean = | |
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() } | |
.onSuccess { fares -> reduce { state.copy(fares = fares) } } | |
.onFailure { /* Handle error */ } | |
.isSuccess | |
private suspend fun SimpleSyntax<FareListState, FareListEffect>.fetchUser(): Boolean = | |
executeUseCase { getUserUseCase(userId).asPresentation() } | |
.onSuccess { user -> reduce { state.copy(user = user) } } | |
.isSuccess |
The asPresentation()
method responsible for mapping the data model from the domain layer to the model of the presentation layer. You can read how to pam data between layers here.
Screen
The Screen file contains all UI-composed implementations with a Compose preview of each screen state like empty, error, loading, and content.
Naming conventions
The screen classes are named after the UI component type that they’re responsible for:
UI component name + Screen.
For example FareListScreen
.
@Composable | |
fun FareListScreen( | |
uiState: FareListState, | |
scaffoldState: ScaffoldState = rememberScaffoldState(), | |
onFareClick: (FareModel) -> Unit, | |
) { | |
Scaffold( | |
modifier = Modifier.statusBarsPadding(), | |
scaffoldState = scaffoldState, | |
topBar = { | |
TopAppBar( | |
title = { | |
Text(text = stringResource(id = R.string.select_fare_title)) | |
}, | |
backgroundColor = AppTheme.colors.material.surface, | |
) | |
}, | |
content = { | |
ScreenContent( | |
status = uiState.status, | |
forceLoading = uiState.status.isLoading, | |
) { | |
FareList( | |
fares = uiState.fares, | |
onClick = onFareClick | |
) | |
} | |
} | |
) | |
} | |
@Preview(name = "Fares Content", showBackground = true) | |
@Composable | |
fun PreviewFareListScreenSuccess() { | |
AppTheme { | |
FareListScreen( | |
uiState = FareListState( | |
status = ScreenContentStatus.Success, | |
fares = fakeFareModels, | |
), | |
onFareClick = {} | |
) | |
} | |
} | |
@Preview(name = "Fares Content", showBackground = true) | |
@Composable | |
fun PreviewFareListScreenLoading() { | |
AppTheme { | |
FareListScreen( | |
uiState = FareListState( | |
status = ScreenContentStatus.Loading, | |
fares = fakeFareModels, | |
), | |
onFareClick = {} | |
) | |
} | |
} | |
@Preview(name = "Fares Content", showBackground = true) | |
@Composable | |
fun PreviewFareListScreenFailure() { | |
AppTheme { | |
FareListScreen( | |
uiState = FareListState( | |
status = ScreenContentStatus.Failure, | |
fares = fakeFareModels, | |
), | |
onFareClick = {} | |
) | |
} | |
} |
There are a few rules I recommend you follow when building your UI using Compose.
- Choose stateless composable over stateful. You can read more about it here.
- Pass all callbacks up to the top screen composable and pass all user interaction to the ViewModel on the Router level.
- Make composable previews for different states of UI components.
Imagine we need to write TopAppBar
Composable function with title
as parameter. There are two ways you can consider, pass title
as a String or @Composable () -> Unit
function.
Option 1.
@Composable | |
fun TopAppBar( | |
modifier: Modifier = Modifier, | |
title: String = "", | |
) { | |
Text(text = title) | |
} | |
Scaffold( | |
topBar = { | |
TopAppBar( | |
title = stringResource(id = R.string.select_fare_title), | |
) | |
}, | |
) |
Option 2.
@Composable | |
fun TopAppBar( | |
modifier: Modifier = Modifier, | |
title: @Composable () -> Unit = {}, | |
) { | |
} | |
Scaffold( | |
topBar = { | |
TopAppBar( | |
title = { Text(text = stringResource(id = R.string.select_fare_title)) }, | |
) | |
}, | |
) |
Always choose option 2. It will make your Composable function more customizable and robust at the same time.
Screen preview
To make the screen preview look as close as possible to the real-world scenario we need some random data to create a state. For that, you can create FareModelFake
class, put it in the same package as FareModel
.
model/
├─FareModel
├─FareModelFake
The FareModelFake
class contains FareModel
with fake data that you can use for your previews.
internal val fakeFareModels: List<FareModel> | |
get() = listOf( | |
FareModel( | |
description = "2.5 Hour Ticket", | |
price = 2.5f, | |
), | |
FareModel( | |
description = "1 Day Pass", | |
price = 5.0f, | |
), | |
FareModel( | |
description = "30 Day Pass", | |
price = 100f, | |
) | |
) |
Packaging conventions
presentation/
├─ fare/
│ ├─ component/
│ │ ├─ FareList
│ │ ├─ FareItem
│ ├─ model/
│ ├─ ├─FareModel
│ ├─ ├─FareModelFake
│ ├─ FareListFragment
│ ├─ FareListNavigator
│ ├─ FareListRoute
│ ├─ FareListScreen
│ ├─ FareListState
│ ├─ FareListViewModel
├─ confirmation/
│ ├─ component/
│ │ ├─ ConfirmationItem
│ ├─ model/
│ ├─ ├─ConfirmationModel
│ ├─ ├─ConfirmationModelFake
│ ├─ ConfirmationFragment
│ ├─ ConfirmationNavigator
│ ├─ ConfirmationRoute
│ ├─ ConfirmationScreen
│ ├─ ConfirmationState
│ ├─ ConfirmationViewModel
Wrapping up
There are a lot of different ways to implement the Presentation layer. Today I shared with you some ideas on how the Presentation layer can be done. You can follow this approach or use some ideas in your implementation.
You can check the sample project on github.
Stay tuned for the next App Architecture topic to cover.
This artice is previously published on proandroiddev.com