Kotlin

Пагинация — достаточно известная, но трудная в реализации функция. Поэтому я решил разработать демонстрационное приложение на основе 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…