-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture Update
Update: 16/07/2021
To improve readability and testing quality, we have implemented a set of changes in architecture. The use of screen states in the application is deprecated most of the time, and the loaded state was ScreenState.Nothing
. The main motivation for using states was navigation and dialogue, which had to be performed in either the MainActivity
or any Fragment
. We have introduced the concept of Managers, which will perform those actions from any ViewModel
. In addition, we have improved the HTTP requests with some utilities methods and classes.
To navigate using the NavController
, we needed to load a special state from the ViewModel
to its Fragment
, which will perform navigation. To test this functionality, we could verify that the state was loaded correctly. We will use the SearchViewModel
to exemplify this situation, using the method that loads the state to display a recipe detail.
fun onShowRecipeDetail(recipe: Recipe) {
loadState(SearchState.ShowRecipeDetail(recipe))
}
We have introduced the NavManager
, which performs navigation between destinations from ViewModel
. This class has to be injected in the constructor and is a singleton; thus, there will only be a single instance.
class SearchViewModel @Inject constructor(
private val searchRecipes: SearchRecipes,
private val navManager: NavManager,
// ...
) : BaseViewModel() {
// ...
}
The NavManager
class contains two methods to navigate within the application.
abstract fun navigate(@IdRes navHostFragment: Int, action: NavDirections)
abstract fun navigateUp(@IdRes navHostFragment: Int)
On the one hand, the navigate()
method performs navigation within the specified NavHostFragment
. On the other hand, the navigateUp()
perform navigation up in the nav graph.
We have developed a set of extension functions to NavManager
to use these methods in the projects.
fun NavManager.navigate(action: NavDirections)
fun NavManager.navigateUp()
fun NavManager.navigateMainFragment(action: NavDirections)
fun NavManager.navigateUpMainFragment()
The first two methods will navigate using the application nav graph, whereas the others will use the nav graph from MainFragment
.
To use NavManager
, we need to obtain the destination to which we are navigating. Each ViewModel
that needs to navigate between fragments should implement a navigation class composed of an interface and its implementation that will be stored within the navigation package of each feature. There will be a method for every navigation of the fragment associated with the ViewModel
, which will return an instance of NavDirections
. This class has to be injected into the constructor, and its provider must be specified in NavigationProviders
.
interface SearchNavigation {
fun navigateToRecipeDetail(recipe: Recipe): NavDirections
}
class SearchNavigationImpl : SearchNavigation {
override fun navigateToRecipeDetail(recipe: Recipe) =
SearchFragmentDirections.actionSearchFragmentToRecipeDetail(recipe.name, recipe)
}
@Module
@InstallIn(SingletonComponent::class)
class NavigationProviders {
@Provides
fun provideSearchNavigation(): SearchNavigation = SearchNavigationImpl()
}
class SearchViewModel @Inject constructor(
private val searchRecipes: SearchRecipes,
private val navManager: NavManager,
private val searchNavigation: SearchNavigation,
// ...
) : BaseViewModel() {
// ...
}
Finally, we might perform navigation to RecipeDetailFragment
from SearchViewModel
using NavManager
and SearchNavigation
.
fun onShowRecipeDetail(recipe: Recipe) {
val action = searchNavigation.navigateToRecipeDetail(recipe)
navManager.navigateMainFragment(action)
}
To test the navigation, we need to create a mock for NavManager
, navigation, and NavDirections
.
@MockK
private lateinit var navManager: NavManager
@MockK
private lateinit var searchNavigation: SearchNavigation
@MockK
private lateinit var navDirections: NavDirections
@Before
fun setUp() {
navManager = mockk()
every { navManager.navigate(any(), any()) } returns Unit
navDirections = mockk()
searchNavigation = mockk()
every { searchNavigation.navigateToRecipeDetail(any()) } returns navDirections
// ...
}
After declaring the mocks, we might write a test about navigation.
@Test
fun `when loading recipe then we navigate to RecipeDetail`() {
val recipe = recipes.first()
viewModel.onShowRecipeDetail(recipe)
verify {
searchNavigation.navigateToRecipeDetail(recipe)
navManager.navigate(any(), navDirections)
}
}
To display dialogues, we needed to load a state with the information to the fragment, which will use some extensions methods to show the dialogue. In this update, we have introduced the DialogManager
, which has to be injected in the ViewModel
constructor.
class SearchViewModel @Inject constructor(
private val searchRecipes: SearchRecipes,
private val navManager: NavManager,
private val dialogManager: DialogManager,
) : BaseViewModel() {
// ...
}
We have defined two classes that accept the same parameters but with different types to display information within the dialogues. On the one hand, IntDialog
accepts string resource ids as its parameters. On the other hand, LambdaDialog
uses lambdas with the context as its parameter to determine the content to be displayed.
data class IntDialog(
val title: Int,
val message: Int,
val positiveButtonText: Int = R.string.ok,
val positiveButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val negativeButtonText: Int? = null,
val negativeButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val neutralButtonText: Int? = null,
val neutralButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val isCancelable: Boolean = true,
)
data class LambdaDialog(
val title: (Context) -> String,
val message: (Context) -> String,
val positiveButtonText: (Context) -> String? = { null },
val positiveButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val negativeButtonText: (Context) -> String? = { null },
val negativeButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val neutralButtonText: (Context) -> String? = { null },
val neutralButtonAction: (DialogInterface, Int) -> Unit = { dialog, _ -> dialog.dismiss() },
val isCancelable: Boolean = true,
)
The DialogManager
class contains some methods to display a dialogue. In addition, there are some methods to display a toast.
abstract fun showDialog(data: IntDialog)
abstract fun showDialog(data: LambdaDialog)
abstract fun showLoadingDialog()
abstract fun cancelLoadingDialog()
abstract fun toast(msg: String, duration: Int = Toast.LENGTH_LONG)
abstract fun toast(@StringRes msgId: Int, duration: Int = Toast.LENGTH_LONG)
abstract fun toast(duration: Int, msg: (Context) -> String)
To test the DialogManager,
we need to use the same approach as we have done with NavManager
.
@MockK
private lateinit var navDirections: NavDirections
@Before
fun setUp() {
dialogManager = mockk()
every { dialogManager.showLoadingDialog() } returns Unit
every { dialogManager.cancelLoadingDialog() } returns Unit
// ...
}
To execute a use case from the ViewModel
, we had defined the executeUseCase()
method, which accepts the use case, the UseCaseResultHandler
to treat the result, a boolean to indicate whether the use case shows the loading dialogue or not, and a lambda to prepare the input. In SearchViewModel
we could get recipes using the onSearchRecipes()
val recipeList = MutableLiveData<List<Recipe>>(mutableListOf())
val mealType = MutableLiveData<MutableList<MealType>>(mutableListOf())
val search = MutableLiveData(listOf("a", "e", "i", "o", "u").random())
private val searchRandomRecipesResultHandler = UseCaseResultHandler<SearchRecipes.Response>(
onSuccess = { result ->
recipeList.value = result.recipes
ScreenState.Nothing
},
onError = { ScreenState.OtherError }
)
fun onSearchRecipes() {
viewModelScope.launch {
executeUseCase(searchRecipes, searchRandomRecipesResultHandler) {
SearchRecipes.Request(search.requireValue(), mealType.requireValue())
}
}
}
Since we have deprecated states' use, we need to introduce a new method that executes use cases using a different approach.
protected suspend fun <I : UseCase.UseCaseRequest, O : UseCase.UseCaseResponse> executeUseCase(
useCase: UseCase<I, O>,
onBefore: () -> Unit = {},
onAfter: () -> Unit = {},
isDefaultErrorBehaviourEnabled: Boolean = true,
onPrepareInput: () -> I,
): UseCaseResult<O>
This method accepts the following parameters:
-
useCase
: the use case that has to be executed -
onBefore
: lambda that will be executed before the use case is executed -
onAfter
: lambda that will be executed after the use case is executed -
isDefaultErrorBehaviour
: enables theCommonExceptions
default dialogs -
onPrepareInput
: lambda that generates the input of the use case
The value returned is the UseCaseResult
instance, which contains two methods to deal with the result.
fun onSuccess(onResult: (O) -> Unit): UseCaseResult<O>
fun onError(onException: (Exception) -> Unit): UseCaseResult<O>
Using this new function, we could execute the SearchRecipes
use case from SearchViewModel
differently.
fun onSearchRecipes() {
viewModelScope.launch {
executeUseCase(
useCase = searchRecipes,
onBefore = { dialogManager.showLoadingDialog() },
onAfter = { dialogManager.cancelLoadingDialog() },
onPrepareInput = {
SearchRecipes.Request(search.requireValue(), mealType.requireValue())
}
).onSuccess { result ->
recipeList.value = result.recipes
}
}
}
To improve readability within the ViewModel
, we have introduced the launch()
method, which will execute a coroutine within a ViewModel
.
fun onSearchRecipes() = launch {
executeUseCase(
useCase = searchRecipes,
onBefore = { dialogManager.showLoadingDialog() },
onAfter = { dialogManager.cancelLoadingDialog() },
onPrepareInput = {
SearchRecipes.Request(search.requireValue(), mealType.requireValue())
}
).onSuccess { result ->
recipeList.value = result.recipes
}
}
To test the use case, we might use the displayCommonError
property from BaseViewModel
, which loads the default CommonExceptions
dialogues.
@Test
fun `when there is no internet then NoInternet message is shown`() {
coEvery { searchRecipes.execute(any()) } returns
UseCaseResult.Error(CommonException.NoInternetException)
viewModel.onSearchRecipes()
val state = viewModel.displayCommonError.getOrAwaitValueExceptDefault(default = null)
assertThat(state, instanceOf(CommonException.NoInternetException::class.java))
}
@Test
fun `when there is an unexpected error OtherError message is shown`() {
coEvery { searchRecipes.execute(any()) } returns
UseCaseResult.Error(CommonException.OtherError(msg))
viewModel.onSearchRecipes()
val state = viewModel.displayCommonError.getOrAwaitValueExceptDefault(default = null)
assertThat(state, instanceOf(CommonException.OtherError::class.java))
}
@Test
fun `when the recipes are found in the remote database then they are stored in the ViewModel`() {
coEvery { searchRecipes.execute(any()) } returns
UseCaseResult.Success(SearchRecipes.Response(recipes))
viewModel.onSearchRecipes()
assertThat(
viewModel.recipeList.getOrAwaitValueExceptDefault(default = emptyList()),
isEqualTo(recipes)
)
}
When we have a situation in which we use a state neither navigate nor show a dialogue, we should use a LiveData from ViewModel
and observe its changes from Fragment
.
In addition to deprecating states, we have introduced a new way to perform HTTP connections. The HttpResponse
class is used to handle the results of the connections. It contains as properties the values returned from Fuel library and two methods.
fun logConnectionResults(): HttpResponse
fun onStatusCode(
isInternetErrorDefault: Boolean = true,
isServerErrorDefault: Boolean = true,
action: (Int) -> Unit,
)
On the one hand, logConnectionResults()
method logs the connection results and returns the same instance. On the other hand, onStatusCode
performs an action depending on the received status code. There are two booleans to control whether it should use the default behaviour for the cases when there is no internet and a server error, which is throwing an exception. To access the different status codes, we should use the HttpUrlConnection
class, containing some constants for each possible code.
To execute an HTTP request, we have to use the new String
extended methods, similar to the ones used by Fuel library.
suspend fun String.getRequest(parameters: Parameters? = null): HttpResponse
suspend fun String.postRequest(body: Any, parameters: Parameters? = null): HttpResponse
suspend fun String.putRequest(parameters: Parameters? = null): HttpResponse
suspend fun String.deleteRequest(parameters: Parameters? = null): HttpResponse
Table of contents
Updates