-
Notifications
You must be signed in to change notification settings - Fork 6
5. Framework Layer
The UI module contains any UI related code Activity, Fragment, Adapter, XML resources etc..., Notice that our architecture make sure that our UI model is as dumb as possible. Since our entities and use-cases are responsible for the business logic. And then our ViewModel is translating between our UI and our use-cases. This will make sure that our UI doesn't have any important code. It is just showing what the view model telling it to show. And Then tells the ViewModel what the user is doing.
At the top level, we have BaseActivity which is not doing much. it is just a good practice to have this class.
abstract class BaseActivity : AppCompatActivity()
And then we have the MVIActivity which implements MVIView
and has two generics for data
binding and ViewModel.
abstract class MVIActivity<B : ViewDataBinding, V : StandardViewModel<*, *>> :
BaseActivity(),
MVIView<B, V>
MVIView on the other hand has a binding and viewModel variable with addition to viewModelVariableId this is the id of the ViewModel in the xml layouts.
interface MVIView<B : ViewDataBinding, V : MVIViewModel<*, *>> {
val binding: B
val viewModel: V
val viewModelVariableId: Int
fun onViewReady()
}
Then We have StandardActivity which has some common behaviours for our
activity. for example we choose that the default value for viewModelVariableId
in our activities are BR.viewModel
. But you still can change that
by overriding the variable. then in the onCreate
method, we use binding to set content view so you don't need to do that in every activity.
We also inject the view model into the data binding object. and then we call observeState()
method which is observing the view model states. and we
observe the errors from the view model. and we also have the onViewReady()
method which will be called when the view is ready for extra change that
the child activity might need.
abstract class StandardActivity<B : ViewDataBinding, V : StandardViewModel<*, *>> :
MVIActivity<B, V>(), StandardView<B, V> {
override val viewModelVariableId = BR.viewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.setVariable(viewModelVariableId, viewModel)
viewModel.observeState()
viewModel.observeError()
onViewReady()
}
fun <I : MVIIntent> MVIViewModel<*, I>.runIntent(intent: I) {
runIntentInScope(lifecycleScope, intent)
}
private fun V.observeState() =
state.observe(this@StandardActivity) { it?.let { handleState(it) } }
private fun V.observeError() {
lifecycleScope.launch {
error.receiveAsFlow().collect { showError(it) }
}
}
override fun showError(message: ErrorMessage) {
val messageStr = if (message == ErrorMessage.DEFAULT) getString(R.string.default_error)
else message.message
showError(messageStr)
}
private fun showError(messageStr: String) = Snackbar
.make(binding.root, messageStr, Snackbar.LENGTH_SHORT)
.show()
override fun onViewReady() = Unit
override fun handleState(state: MVIState) = Unit
}
For the Fragments we have a similar structure as activity the BaseFragment is an important empty class. Notice that the BaseFragment is getting its layout from the constructor. this is making implementing data binding easier.
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId)
And then we have MVIFragment which is very similar to MVIActivity
. It is
implementing MVIView
in addition to data binding and view model generics.
abstract class MVIFragment<B : ViewDataBinding, V : StandardViewModel<*, *>>(
@LayoutRes contentLayoutId: Int
) : BaseFragment(contentLayoutId),
MVIView<B, V>
And then StandardFragment is also similar to StandardActivity
. but notice
that the standard fragment is using the main activity viewModel to send the error. this will give us a more stable error message implementation.
abstract class StandardFragment<B : ViewDataBinding, V : StandardViewModel<*, *>>(
@LayoutRes contentLayoutId: Int
) : MVIFragment<B, V>(contentLayoutId), StandardView<B, V> {
override val viewModelVariableId = BR.viewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.setVariable(viewModelVariableId, viewModel)
viewModel.observeState()
viewModel.observeError()
onViewReady()
}
fun <I : MVIIntent> MVIViewModel<*, I>.runIntent(intent: I) {
runIntentInScope(viewLifecycleOwner.lifecycleScope, intent)
}
private fun V.observeState() = state.observe(viewLifecycleOwner) { it?.let { handleState(it) } }
private fun V.observeError() {
viewLifecycleOwner.lifecycleScope.launch {
error.receiveAsFlow().collect { showError(it) }
}
}
override fun showError(message: ErrorMessage) {
if (activity is StandardView<*, *>) {
val standardActivity = (activity as StandardView<*, *>)
standardActivity.viewModel.updateError(message)
}
}
override fun onViewReady() = Unit
override fun handleState(state: MVIState) = Unit
}
In the MoviesFragment we are giving the implementation for binding and ViewModel
using property delegation. And when the user swipes the list to refresh the movies we are running MoviesIntent.RefreshMovies
intent this will inform
the view model to refresh the list.
In handleState
function, we are handling the MovieList
state by showing the list and the loading if the UI is in the loading state.
@AndroidEntryPoint
class MoviesFragment : StandardFragment<FragmentMoviesBinding, MoviesViewModel>(
R.layout.fragment_movies
) {
override val binding by dataBindings(FragmentMoviesBinding::bind)
override val viewModel: MoviesViewModel by viewModels()
private val moviesAdapter by lazy { MovieAdapter(viewModel) }
override fun onViewReady() {
binding.movieRV.setHasFixedSize(true)
binding.movieRV.adapter = moviesAdapter
binding.swipeRefresh.setOnRefreshListener {
viewModel.runIntent(MoviesIntent.RefreshMovies)
}
}
override fun handleState(state: MVIState) {
when (state) {
is MoviesState.MovieList -> handleMovieList(state)
}
}
private fun handleMovieList(moviesState: MoviesState.MovieList) {
binding.swipeRefresh.isRefreshing = moviesState.isLoading
moviesAdapter.items = moviesState.movies
}
}
The Remote module is an Android module that is heavenly depending on the android framework to do the system remote operation. This module is using tools like OkHttp, Retrofit and Moshi. To perform a network HTTP requests to the Movie DB API then parse and map the result to an entity represented object. Again there isn't any important code in this module it is rather just performing what the repository is asking in the third layer.
The API data source is the actual implementation of the remote data sources. Those classes are using movie DB API to access the remote data in the system.
The base implementation is an empty Class called APIDataSource.
abstract class APIDataSource
The MovieAPIDataSource is using movieApi that is provided by retrofit to perform movie-related remote operating. Like loading movie list using LoadMovieList function.
class MovieAPIDataSource @Inject constructor(
private val movieApi: MovieApi
) : APIDataSource(), MovieRemoteDataSource {
override suspend fun loadMovieList(
): List<MovieRemoteDTO> = movieApi.loadMovieList().bodyOrException().movieList
}
The DB module is also an Android module that is heavenly depending on the android framework to cash data locally. This module is using the Room library to perform its actions. and also we need to make this module as dumb as possible. It just needs to do what is the repository is asking it to do.
The base implementation is an empty Class called DBDataSource.
abstract class DBDataSource
DB data sources is the actual implementation of the Local data source introduced in the repository module. Those data sources are using room DB to cash data locally and perform operations on it.
Our MovieDBDataSource Implements MovieLocalDataSource
interface and
gives concrete implementation for it. and notice that it takes a MovieDao object in its constructor and uses it to perform its operations. for
example, updateMovieList
function is using insertAll function in MovieDao
to insert the movies to the movie table.
class MovieDBDataSource @Inject constructor(
private val movieDao: MovieDao
) : DBDataSource(), MovieLocalDataSource {
override suspend fun updateMovieList(movieList: List<MovieLocalDTO>): Unit =
movieDao.insertAll(movieList)
override suspend fun observeMovies(): Flow<List<MovieLocalDTO>> =
movieDao.observeMovies()
override suspend fun loadMovieSize(): Int = movieDao.loadMovieSize()
}
Our App module is the actual application. it contains our Hilt/Dagger modules. So it has the DI configuration. In addition to App class. notice that in our app/build that the app is depending on all our other modules. which is actually a limitation in the hilt library. HiltAndroidApp needs to have access to all our modules in order to work.
dependencies {
//region setup
api project(path: ':ui')
api project(path: ':db')
api project(path: ':remote')
//endregion setup
...
}
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.