Экспериментируем с Paging Library, Retrofit, Coroutines, Koin и тестированием
Пагинация — достаточно известная, но трудная в реализации функция. Поэтому автор статьи (см. источник) решил разработать демонстрационное приложение на основе Android Paging Library с пагинацией, основанной на пользовательских запросах в поиске. Он также использовал Retrofit и следующие библиотеки:
- Kotlin Coroutines: Для асинхронного программирования.
- Koin: Для внедрения зависимостей.
Как работает это приложение?
Разобраться в приложении достаточно просто. Значок “Search” находится на панели инструментов. Нажав на него, пользователь начинает печатать необходимый запрос: при каждом совпадении буквы обновляется RecyclerView, а на API GitHub запускается новый запрос (если запущен предыдущий, то он отклоняется).
Круговой индикатор процесса находится в нижней части RecyclerView и отображает загрузку следующего запроса.
Пользователь также может использовать фильтр запросов с помощью диалога после нажатия FAB.
Я также использовал обработчик ошибок сети. Таким образом, можно уведомить пользователя о случившейся ошибке. В этом приложении происходят два типа ошибок:
- Pagination error: первый запрос выполнен правильно, а второй дает сбой… В этом случае пользователь получает сообщение и кнопку “повторить запрос”.
- Global error: первый запрос дает сбой… В этом случае выходит сообщение и кнопка “обновить” вместо RecyclerView.
Как создать такое приложение?
Процесс создания относительно прост.
Изначально у нас есть фрагмент кода, содержащий RecyclerView, связанный с PagedList с помощью LiveData. PagedList — это список (List), который загружает данные по фрагментам (страницам) из DataSource, созданным с помощью DataSource.Factory.
В данном примере UserDataSource не выдает данные напрямую. Эту функцию выполняет UserRepository.
Покажите мне код!
Retrofit & Coroutines
При использовании coroutines с Retrofit каждый вызов должен возвращать ответ Deferred.
interface UserService {
@GET("search/users")
fun search(
@Query("q") query: String,
@Query("page") page: Int,
@Query("per_page") perPage: Int,
@Query("sort") sort: String,
@Query("client_id") clientId: String = BuildConfig.GithubClientId,
@Query("client_secret") clientSecret: String = BuildConfig.GithubClientSecret
): Deferred<Result>
@GET("users/{username}")
fun getDetail(
@Path("username") username: String,
@Query("client_id") clientId: String = BuildConfig.GithubClientId,
@Query("client_secret") clientSecret: String = BuildConfig.GithubClientSecret
): Deferred<User>
@GET("users/{username}/repos")
fun getRepos(
@Path("username") username: String,
@Query("client_id") clientId: String = BuildConfig.GithubClientId,
@Query("client_secret") clientSecret: String = BuildConfig.GithubClientSecret
): Deferred<List<Repository>>
@GET("users/{username}/followers")
fun getFollowers(
@Path("username") username: String,
@Query("per_page") perPage: Int = 2,
@Query("client_id") clientId: String = BuildConfig.GithubClientId,
@Query("client_secret") clientSecret: String = BuildConfig.GithubClientSecret
): Deferred<List<User>>
}Затем в UserRepository нужно вызвать каждый предыдущий запрос с помощью функций suspend с использованием метода .await():
class UserRepository(private val service: UserService) {
private suspend fun search(
query: String,
page: Int,
perPage: Int,
sort: String
) = service.search(query, page, perPage, sort).await()
private suspend fun getDetail(login: String) =
service.getDetail(login).await()
private suspend fun getRepos(login: String) =
service.getRepos(login).await()
private suspend fun getFollowers(login: String) =
service.getFollowers(login).await()
suspend fun searchUsersWithPagination(
query: String,
page: Int,
perPage: Int,
sort: String
): List<User> {
if (query.isEmpty()) return listOf()
val users = mutableListOf<User>()
val request = search(query, page, perPage, sort) // Search by name
request.items.forEach {
val user = getDetail(it.login) // Fetch detail for each user
val repositories = getRepos(user.login) // Fetch all repos for each user
val followers = getFollowers(user.login) // Fetch all followers for each user
user.totalStars = repositories.map { it.numberStars }.sum()
user.followers = if (followers.isNotEmpty()) followers else listOf()
users.add(user)
}
return users
}
}Функции suspend достаточно читабельны (даже для тех, кто не знаком с coroutines), поэтому легко отгадать, что именно получит с API Github каждая из них.
Paging Library
После подготовки сетевых запросов переходим к настройке классов из Paging Library. Сначала создаем UserDataSource:
class UserDataSource(
private val repository: UserRepository,
private val query: String,
private val sort: String,
private val scope: CoroutineScope
): PageKeyedDataSource<Int, User>() {
// FOR DATA ---
private var supervisorJob = SupervisorJob()
//...
// OVERRIDE ---
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, User>
) {
//...
executeQuery(1, params.requestedLoadSize) {
callback.onResult(it, null, 2)
}
}
override fun loadAfter(
params: LoadParams<Int>,
callback: LoadCallback<Int, User>
) {
val page = params.key
//...
executeQuery(page, params.requestedLoadSize) {
callback.onResult(it, page + 1)
}
}
override fun invalidate() {
super.invalidate()
// Cancel possible running job to only keep
// last result searched by user
supervisorJob.cancelChildren()
}
// UTILS ---
private fun executeQuery(
page: Int,
perPage: Int,
callback: (List<User>) -> Unit
) {
//...
scope.launch(getJobErrorHandler() + supervisorJob) {
delay(200) // To handle user typing case
val users = repository.searchUsersWithPagination(query, page, perPage, sort)
//...
callback(users)
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Log.e(UserDataSource::class.java.simpleName, "An error happened: $e")
networkState.postValue(NetworkState.FAILED)
}
//...
}Этот класс представляет собой сердце пагинации и наследуется из PageKeyedDataSource.
Метод executeQuery() запускает новую coroutine, которая получит данные с API Github. Я также использовал SupervisorJob для облегчения обработки возможных сбоев и отмены действий дочерних элементов.
CoroutineExceptionHandler управляет не перехваченными исключениями (uncaught exceptions).
Затем создаем UserDataSource с помощью UserDataSourceFactory, который наследует DataSource.Factory:
class UserDataSourceFactory(
private val repository: UserRepository,
private var query: String = "",
private var sort: String = "",
private val scope: CoroutineScope
): DataSource.Factory<Int, User>() {
val source = MutableLiveData<UserDataSource>()
override fun create(): DataSource<Int, User> {
val source = UserDataSource(repository, query, sort, scope)
this.source.postValue(source)
return source
}
//...
}Можно заметить, что объекты CoroutineScope и UserRepository дважды переданы в оба конструктора UserDataSourceи UserDataSourceFactory.
Изначально эти объекты были созданы с помощью SearchUserViewModel. Таким образом, при уничтожении VM, можно с легкостью прекратить работу запущенных coroutines.
ViewModel
SearchUserViewModel сконструирует все предыдущие объекты, чтобы создать LiveData из PagedList:
class SearchUserViewModel(
repository: UserRepository,
private val sharedPrefsManager: SharedPrefsManager
): BaseViewModel() {
// FOR DATA ---
private val userDataSource =
UserDataSourceFactory(repository = repository, scope = ioScope)
// OBSERVABLES ---
val users = LivePagedListBuilder(userDataSource, pagedListConfig()).build()
// PUBLIC API ---
/**
* Fetch a list of [User] by name
* Called each time an user hits a key through [SearchView].
*/
fun fetchUsersByName(query: String) {
val search = query.trim()
if (userDataSource.getQuery() == search) return
userDataSource.updateQuery(search, sharedPrefsManager.getFilterWhenSearchingUsers().value)
}
//...
// UTILS ---
private fun pagedListConfig() = PagedList.Config.Builder()
.setInitialLoadSizeHint(5)
.setEnablePlaceholders(false)
.setPageSize(5 * 2)
.build()
}Для наглядности рассмотрим пример с классом BaseViewModel:
abstract class BaseViewModel: ViewModel() {
/**
* This is a scope for all coroutines launched by [BaseViewModel]
* that will be dispatched in a Pool of Thread
*/
protected val ioScope = CoroutineScope(Dispatchers.Default)
/**
* Cancel all coroutines when the ViewModel is cleared
*/
override fun onCleared() {
super.onCleared()
ioScope.coroutineContext.cancel()
}
}Koin
Для внедрения зависимости был выбран Koin из-за его читаемости и простоты использования.
Рассмотрим пример сетевого модуля Koin для этого приложения:
val networkModule = module {
factory<Interceptor> {
HttpLoggingInterceptor(HttpLoggingInterceptor.Logger { Log.d("API", it) })
.setLevel(HttpLoggingInterceptor.Level.HEADERS)
}
factory { OkHttpClient.Builder().addInterceptor(get()).build() }
single {
Retrofit.Builder()
.client(get())
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
}
factory { get<Retrofit>().create(UserService::class.java) }
}Выглядит неубедительно? Рассмотрим модуль ViewModel:
val viewModelModule = module {
viewModel { SearchUserViewModel(get(), get()) }
}Все максимально понятно. 👌
Тестирование
Unit Tests
Благодаря Koin и Coroutines модульное тестирование становится более удобным и читабельным:
class UserRepositoryTest: BaseUT() {
// FOR DATA ---
private val userRepository by inject<UserRepository>()
// OVERRIDE ---
override fun isMockServerEnabled() = true
override fun setUp() {
super.setUp()
startKoin(configureAppComponent(getMockUrl()))
}
// TESTS ---
@Test
fun `search users by name and succeed`() {
mockHttpResponse("search_users.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("detail_user.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("repos_user.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("followers_user.json", HttpURLConnection.HTTP_OK)
runBlocking {
val users = userRepository.searchUsersWithPagination("FAKE", -1, -1, "FAKE")
assertEquals(1, users.size)
assertEquals("PhilippeBoisney", users.first().login)
assertEquals(103, users.first().totalFollowers)
assertEquals(32, users.first().totalRepos)
assertEquals(1346, users.first().totalStars)
assertEquals(2, users.first().followers.size)
assertEquals("UgurMercan", users.first().followers[0].login)
assertEquals("https://avatars0.githubusercontent.com/u/7712975?v=4", users.first().followers[0].avatarUrl)
assertEquals("Balasnest", users.first().followers[1].login)
assertEquals("https://avatars3.githubusercontent.com/u/6050520?v=4", users.first().followers[1].avatarUrl)
}
}
@Test(expected = HttpException::class)
fun `search users by name and fail`() {
mockHttpResponse("search_users.json", HttpURLConnection.HTTP_FORBIDDEN)
runBlocking {
userRepository.searchUsersWithPagination("FAKE", -1, -1, "FAKE")
}
}
}Для справки: я использовал MockWebServer для имитации HTTP-сервера и пользовательских ответов.
Instrumented Tests
Повторюсь, тестировать с Koin намного проще, особенно при обновлении зависимостей контроллера (Activity/Fragment) перед каждым тестом.
@RunWith(AndroidJUnit4::class)
@LargeTest
class SearchUserFragmentTest: BaseIT() {
@Rule
@JvmField
val activityRule = ActivityTestRule(MainActivity::class.java, true, false)
@get:Rule
var executorRule = CountingTaskExecutorRule()
// OVERRIDE ---
override fun isMockServerEnabled() = true
@Before
override fun setUp() {
super.setUp()
configureCustomDependencies()
activityRule.launchActivity(null)
}
// TESTS ---
@Test
fun whenFragmentIsEmpty() {
onView(withId(R.id.fragment_search_user_empty_list_image)).check(matches(isDisplayed()))
onView(withId(R.id.fragment_search_user_empty_list_title)).check(matches(isDisplayed()))
onView(withId(R.id.fragment_search_user_empty_list_title)).check(matches(withText(containsString(getString(R.string.no_result_found)))))
onView(withId(R.id.fragment_search_user_empty_list_button)).check(matches(not(isDisplayed())))
}
@Test
fun whenUserSearchUsersAndSucceed() {
mockHttpResponse("search_users.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("detail_user.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("repos_user.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("followers_user.json", HttpURLConnection.HTTP_OK)
mockHttpResponse("search_users_empty.json", HttpURLConnection.HTTP_OK)
onView(withId(R.id.action_search)).perform(click())
onView(isAssignableFrom(AutoCompleteTextView::class.java)).perform(typeText("t"))
waitForAdapterChangeWithPagination(getRecyclerView(), executorRule, 4)
onView(withId(R.id.fragment_search_user_rv)).check(matches((hasItemCount(1))))
onView(allOf(withId(R.id.item_search_user_title), withText("PhilippeBoisney"))).check(matches(isDisplayed()))
onView(allOf(withId(R.id.item_search_user_repositories), withText("1346 - 32 ${getString(R.string.repositories)}"))).check(matches(isDisplayed()))
onView(allOf(withId(R.id.item_search_user_follower_name), withText("UgurMercan"))).check(matches(isDisplayed()))
onView(allOf(withId(R.id.item_search_user_follower_count), withText("+102"))).check(matches(isDisplayed()))
}
@Test
fun whenUserSearchUsersAndFailed() {
mockHttpResponse("search_users.json", HttpURLConnection.HTTP_BAD_REQUEST)
onView(withId(R.id.action_search)).perform(click())
onView(isAssignableFrom(AutoCompleteTextView::class.java)).perform(typeText("t"))
Thread.sleep(1000)
onView(withId(R.id.fragment_search_user_empty_list_image)).check(matches(isDisplayed()))
onView(withId(R.id.fragment_search_user_empty_list_title)).check(matches(isDisplayed()))
onView(withId(R.id.fragment_search_user_empty_list_title)).check(matches(withText(containsString(getString(R.string.technical_error)))))
onView(withId(R.id.fragment_search_user_empty_list_button)).check(matches(isDisplayed()))
}
// UTILS ---
/**
* Configure custom [Module] for each [Test]
*/
private fun configureCustomDependencies() {
loadKoinModules(configureAppComponent(getMockUrl()).toMutableList().apply { add(storageModuleTest) })
}
/**
* Convenient access to String resources
*/
private fun getString(id: Int) = activityRule.activity.getString(id)
/**
* Convenient access to [SearchUserFragment]'s RecyclerView
*/
private fun getRecyclerView() = activityRule.activity.findViewById<RecyclerView>(R.id.fragment_search_user_rv)
}Возможно, вы заметили Thread.sleep(1000)? RecyclerView исчезает на некоторое время, когда никаких данных не загружено.
Мы рассмотрели использование Android Paging Library с Retrofit и Coroutines, а также несколько способов тестирования.
Весь код проекта доступен в этом репозитории.
Источник: Экспериментируем с…Paging Library, Retrofit, Coroutines, Koin и тестированием