Skip to content

Architecture Update

Albert Pinto edited this page Jul 16, 2021 · 5 revisions

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.

Navigation

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() {
    // ...
}

Methods to navigate

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.

NavDirections

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() {
    // ...
}

Performing navigation

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)
}

Testing navigation

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)
    }
}

Dialogues

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() {
    // ...
}

Dialog data classes

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,
)

Methods

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)

Testing

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

    // ...
}

Executing use cases

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 the CommonExceptions 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
        }
    }
}

Launch method

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
    }
}

Testing

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)
    )
}

Other events

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.

Http requests

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
Clone this wiki locally