Separating Presentation layer from Android (refactoring)
In this article I will explain how I separated Presentation layer from Android libraries and dependencies.
Topics covered
- Clean Architecture
- Testing
The project
The project I use for the demonstation is Victor Checkers This is the commit in question.
The problem
The problem with the previous solution, which treated the Presentation layer like an Android library rather than a Java library, was that I found the JUnit tests placed in the Android library did not recognize the classes present in a Java module.
The solution
The solution was to remove Android dependencies from the Presentation layer. This is the solution I found in Eran Boudjnah’s project Clean Architecture for Android Sample Project.
The steps
Step 1
Just remove Android dependencies from your module’s level build.gradle:
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'kotlin-kapt'
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
dependencies {
kapt "com.google.dagger:hilt-compiler:2.52"
implementation project(':checkers-domain')
testImplementation 'junit:junit:4.13.2'
testImplementation "org.mockito.kotlin:mockito-kotlin:5.4.0"
testImplementation "org.mockito:mockito-inline:5.2.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC.2"
}
Step 2
Rename BaseViewModel
to BasePresentation
and don’t inherit it from ViewModel
.
package pl.org.seva.checkers.presentation.architecture
import ...
abstract class BasePresentation<VIEW_STATE : Any, NOTIFICATION : Any>(
useCaseExecutorProvider: UseCaseExecutorProvider
) { ...
Step 3
Replace androidx.compose.runtime.State
with kotlinx.coroutines.flow.MutableStateFlow
.
var whiteWon = MutableStateFlow(false)
var blackWon = MutableStateFlow(false)
Step 3, ViewModel
The reason I still use ViewModel
is that I want to have access to viewModelScope
. If I ever decide to navigate away from the checkerboard screen, I want the minimax algorithm to automatically cancel generation of the new movements, but I do want it to survive mere screen orientation changes.
This is what I put in the UI layer:
package pl.org.seva.checkers.ui.view
import ...
@HiltViewModel
class GameViewModel @Inject constructor(
private val presentation: GamePresentation
) : ViewModel() { ...
The ViewModel
is small, though, and only maps the methods to corresponding methods in the Presentation
class in the Presentation layer.
Step 4, Jetpack Compose
Because I use StateFlow
in the Presentation layer, in the UI layer I convert them again to State
.
This is what I do in the Fragment
:
Pieces(
piecesPresentationToUiMapper.toUi(vm.viewState.collectAsState().value.pieces),
vm.viewState.collectAsState().value.movingWhiteMan,
vm.viewState.collectAsState().value.movingWhiteKing,
onTouchListener,
)
Step 5, usecases
Because I no longer use ViewModel
in the Presentation layer, I had to somehow come up with a way to pass viewModelScope
from the UI layer to the Presentation layer.
This is what I do in GameViewModel
to call a usecase:
fun addWhite(x: Int, y: Int, king: Boolean) = presentation.addWhite(x, y, king, viewModelScope)
This is the method in GamePresentation
:
fun addWhite(x: Int, y: Int, forceKing: Boolean = false, coroutineScope: CoroutineScope) {
updateViewState(if (forceKing || y == 0) {
viewState.value.addWhiteKing(x to y)
}
else {
viewState.value.addWhiteMan(x to y)
})
lastMove = LastMove.WHITE
execute(whiteMoveUseCase, coroutineScope, piecesPresentationToDomainMapper.toDomain(viewState.value.pieces), ::presentPieces)
}
And this is the execute
method in BasePresentation
:
protected fun <INPUT, OUTPUT> execute(
useCase: UseCase<INPUT, OUTPUT>,
coroutineScope: CoroutineScope,
value: INPUT,
onSuccess: (OUTPUT, CoroutineScope) -> Unit = { _, _ -> },
onException: (DomainException) -> Unit = {}
) {
useCaseExecutor.execute(useCase, coroutineScope, value, onSuccess, onException)
}
For reference, this is the full code of UseCaseExecutor
:
package pl.org.seva.checkers.domain.cleanarchitecture.usecase
import ...
class UseCaseExecutor {
fun <INPUT, OUTPUT> execute(
useCase: UseCase<INPUT, OUTPUT>,
coroutineScope: CoroutineScope,
value: INPUT,
onSuccess: (OUTPUT, CoroutineScope) -> Unit = { _, _ ->},
onException: (DomainException) -> Unit = {}
) {
coroutineScope.launch {
try {
useCase.execute(value, coroutineScope, onSuccess)
}
catch (ignore: CancellationException) {
}
catch (throwable: Throwable) {
onException(
(throwable as? DomainException)
?: UnknownDomainException(throwable)
)
}
}
}
}
Summary
The following steps have been used for getting rid of Android dependencies in the Presentation layer:
- Remove the dependencies themselves.
- Create a new
ViewModel
class in the UI layer, for the sake ofviewModelScope
. - Every time you execute a test case, pass the
viewModelScope
in a parameter to the Presentation layer.