Пагинация — достаточно известная, но трудная в реализации функция. Поэтому я решил разработать демонстрационное приложение на основе 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()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
// 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, а также несколько способов тестирования.
Весь код проекта доступен в этом репозитории.
Перевод статьи Philippe BOISNEY: Playing with…