Muselee is a demo app which allows the user to browse popular music artists. It is not intended to be a fully-featured user app, but a vehicle to explore good app architecture, how to implement current best-practice, and explore how the two often go hand in hand. Moreover it will be used to explore how implementing some specific patterns can help to keep our app both maintainable, and easy to extend.
Previously we looked at the Networking side of the feature module to display a list of top artists from last.fm. So we have a mechanism for obtaining this data, but no way of actually displaying it. So now we’ll turn our attention to the UI side of things.
For the UI we’re going to use a very simple form of the MVVM (Model -View-ViewModel) pattern using the Jetpack Lifecycle components to provide the ViewModel
and use LiveData
to trigger updates to our View (in this case the TopArtistsFragment
). The Model
is the Artist
and TopArtistsState
entities that we defined previously as part of our internal domain model. Note how the work that we did previously means that we have no knowledge here that the data is coming from last.fm – we are completely abstracted from those implementation details of the network components.
In the MVVM pattern, the ViewModel
is responsible for interacting with the Model
and communicating changes in the view state of the to the View
itself. While it would be easy enough to pass the TopArtistsState
directly to the View
it provides us with a better abstraction to define a separate view state in order to completely separate the View
from the Model
. So we’ll first define this:
sealed class TopArtistsViewState { object InProgress : TopArtistsViewState() class ShowTopArtists(val topArtists: List) : TopArtistsViewState() class ShowError(val message: String) : TopArtistsViewState() }
While this is pretty analogous to the states represented in TopArtistsState
the naming makes this representative of what the appearance of the View
should be and therefore makes the View
itself agnostic of the loading states of the data (which is what TopArtistsState
represents). The ViewModel
performs the necessary conversion:
class TopArtistsViewModel @Inject constructor( private val topArtistsProvider: DataProvider) : ViewModel() { private val mutableLiveData: MutableLiveData = MutableLiveData() val topArtistsViewState: LiveData get() = mutableLiveData init { load() } fun load() { topArtistsProvider.requestData { artistsState -> mutableLiveData.value = when (artistsState) { TopArtistsState.Loading -> TopArtistsViewState.InProgress is TopArtistsState.Error -> TopArtistsViewState.ShowError(artistsState.message) is TopArtistsState.Success -> TopArtistsViewState.ShowTopArtists(artistsState.artists) } } } }
TopArtistsViewModel
is injected with a DataProvider<TopArtistsState>
instance and this is the DataProvider
interface that we looked at previously. This keeps the ViewModel
completely agnostic of how the data is obtained, it simply relies on the DataProvider
to provide the required data. So the DataProvider
is actually the interface to the Model
.
Internally it uses a MutableLiveData<TopArtistsViewState>
to represent the current state of the view. It exposes this as LiveData<TopArtistsViewState>
via the topArtistsViewState
which enables consumers to subscribe to the LiveData
but the value can only be updated by the ViewModel
itself.
The load()
method is the main workhorse. It will asynchronously request data from the DataProvider
, and then update the MutableLiveData<TopArtistsViewState>
once the callback lambda is invoked. This will automatically trigger updates in the View
which is subscribed to topArtistsViewState
.
In the init
constructor we invoke load()
to begin fetching data immediately. So any consumers do not need to manually invoke load()
to obtain initial data.
Being a ViewModel
from the Jetpack Architecture components, we’ll get all of the lifecycle subscribe / unsubscribe automatically, and we’ll also handle device rotations gracefully as a result.
The data will be displayed in a RecyclerView
and each item within the list will contain the rank (i.e. the most popular artist will have a rank of 1), the name of the artist, and an image of the artist. This gets represented through a simple `ViewHolder`:
class TopArtistsViewHolder( item: View, private val rankView: TextView = item.findViewById(R.id.rank), private val imageView: ImageView = item.findViewById(R.id.image), private val nameView: TextView = item.findViewById(R.id.name) ) : RecyclerView.ViewHolder(item) { fun bind(rank: String, artistName: String, artistImageUrl: String) { rankView.text = rank nameView.text = artistName Glide.with(imageView) .load(artistImageUrl) .transition(withCrossFade()) .into(imageView) } }
This is about as simple as it gets: We set the rank and artist name, and then use Glide to load the image. This is used by the Adapter
to create and bind the individual list items:
class TopArtistsAdapter( private val items: MutableList= mutableListOf() ) : RecyclerView.Adapter () { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopArtistsViewHolder = TopArtistsViewHolder( LayoutInflater.from(parent.context) .inflate(R.layout.item_top_artist, parent, false) ) override fun getItemCount(): Int = items.size override fun onBindViewHolder(holder: TopArtistsViewHolder, position: Int) { items[position].also { artist -> holder.bind( rank = (position + 1).toString(), artistName = artist.name, artistImageUrl = artist.images[ImageSize.MEDIUM] ?: artist.images.values.first() ) } } fun replace(artists: List ) { val difference = DiffUtil.calculateDiff(TopArtistsDiffUtil(items, artists)) items.clear() items += artists difference.dispatchUpdatesTo(this) } private class TopArtistsDiffUtil( private val oldList: List , private val newList: List ) : DiffUtil.Callback() { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition] === newList[newItemPosition] override fun getOldListSize(): Int = oldList.size override fun getNewListSize(): Int = newList.size override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition].name == newList[newItemPosition].name } }
Once again, this is pretty standard stuff, so doesn’t warrant too much explanation. It inflates the layouts for each item, and performs the necessary binding to the TopArtistsViewHolder
. Finally we use DiffUtil
to correctly manage changes to the list items. This does not have much effect in the initial implementation of this feature module because we will not be dynamically updating the list. However, it is a good practice to get in to because we’ll get some nice behaviours for minimal effort. More information about this can be found here.
This all gets bound to the ViewModel
by the Fragment
:
class TopArtistsFragment : DaggerFragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var topArtistsViewModel: TopArtistsViewModel private lateinit var topArtistsAdapter: TopArtistsAdapter private lateinit var topArtistsRecyclerView: RecyclerView private lateinit var retryButton: Button private lateinit var progress: ProgressBar private lateinit var errorMessage: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) topArtistsViewModel = ViewModelProviders.of(this, viewModelFactory) .get(TopArtistsViewModel::class.java) topArtistsAdapter = TopArtistsAdapter() } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.fragment_top_artists, container, false).also { view -> topArtistsRecyclerView = view.findViewById(R.id.top_artists) retryButton = view.findViewById(R.id.retry) progress = view.findViewById(R.id.progress) errorMessage = view.findViewById(R.id.error_message) topArtistsRecyclerView.apply { adapter = topArtistsAdapter layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) } retryButton.setOnClickListener { topArtistsViewModel.load() } topArtistsViewModel.topArtistsViewState.observe(this, Observer { newState -> viewStateChanged(newState) }) } private fun viewStateChanged(topArtistsViewState: TopArtistsViewState) { when (topArtistsViewState) { is TopArtistsViewState.InProgress -> setLoading() is TopArtistsViewState.ShowError -> setError(topArtistsViewState.message) is TopArtistsViewState.ShowTopArtists -> updateTopArtists(topArtistsViewState.topArtists) } } private fun setLoading() { progress.visibility = View.VISIBLE errorMessage.visibility = View.GONE retryButton.visibility = View.GONE topArtistsRecyclerView.visibility = View.GONE } private fun setError(message: String) { progress.visibility = View.GONE errorMessage.visibility = View.VISIBLE errorMessage.text = message retryButton.visibility = View.VISIBLE topArtistsRecyclerView.visibility = View.GONE } private fun updateTopArtists(topArtists: List) { progress.visibility = View.GONE errorMessage.visibility = View.GONE retryButton.visibility = View.GONE topArtistsRecyclerView.visibility = View.VISIBLE topArtistsAdapter.replace(topArtists) } }
We get the ViewModelFactory
that we defined in the Core
module injected, and in onCreate()
we use this to obtain an instance of TopArtistsViewModel
as well as creating the TopArtistsAdapter
instance for the RecyclerView
.
In onCreateView()
we inflate the view, perform view lookups, then assign the adapter and layout manager to the RecyclerView
, add a click listener to the retry
button (which will be displayed in the event of an error, and then begin observing the LiveData
in our TopArtistsViewModel
.
The remainder of the class is implementing the various view states that get applied from viewStateChanged()
which is what will be called when the LiveData
changes. The individual methods for doing this are pretty self-explanatory – they change the visibility and contents of the View
s within the layout depending on the view state. For example, in updateTopArtists()
, we hide the loading ProgressBar
, error message TextView
, and retry Button
. Then we show the RecyclerView
and update the list of artists in the Adapter
to display the current set of top artists.
What is useful here is that there is very little logic within the Fragment
other than to determine what the current view state is whenever it changes.
The only thing that is remaining for us to get this working is to add some Dagger 2 bindings for these components:
@Module( includes = [ NetworkModule::class, BaseViewModule::class, LastFmTopArtistsModule::class ] ) @Suppress("unused") abstract class TopArtistsModule { @ContributesAndroidInjector abstract fun bindTopArtistsFragment(): TopArtistsFragment @Binds @IntoMap @ViewModelKey(TopArtistsViewModel::class) abstract fun bindChartsViewModel(viewModel: TopArtistsViewModel): ViewModel }
Here we include three other modules that we defined earlier which provide all of the other dependencies that we need. bindTopArtistsFragment()
enables TopArtistsFragment
to be injected with the necessary dependencies, and bindChartsViewModel()
makes TopArtistsViewModel
available to the ViewModelFactory
that we created in the Core
module, and when we pass that to the ViewModelProviders.Of()
factory in TopArtistsFragment
, ViewModelFactory
is able to construct an instance of TopArtistsViewModel
thanks to this entry.
We now have something working:
So we now have a working feature module, and we have looked at how the components that we’ve added fit within the MVVM pattern. However, there is something more going on here which we’ll explore in the next article.
The source code for this article is available here.
© 2019, Mark Allison. All rights reserved.
Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.