Экспериментируем с 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 и тестированием