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’ve looked at both the App and Core modules which include infrastructure but no behaviour. Now we’ll start looking at our first feature module which will provide a list of the most popular artists on last.fm. We obtain the list of top artists from the Chart.getTopArtists endpoint which returns a list of the most popular artists. last.fm requires developers to register and use their own API key in requests to this endpoint. For obvious reasons I am not including mine in the source repo, so you’ll need to add your own if you want to build and run the app.
The first thing that we’ll do is add a Network Dagger module for dependency injection:
@Module(includes = [CoreNetworkModule::class]) object NetworkModule { @Provides @Named("API_KEY") @JvmStatic internal fun providesApiKey() = Interceptor { chain -> val newRequest = chain.request().let { request -> val newUrl = request.url().newBuilder() .addQueryParameter("api_key", BuildConfig.LAST_FM_APIKEY) .build() request.newBuilder() .url(newUrl) .build() } chain.proceed(newRequest) } @Provides @Named("JSON") @JvmStatic internal fun providesJson() = Interceptor { chain -> val newRequest = chain.request().let { request -> val newUrl = request.url().newBuilder() .addQueryParameter("format", "json") .build() request.newBuilder() .url(newUrl) .build() } chain.proceed(newRequest) } @Provides @JvmStatic internal fun providesOkHttpClient( builder: OkHttpClient.Builder, @Named("API_KEY") apiKeyInterceptor: Interceptor, @Named("JSON") jsonInterceptor: Interceptor ): OkHttpClient = builder.addInterceptor(apiKeyInterceptor) .addInterceptor(jsonInterceptor) .build() @Provides @Singleton @JvmStatic internal fun providesRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl("https://ws.audioscrobbler.com/2.0/") .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create()) .build() @Provides @JvmStatic internal fun providesLastFmTopArtistsApi(retrofit: Retrofit): LastFmTopArtistsApi = retrofit.create(LastFmTopArtistsApi::class.java) @Provides @JvmStatic fun testString() = "Hello World!" }
We’ll leave the final testString()
provider in for the moment because we aren’t updating the Fragment which requires is quite yet.
The CoreNetworkModule
was added in the Core
module, and provided a common OkHttp implementation. We define a new interceptor in providesApiKey()
which will add the last.fm API key to all transactions. Then we define another Interceptor in providesJson()
to specify that we require JSON data in all responses. We use the Dagger @Named
annotation to differentiate between these because they both provide instances the same class. providesOkHttpClient()
then uses these interceptors along with the OkHttp.Builder
instance provided by CoreNetworkModule
to create an OkHttpClient
instance. providesRetrofit()
uses this OkHttpClient
instance to construct a Retrofit
instance for the base URL of the last.fm API which includes a MoshiConverterFactory
to deserialise the JSON in the response. Finally providesLastFmTopArtistsApi()
uses this to construct an instance of LastFmTopArtistsApi
:
interface LastFmTopArtistsApi { @GET("?method=chart.gettopartists") fun getTopArtists(): Call}
The getTopArtists()
method is what we’ll call to retrieve the list of most popular artists, and we can now inject a LastFmTopArtistsApi
instance wherever we need it.
For Moshi will deserialise the JSON from the response in to Java / Kotlin objects, and this has been modelled as LastFmArtists
which is returned by the getTopArtists()
method:
data class LastFmArtists(val artists: ArtistsList) data class ArtistsList(val artist: List) data class LastFmArtist( val name: String, @field:Json(name = "playcount") val playCount: Long, val listeners: Long, val mbid: String, val url: String, val streamable: Int, @field:Json(name = "image") val images: List ) data class LastFmImage( @field:Json(name = "#text") val url: String, val size: String )
This directly models the JSON returned by the API call.
We really don’t want to use this data model internally because it is defined externally, and it could cause us problems and extra work if last.fm were to change it at any point. In order to keep our app resilient to such changes it is best to convert it to an internal format as soon as we receive it. To do this we’ll implement a DataMapper
, which is an interface that we defined in the Core
module:
class LastFmArtistsMapper : DataMapper> { override fun map(source: LastFmArtists): List = source.artists.artist.map { artist -> Artist(artist.name, artist.normalisedImages()) } private fun LastFmArtist.normalisedImages() = images.map { it.size.toImageSize() to it.url }.toMap() private fun String.toImageSize(): ImageSize = when (this) { "small" -> ImageSize.SMALL "medium" -> ImageSize.MEDIUM "large" -> ImageSize.LARGE "extralarge" -> ImageSize.EXTRA_LARGE else -> ImageSize.UNKNOWN } }
This converts a LastFmArtists
object to a List<Artist>
which is our internal representation of of an ordered list of artists:
data class Artist(val name: String, val images: Map) { enum class ImageSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE, UNKNOWN } }
This distills the data down to only what we need in this feature module and separates the rest of the module from any specifics of last.fm. The LastFmTopArtistsProvider
class is how the other components of the module will actually access this:
class LastFmTopArtistsProvider( private val topArtistsApi: LastFmTopArtistsApi, private val connectivityChecker: ConnectivityChecker, private val mapper: DataMapper> ) : DataProvider { override fun requestData(callback: (topArtists: TopArtistsState) -> Unit) { if (!connectivityChecker.isConnected) { callback(TopArtistsState.Error("No network connectivity")) return } callback(TopArtistsState.Loading) topArtistsApi.getTopArtists().enqueue(object : Callback { override fun onFailure(call: Call , t: Throwable) { callback(TopArtistsState.Error(t.localizedMessage)) } override fun onResponse(call: Call , response: Response ) { response.body()?.also { topArtists -> callback(TopArtistsState.Success(mapper.map(topArtists))) } } }) } }
There are a couple of things here which are noteworthy. Firstly this implements the DataProvider
interface that we declared in the Core
module to return a TopArtistsState
object which we’ll look at in a moment. It encapsulates the current status of the request in to three distinct states: Loading
, Error
, and Success
. Initially we use the ConnectivityChecker
that is provided by the Core
to determine network connectivity, and return an Error
state if there is no connectivity. If we have connectivity, then we set the state to Loading
and enqueue the network request. In the response callback we set the state to Error
if the request failed, or Success
if the request succeeded. If successful, we convert the LastFmArtists
object to a list of Artist
objects, and set this as the payload to the TopArtistsState.Success
object. TopArtistsState
looks like this:
sealed class TopArtistsState { object Loading : TopArtistsState() class Success(val artists: List) : TopArtistsState() class Error(val message: String) : TopArtistsState() }
Using a Kotlin sealed class in this manner is a really useful trick for providing different states, with different states holding different payloads. In this case, the Success
state holds the retrieved data, whereas the Error
state holds a String
containing details of the error.
Finally, we need to be able to inject the LastFmTopArtistsProvider
in to other components, so we create a Dagger module:
@Module object LastFmTopArtistsRepositoryModule { @Provides @JvmStatic fun providesTopArtistsProvider( lastFmTopArtistsApi: LastFmTopArtistsApi, connectivityChecker: ConnectivityChecker, mapper: DataMapper> ): DataProvider = LastFmTopArtistsProvider( lastFmTopArtistsApi, connectivityChecker, mapper ) @Provides @JvmStatic fun providesLastFmMapper(): DataMapper > = LastFmArtistsMapper() }
It is important to note how we use both the DataProvider
and DataMapper
interfaces that we defined in the Core
module here. Any consumers of these do not need to know about the instances that Dagger will inject (in this case LastFmTopArtistsProvider
and LastFmArtistsMapper
), instead they just know that they are a DataProvider<TopArtistsState>
and DataMapper<LastFmArtists, List<Artist>>
. This makes a really clean abstraction from the actual implementations, and using interfaces such as these in conjunction with Dagger is really useful for decoupling the different parts of the module.
Although this code will compile, it doesn’t do much without any UI to show the data retrieved. In the next article in this series we’ll look at the UI components and see how we can hook all of this up to get this feature module working.
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.
Hi Mark, you might want to check the src of the LastFmTopArtistsApi code snippet, looks like it’s not rendered correctly with all those “wp:html” tags.
Thanks for letting me know. It should now be fixed, once it has propagated through caches, that is