May 11, 2020

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