存储架构

Muselee 12: Repository – Part 2

微信扫一扫,分享到朋友圈

Muselee 12: Repository – Part 2
0 0

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<List<Artist>>,
    private val provider: DataProvider<TopArtistsState>
) : DataProvider<TopArtistsState> {
 
    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))
            }
        }
}
 
<!-- /wp:html -->
 
<!-- wp:paragraph -->
<p>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).</p>
<!-- /wp:paragraph -->
 
<!-- wp:paragraph -->
<p>One interesting thing to note is that the repository itself implements <code>DataProvider<TopArtistsState></code> exactly the same as the network component. As our <code>ViewModel</code> consumes this interface, we can simply substitute the network component for  the repository via DI and our <code>ViewModel</code> 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 <code>DataPersister</code><em><code><</code></em><code>List</code><em><code><</code></em><code>Artist</code><em><code>>></code></em> is pretty straightforward:</p>
<!-- /wp:paragraph -->
 
<!-- wp:html -->
<pre class="language:default" title="di/DatabaseModule.kt">@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<Triple<Int, Artist, Long>, Pair<DbArtist, Collection<DbImage>>> =
        DatabaseTopArtistsMapper()
 
    @Provides
    @JvmStatic
    internal fun providesDatabasePersister(
        dao: TopArtistsDao,
        mapper: DataMapper<Triple<Int, Artist, Long>, Pair<DbArtist, Collection<DbImage>>>
    ): DataPersister<List<Artist>> =
        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<Pair<LastFmArtists, Long>, List<Artist>>
    ): DataProvider<TopArtistsState> =
        LastFmTopArtistsProvider(
            lastFmTopArtistsApi,
            connectivityChecker,
            mapper
        )
 
    @Provides
    @JvmStatic
    fun providesLastFmMapper(): DataMapper<Pair<LastFmArtists, Long>, List<Artist>> =
        LastFmArtistsMapper()
}

We can now use this in EntitiesModule :

@Module
object EntitiesModule {
 
    @Provides
    @Named(TopArtistsModule.ENTITIES)
    @JvmStatic
    internal fun providesTopArtistsRepository(
        persistence: DataPersister<List<Artist>>,
        @Named(TopArtistsModule.NETWORK) networkProvider: DataProvider<TopArtistsState>
    ): DataProvider<TopArtistsState> = TopArtistsRepository(persistence, networkProvider)
}
 
<!-- /wp:html -->
 
<!-- wp:paragraph -->
<p>Not only do we specify that we require the <code>DataProvider<TopArtistsState></code> implementation named <code>NETWORK</code>, but we declare this implementation of <code>DataProvider<TopArtistsState></code> as being named <code>ENTITIES</code>.</p>
<!-- /wp:paragraph -->
 
<!-- wp:paragraph -->
<p>With this in place we can now update the constructor injection of our <code>ViewModel</code> and we'll now get the repository injected rather than the direct network call:</p>
<!-- /wp:paragraph -->
 
<!-- wp:html -->
<pre class="language:default mark:2" title="view/TopArtistsViewModel.kt">class TopArtistsViewModel @Inject constructor(
    @Named(TopArtistsModule.ENTITIES) private val topArtistsProvider: DataProvider<TopArtistsState>
) : 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<TopArtistsState>
) : ViewModel(), CoroutineScope {
 
    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job
 
    private val mutableLiveData: MutableLiveData<TopArtistsViewState> = MutableLiveData()
 
    val topArtistsViewState: LiveData<TopArtistsViewState>
        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<List<Artist>>,
    private val provider: DataProvider<TopArtistsState>
) : DataProvider<TopArtistsState> {
 
    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.

阅读原文...

Styling Android

开源时代,人才哪里找?这份报告给出了一份详尽的人才供需图

上一篇

iOS 12.2 makes audio messages sound way better

下一篇

您也可能喜欢

评论已经被关闭。

插入图片
Muselee 12: Repository – Part 2

长按储存图像,分享给朋友