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 implemented some changes to our network data retrieval component to include expiry information to our Top Artists data, and then implemented a Room database to be able to persist that data on the device.
While this already provides much of the functionality that we need, hooking it together is perhaps not quite as straightforward as it might seem.
Let’s start by looking at our Repository implementation:
class TopArtistsRepository( private val persister: DataPersister>, private val provider: DataProvider
) : DataProvider { override fun requestData(callback: (item: TopArtistsState) -> Unit) = persister.requestData { artists -> if (artists.isEmpty()) { provider.requestData { state -> if (state is TopArtistsState.Success) { persister.persistData(state.artists) } callback(state) } } else { callback(TopArtistsState.Success(artists)) } } }
This is core business logic, and lives in the innermost tier of our clean architecture. The logic isn’t particularly complex, because of how we deferred responsibilities to individual components, but it first attempts to retrieve data from the persister (which is actually our Room database, although this component is totally agnostic of that) which will return any non-expired data that it has. If this fails then it will try the provider instead (which is our network component which will request data from last.fm), and if this is successful it will persist to the persister before making a callback (to our UI to display the data).
One interesting thing to note is that the repository itself implements DataProvider<TopArtistsState>
exactly the same as the network component. As our ViewModel
consumes this interface, we can simply substitute the network component for the repository via DI and our ViewModel
will simply work, but we’ve substituted in some new business logic. That’s pretty neat, but it does add some slight complexity. Making the persistence component available via DataPersister
<
List
<
Artist
>>
is pretty straightforward:
@Module object DatabaseModule { @Provides @JvmStatic internal fun providesDatabase(context: Application): TopArtistsDatabase = Room.databaseBuilder(context, TopArtistsDatabase::class.java, "top-artists").build() @Provides @JvmStatic internal fun providesTopArtistsDao(database: TopArtistsDatabase): TopArtistsDao = database.topArtistsDao() @Provides @JvmStatic internal fun providesTopArtistsMapper(): DataMapper, Pair >> = DatabaseTopArtistsMapper() @Provides @JvmStatic internal fun providesDatabasePersister( dao: TopArtistsDao, mapper: DataMapper , Pair >> ): DataPersister > = DatabaseTopArtistsPersister(dao, mapper) }
However things become a little trickier when it comes to adding exposing the Repository because there are now two components which are exposed as DataProvider<TopArtistsState>
: one which performs a network call to last.fm (LastFmTopArtistsProvider
); and one the repository (`TopArtistsRepository`). Moreover, the repository itself actually consumes DataProvider<TopArtistsState>
so we require a mechanism to disambiguate these distinct implementations of the same interface. Fortunately Dagger 2 provides a naming mechanism for such cases. We first add the new modules to our TopArtistsModule
, and define some name constants: ENTITIES
and NETWORK
:
@Module( includes = [ EntitiesModule::class, DatabaseModule::class, NetworkModule::class, BaseViewModule::class, LastFmTopArtistsModule::class ] ) @Suppress("unused") abstract class TopArtistsModule { companion object { const val ENTITIES = "ENTITIES" const val NETWORK = "NETWORK" } @ContributesAndroidInjector abstract fun bindTopArtistsFragment(): TopArtistsFragment @Binds @IntoMap @ViewModelKey(TopArtistsViewModel::class) abstract fun bindChartsViewModel(viewModel: TopArtistsViewModel): ViewModel }
In our LastFmTopArtistsModule
we can add a name to our @Provides
:
@Module object LastFmTopArtistsModule { @Provides @Named(TopArtistsModule.NETWORK) @JvmStatic fun providesTopArtistsDataProvider( lastFmTopArtistsApi: LastFmTopArtistsApi, connectivityChecker: ConnectivityChecker, mapper: DataMapper, List > ): DataProvider = LastFmTopArtistsProvider( lastFmTopArtistsApi, connectivityChecker, mapper ) @Provides @JvmStatic fun providesLastFmMapper(): DataMapper , List > = LastFmArtistsMapper() }
We can now use this in EntitiesModule
:
@Module object EntitiesModule { @Provides @Named(TopArtistsModule.ENTITIES) @JvmStatic internal fun providesTopArtistsRepository( persistence: DataPersister>, @Named(TopArtistsModule.NETWORK) networkProvider: DataProvider
): DataProvider = TopArtistsRepository(persistence, networkProvider) }
Not only do we specify that we require the DataProvider<TopArtistsState>
implementation named NETWORK
, but we declare this implementation of DataProvider<TopArtistsState>
as being named ENTITIES
.
With this in place we can now update the constructor injection of our ViewModel
and we’ll now get the repository injected rather than the direct network call:
class TopArtistsViewModel @Inject constructor( @Named(TopArtistsModule.ENTITIES) private val topArtistsProvider: DataProvider) : ViewModel() { . . . }
Although this feels like it’s good to go, and it compiles it will actually fail at runtime because we are now performing long running operations on the main thread. Thus far we haven’t had to worry too much about this because we are using Retrofit for our network call in asynchronous mode which automatically takes the blocking behaviour off the main thread, and issues a callback on the main thread once things are complete. However adding the repository which does not operate in the same way results in us accessing the SQLite database on the main thread which is a really bad idea. Room now has some support for coroutines which we could use for this, but I feel that it is cleaner in this instance to tie our coroutine scope to our ViewModel
(which is Activity lifecycle-aware), and therefore cancel any pending operations if it goes out of scope. To do this we need to implement CoroutineScope
on our ViewModel
, and then we can preform the request to our topArtistsProvider
on an IO
thread, and handle the callback on the Main
thread:
class TopArtistsViewModel @Inject constructor( @Named(TopArtistsModule.ENTITIES) private val topArtistsProvider: DataProvider) : ViewModel(), CoroutineScope { private val job = Job() override val coroutineContext: CoroutineContext get() = Dispatchers.Main + job private val mutableLiveData: MutableLiveData = MutableLiveData() val topArtistsViewState: LiveData get() = mutableLiveData init { load() } override fun onCleared() { super.onCleared() job.cancel() } fun load() = launch { withContext(Dispatchers.IO) { topArtistsProvider.requestData { artistsState -> update(artistsState) } } } private fun update(artistsState: TopArtistsState) = launch { withContext(Dispatchers.Main) { mutableLiveData.value = when (artistsState) { TopArtistsState.Loading -> TopArtistsViewState.InProgress is TopArtistsState.Error -> TopArtistsViewState.ShowError(artistsState.message) is TopArtistsState.Success -> TopArtistsViewState.ShowTopArtists(artistsState.artists) } } } }
This feels like a really nice, clean solution which we’ve properly linked to our Activity lifecycle to perform good cleanup if our Activity is destroyed. However, there is an edge-case that could cause us problems. While it is good to cancel background operations when the Activity is destroyed, there is one specific operation that we would not want to cancel, and that is where we have retrieved fresh data from the network, and are in the process of persisting that to the database. If the Activity were to be destroyed while the database write is in progress, it could result in corruption to our database. So we really want to detach the database write from our Activity lifecycle. This is actually really easy to do. Although the requestData()
method of TopArtistsRepository
will be executed within the CoroutineContext
of our ViewModel
we can actually detach the call to persist the data from this and move it to a Global context. Most of the time it is wise to avoid using a Global context that is not lifecycle-bound, but in very few cases we really want operations to complete. Implementing this is actually pretty straightforward:
class TopArtistsRepository( private val persister: DataPersister>, private val provider: DataProvider
) : DataProvider { override fun requestData(callback: (item: TopArtistsState) -> Unit) = persister.requestData { artists -> if (artists.isEmpty()) { provider.requestData { state -> if (state is TopArtistsState.Success) { GlobalScope.launch(Dispatchers.IO) { persister.persistData(state.artists) } } callback(state) } } else { callback(TopArtistsState.Success(artists)) } } }
Simply launching on GlobalScope.launch(Dispatchers.IO)
will perform the operation on a background job which will not be cancelled if the Activity is destroyed. Furthermore it also improves the user experience marginally. Previously, the retrieved data would be persisted before the callback to the UI was made to display the data. Now, the persistence is performed on a separate thread so the callback to the UI is made while the persistence is in progress. While this will only be a tiny and most probably imperceptible improvement, it is still an improvement nonetheless.
With our Repository now in place, the UI itself has not changed, and we have barely even touched the UI code, save for adding coroutines to the ViewModel
. However we have improved the UX in that we will be able to present data to the user even when, in some cases, the device lacks network connectivity.
In the next article we’ll explore how we can take this even further.
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.