-
Notifications
You must be signed in to change notification settings - Fork 6
4. Interface Adapter Layer
The repository module is an android module but its dependency on the android framework is as minimum as possible. This module contains our repository implementation with it is data source interfaces.
As shown in the UML diagram, our usecase has a weak reference to the repositoryImp class which is the repository implementation in the third layer of our architecture. Then our repository implementation has multiple local or remote data sources. Those interfaces are in the third layer as well. But the implementation of those data sources is in the fourth layer. The Implementation of the remote data sources is in the remote module. And we call them API data sources. Those data sources are using public movie DB APIs. And then the implementation of the local data source is in the DB module and we call them DBDataSource. DB data sources are using room databases to do their tasks.
Our base implementation is just an empty class called RepositoryImp that implements the Base Repository interface. Again this can be useful in future for polymorphism reasons.
abstract class RepositoryImp : Repository
Our local data source MovieLocalDataSource has three functions to update the movie list another to observe the movies and the last one to load movies size in cash.
interface MovieLocalDataSource {
suspend fun updateMovieList(movieList: List<MovieLocalDTO>)
suspend fun observeMovies(): Flow<List<MovieLocalDTO>>
suspend fun loadMovieSize(): Int
}
Our local DTO is a data transfer object used by our room library to cash data in our database. check
out MovieLocalDTO as an example. notice that we can have @PrimaryKey
to make our id a primary key to our movie table.
@Entity(tableName = "movie")
data class MovieLocalDTO(
@PrimaryKey val id: String,
val posterPath: String,
val title: String,
val voteAverage: Double
)
DB mappers are like other mappers in the project they are mapping entity objects to DBDTO objects and vice versa. check out MovieMappers as an example.
internal fun MovieLocalDTO.toEntity() = Movie(
id = id,
posterPath = posterPath,
title = title,
voteAverage = voteAverage,
)
internal fun List<MovieLocalDTO>.toEntity() = map { it.toEntity() }
internal fun Movie.toLocalDTO() = MovieLocalDTO(
id = id,
posterPath = posterPath,
title = title,
voteAverage = voteAverage,
)
internal fun List<Movie>.toLocalDTO() = map { it.toLocalDTO() }
The remote data source MovieRemoteDataSource has one function to load a list of remote movies.
interface MovieRemoteDataSource {
suspend fun loadMovieList(): List<MovieRemoteDTO>
}
The package DTO has our remoteDTO s like MovieRemoteDTO those DTO are data transfer objects that can be used to pars API JSON or encode data to JSON.
data class MovieRemoteDTO(
val id: String,
@Json(name = "poster_path") val posterPath: String,
@Json(name = "original_title") val title: String,
@Json(name = "vote_average") val voteAverage: Double,
)
Our mappers are responsible to map entities to RemoteDTO or vice versa. For example, check out MovieMapper.kt file.
internal fun MovieRemoteDTO.toEntity() = Movie(
id = id,
posterPath = posterPath,
title = title,
voteAverage = voteAverage,
)
internal fun List<MovieRemoteDTO>.toEntity() = map { it.toEntity() }
Our only RepositoryImp is MovieRepositoryImp
this is repository is responsible to provide movie data that the system needs. The repository has one
remote data source and one local data source. It is also implementing our MovieRepository
interface in the second layer.
Then the updateMovieList
function is using a remote data source to load new remote movies and then uses the local data source to update the locally
cashed movie list. The function observeMovies
is returning a flow of movies that can be used to observe the locally cashed movies.
And loadMovieSize
is just returning the size of locally cached movies.
class MovieRepositoryImp @Inject constructor(
private val movieRemoteDataSource: MovieRemoteDataSource,
private val movieLocalDataSource: MovieLocalDataSource,
) : RepositoryImp(), MovieRepository {
override suspend fun updateMovieList() = movieLocalDataSource.updateMovieList(
movieRemoteDataSource.loadMovieList().toEntity().toLocalDTO()
)
override suspend fun observeMovies() = movieLocalDataSource.observeMovies().map {
val a = it.toEntity()
a
}
override suspend fun loadMovieSize(): Int = movieLocalDataSource.loadMovieSize()
}
Our ViewModel module is an Android module that has as minimum dependency on the android framework as possible. This module contains ViewModels in the MVI architecture pattern.
MVI is the abbreviation of Model - View - Intent. But In the MVI architecture pattern, we also have other components like ViewModel and State.
The model holds data and the logic of the application. The view cannot access the model. Instead, ViewModel exposes the model to the view throw observables. In our template, the first and second layer of the architecture acts as the model in the MVI architecture pattern. So Use Cases and entities are our models in MVI.
The view is observing the mutable states provided by ViewModel and draws the UI accordingly. In android fragments and activities are our views. The view is also responsible for sending intents to the ViewModel.
The intent is an action that the user performs like clicking a button or swapping the screen. or some system events like when the connection is down.
ViewModel is the intermediate between view and model. When the user interacts with the view. The view is sending an intent to the ViewModel and ViewModel is using the module to responds to the Intent. And then updates the UI throw an observable property called state.
The state is representing a UI state. States contain all the data that is required to draw a specific UI screen.
For ViewModel Base Implementation first, we have an abstract class called BaseViewModel Which is extending android ViewModel and also implements the output port. notice that this class doesn't have any MVI logic since it is just a simple ViewModel.
abstract class BaseViewModel : ViewModel(), OutputPort
And then we have our MVI ViewModel Implementation. First We need an interface called MVIIntent this will be our system Intents, of course, this is different from android intents.
interface MVIIntent
Then we need another interface called MVIState which will be for our UI states.
interface MVIState
For the ViewModel implementation, we will have an abstract class
called MVIViewModel that will extend our BaseViewModel.The class also have two
generics State and Intent. Our kotlin channel intent will be used to get intents from the view. using intent
the view can send a one-time event to
ViewModel. The State
LiveData will be used by the ViewModel to update the View.
abstract class MVIViewModel<S : MVIState, I : MVIIntent> : BaseViewModel() {
abstract val intents: Channel<I>
abstract val state: LiveData<S>
}
We also need another class called StandardViewModel this class have some default configuration that is needed in most of our ViewModels. This class have some default behaviour for injecting output ports, consuming intents that come from the view. and also showing a default error message when something goes wrong.
notice
@Suppress("MemberVisibilityCanBePrivate")
abstract class StandardViewModel<S : MVIState, I : MVIIntent>(
private val backgroundDispatcher: CoroutineContext = Dispatchers.IO
) : MVIViewModel<S, I>() {
override val intents: Channel<I> by lazy {
Channel<I>().tryToConsume()
}
private val _state = MutableLiveData<S>()
override val state: LiveData<S> = _state
val error = Channel<ErrorMessage>()
init {
tryToInjectOutputPorts()
}
//region intent
private fun Channel<I>.tryToConsume(): Channel<I> {
launchInIO { tryTo { consumeAsFlow().collect { tryToHandleIntent(it) } } }
return this@tryToConsume
}
private suspend fun tryToHandleIntent(intent: I) = tryTo {
handleIntent(intent)
}
protected open suspend fun handleIntent(intent: I): Any = Unit
//endregion intent
//region injection
private fun <O : OutputPort> O.tryToInjectOutputPorts() {
launchInIO { tryTo { injectOutputPorts() } }
}
private suspend fun <O : OutputPort> O.injectOutputPorts() = this::class.memberProperties.map {
it.isAccessible = true
it.getter.call(this)
}.filterIsInstance<InputPort<O>>().forEach {
it.registerOutputPort(this@injectOutputPorts)
}
//endregion injection
//region error
open fun updateError(e: Throwable) = updateError(ErrorMessage.DEFAULT)
open fun updateError(message: ErrorMessage) {
launchInIO { error.send(message) }
}
//endregion error
//region utils
protected suspend fun tryTo(callback: suspend () -> Unit) = try {
callback()
} catch (e: Throwable) {
Timber.e(e)
updateError(e)
}
protected fun <T> Flow<T>.collectResult(
action: (value: T) -> Unit
) = launchInIO { tryTo { collect { tryTo { action(it) } } } }
protected fun updateState(state: S) = _state.postValue(state)
protected fun launchInIO(
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch(
context = backgroundDispatcher,
start = start,
block = block,
)
//endregion utils
}
UI DTO s are data transfer objects between our business logic and our UI. The MovieUIDTO
is an example.
The MovieUIDTO is implementing StandardStateListItem
so it can be used in our
StandardAdapters
.
data class MovieUIDTO(
override val id: String,
val posterPath: String,
val title: String,
val voteAverage: String,
) : StandardStateListItem
The UI mappers are mapping our entity objects to the UID TO objects. for example,
the MovieMappers is mapping our movie
class to the MovieUIDTO
notice that
we are setting the base URL for poster path and we also change average to string so it can be used easier by our UI.
internal fun Movie.toUIDTO() = MovieUIDTO(
id = id,
posterPath = BuildConfig.TMDB_API_BASE_IMG_URL + posterPath,
title = title,
voteAverage = voteAverage.toString(),
)
internal fun List<Movie>.toUIDTO() = map { it.toUIDTO() }
Check out MoviesIntent as you can see we have two intents RefreshMovies
which asks the view model to refresh the movie list and OnMovieClicked
which will be used to inform the view model that a movie has been clicked
sealed class MoviesIntent : MVIIntent {
object RefreshMovies : MoviesIntent()
class OnMovieClicked(
val position: Int,
val id: String,
) : MoviesIntent()
}
For the MoviesState we have a MovieList
state which has a list
of MovieUIDTO
to show and a boolean that indicates whether the loading is showing or not.
sealed class MoviesState(
open val movies: List<MovieUIDTO> = listOf(),
) : MVIState {
class MovieList(
override val movies: List<MovieUIDTO>,
val isLoading: Boolean = false
) : MoviesState(movies)
}
The MoviesViewModel then handleIntent(intent: MoviesIntent)
method
handling movie state using the movie movieInputPort. observeMovies(flow: Flow<List>)
method is overriding from LoadMovieListOutputPort interface
which is giving a flow so the view model can observe changes in the local movie list. and the showLoading(loading: Boolean)
inform view model to
show the loading or not.
@HiltViewModel
class MoviesViewModel @Inject constructor(
private val movieInputPort: LoadMovieListInputPort
) : StandardViewModel<MoviesState, MoviesIntent>(),
LoadMovieListOutputPort {
override suspend fun handleIntent(intent: MoviesIntent) = when (intent) {
is MoviesIntent.RefreshMovies -> handleRefreshMovies()
is MoviesIntent.OnMovieClicked -> handleMovieClicked(intent)
}
private suspend fun handleRefreshMovies() {
movieInputPort.startUpdatingMovieList()
}
private fun handleMovieClicked(intent: MoviesIntent.OnMovieClicked) {
state.value?.movies?.getOrNull(intent.position)?.takeIf { it.id == intent.id }?.let {
println("showing movie : $it")
}
}
override suspend fun startObserveMovies(flow: Flow<List<Movie>>) {
flow.collectResult {
updateState(MoviesState.MovieList(it.toUIDTO()))
}
}
override suspend fun showLoading(loading: Boolean) {
state.value?.movies?.let { movies ->
updateState(MoviesState.MovieList(movies, loading))
}
}
}
Copyright (c) <2021> <Muhammad Khoshnaw>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.