관리 메뉴

나만을 위한 블로그

[Android] 페이징 라이브러리, Hilt, LiveData로 Github API 사용하기 본문

Android

[Android] 페이징 라이브러리, Hilt, LiveData로 Github API 사용하기

참깨빵위에참깨빵 2023. 4. 9. 19:49
728x90
반응형

23.05.02 - GithubPagingSource 소스코드 추가

 

페이징 라이브러리 3을 사용해 Github API를 사용하는 법을 확인한다. 먼저 사용할 라이브러리는 아래와 같다.

 

  • 페이징 라이브러리 3
  • Hilt
  • Flow (repository에서 데이터를 가져올 때만 사용. 이후 LiveData 사용)
  • LiveData
  • Data Binding

 

그리고 리사이클러뷰에 PagingDataAdapter를 적용할 것이다. PagingDataAdapter는 내부적으로 생성자를 통해 DiffUtil을 받아서 AsyncPagingDataDiffer를 만들어 사용하기 때문에, ListAdapter를 사용할 때와 같이 아이템 변경 시 애니메이션 효과 등을 적용받을 수 있다.

깃허브 API를 사용하기 위해 API key를 받는 과정, Hilt 의존성 적용 과정은 생략하고 바로 코드부터 본다.

 

const val GITHUB_API_HEADER_PREFIX = "Authorization"

// 레포 검색
const val SEARCH_REPO_URL = "search/repositories?sort=stars"
const val QUERY = "q"
const val PAGE = "page"
const val PER_PAGE = "per_page"

const val NETWORK_PAGE_SIZE = 50    // 한 번에 50개 씩 서버에 데이터 요청
const val STARTING_PAGE_INDEX = 1
const val IN_QUALIFIER = "in:name,description"  // 검색어 뒤에 붙이는 검색 조건

 

파일 수준 선언으로 클래스를 하나 만들고 그 안에 이것들을 넣는다. object로 만들어 싱글톤하게 관리할 필요는 없어 보여서 파일 수준 선언을 사용했다.

 

import com.example.kotlinprac.networkstate.two.di.util.AuthenticationInterceptor
import com.example.kotlinprac.paging.prac.GITHUB_API_HEADER_PREFIX
import com.example.kotlinprac.paging.prac.GithubApiService
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
class AppModule {

    @Provides
    fun provideGson(): Gson = GsonBuilder().create()

    @Singleton
    @Provides
    fun provideAuthenticationInterceptor(): AuthenticationInterceptor = AuthenticationInterceptor()

    @Singleton
    @Provides
    fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
        val httpLoggingInterceptor = HttpLoggingInterceptor()
        httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
        return httpLoggingInterceptor
    }

    @Singleton
    @Provides
    fun provideHttpClient(
        logging: HttpLoggingInterceptor,
        auth: AuthenticationInterceptor
    ): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(logging)
        .addInterceptor(auth)
        .connectTimeout(1, TimeUnit.MINUTES)
        .readTimeout(1, TimeUnit.MINUTES)
        .writeTimeout(1, TimeUnit.MINUTES)
        .addInterceptor { chain ->
            chain.proceed(
                chain.request()
                    .newBuilder()
                    .header(GITHUB_API_HEADER_PREFIX, "token 여기에_토큰_입력")
                    .build()
            )
        }
        .build()

    @Singleton
    @Provides
    fun provideGithubApiService(client: OkHttpClient): GithubApiService = Retrofit.Builder()
        .baseUrl("https://api.github.com/")
        .addConverterFactory(GsonConverterFactory.create(provideGson()))
        .client(client)
        .build()
        .create(GithubApiService::class.java)
}

 

깃허브 API key를 받아서 "token " 뒤에 토큰을 입력한다. 토큰이 aaa라면 "token aaa" 형태로 입력하면 된다.

 

import retrofit2.http.GET
import retrofit2.http.Query

interface GithubApiService {
    @GET("search/repositories?sort=stars")
    suspend fun searchRepos(
        @Query(QUERY) query: String,
        @Query(PAGE) page: Int,
        @Query(PER_PAGE) perPage: Int
    ): RepoSearchResponse
}

 

레트로핏 쓸 거니까 인터페이스 정의해 준다.

 

class GithubPagingSource(
    private val service: GithubApiService,
    private val query: String
): PagingSource<Int, Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(query, position, params.loadSize)
            val repos = response.items
            val nextKey = if (repos.isEmpty()) null else position + (params.loadSize / NETWORK_PAGE_SIZE)

            LoadResult.Page(
                data = repos,
                prevKey = if (position == STARTING_PAGE_INDEX) null else position - 1,
                nextKey = nextKey
            )
        } catch (exception: IOException) {
            LoadResult.Error(exception)
        } catch (exception: HttpException) {
            LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? =
        state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
    }
}

 

데이터를 가져오는 방법을 정의하는 PagingSource다. 제네릭 1번 인자는 보통 Int를 사용하고 2번 인자는 data class를 넣어준다.

 

import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class GithubRepository @Inject constructor(
    private val service: GithubApiService
) {
    fun getSearchRepoResult(query: String): LiveData<PagingData<Repo>> {
        return Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
                prefetchDistance = 200
            ),
            pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow.asLiveData()
    }
}

 

repository를 만들고 GithubApiService에 정의한 함수를 사용한다. 주의할 것은 리턴하는 값은 Pager 형태로 리턴해야 한다.

그리고 특이한 게 있다면 prefetchDistance에 200이라는 값을 넣었는데, 이것은 리사이클러뷰에 현재 표시중인 아이템이 몇 개 남았을 때 다음 데이터를 가져오라고 설정하는 코드다. 숫자를 얼마나 입력해야 지연 없이 부드럽게 데이터를 가져오는지 확인하려고 200을 넣었는데 IDE에서 컨트롤 클릭해 문서를 확인하면 일반적으로 화면에 표시되는 아이템 개수의 몇 배만큼 설정하라고 한다.

repository를 만들었으니 이제 ViewModel을 만든다.

 

import androidx.lifecycle.*
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class GithubViewModel @Inject constructor(
    private val repository: GithubRepository
): ViewModel() {
    private val _searchQuery = MutableLiveData<String>()
    val searchQuery: LiveData<String> = _searchQuery

    private val repoResult: LiveData<PagingData<Repo>> = _searchQuery.switchMap { query ->
        if (query.isBlank()) {
            MutableLiveData(PagingData.empty())
        } else {
            repository.getSearchRepoResult(query).cachedIn(viewModelScope)
        }
    }

    val repos: LiveData<PagingData<Repo>> = repoResult

    fun searchRepos(query: String) {
        _searchQuery.value = query
    }
}

 

검색어가 바뀔 때마다 바뀐 검색어로 검색한 값을 가져와야 하기 때문에 switchMap {}을 사용했다. 그 외엔 특별한 게 없는 코드다.

밑준비는 끝났고 이제 UI를 만든다. 페이징 라이브러리를 확인하는 용도로 만든 예제기 때문에 UI에 많이 신경쓰지 않았다. 아래는 리사이클러뷰 아이템 XML이다.

 

<!-- item_repo.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="repository"
            type="com.example.kotlinprac.paging.prac.Repo" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp">

        <TextView
            android:id="@+id/repo_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{repository.name}"
            android:textSize="18sp"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/repo_description"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="@{repository.description}"
            android:textSize="14sp"
            app:layout_constraintStart_toStartOf="@+id/repo_name"
            app:layout_constraintTop_toBottomOf="@+id/repo_name" />

        <TextView
            android:id="@+id/repo_stars"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="@{Integer.toString(repository.stars)}"
            android:textSize="14sp"
            app:layout_constraintStart_toStartOf="@+id/repo_description"
            app:layout_constraintTop_toBottomOf="@+id/repo_description" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

그리고 어댑터다.

 

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinprac.databinding.ItemRepoBinding

class RepoListAdapter: PagingDataAdapter<Repo, RepoListAdapter.RepoViewHolder>(diffUtil) {

    companion object {
        private val diffUtil = object : DiffUtil.ItemCallback<Repo>() {
            override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem.id == newItem.id
            override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem == newItem
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): RepoListAdapter.RepoViewHolder {
        return RepoViewHolder(ItemRepoBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }

    override fun onBindViewHolder(holder: RepoListAdapter.RepoViewHolder, position: Int) {
        getItem(position)?.let {
            holder.bind(it)
        }
    }

    inner class RepoViewHolder(
        private val binding: ItemRepoBinding
    ): RecyclerView.ViewHolder(binding.root) {
        fun bind(repo: Repo) {
            binding.apply {
                repository = repo
                executePendingBindings()
            }
        }
    }
}

 

서두에 PagingDataAdapter는 내부적으로 DiffUtil을 받는다고 했는데 그 말처럼 실제 구현 시 diffUtil이란 companion object를 만들어 PagingDataAdapter의 생성자 매개변수로 넣어주고 있다. 그 외에는 리사이클러뷰를 만들 때 구현하는 재정의 함수들과 잡다한 것들이라 생략한다.

액티비티 XML 파일은 아래처럼 작성한다.

 

<!-- activity_github_paging.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.example.kotlinprac.paging.prac.GithubViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".paging.prac.GithubPagingActivity">

        <EditText
            android:id="@+id/etSearchQuery"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:hint="Search repositories"
            android:imeOptions="actionSearch"
            android:inputType="text"
            android:maxLines="1"
            app:layout_constraintEnd_toStartOf="@+id/btnSearch"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/btnSearch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:text="Search"
            app:layout_constraintBaseline_toBaselineOf="@+id/etSearchQuery"
            app:layout_constraintEnd_toEndOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rvRepoList"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            android:orientation="vertical"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/etSearchQuery" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

마지막으로 액티비티 파일에서 지금까지 만든 모든 것들을 활용한다.

 

import android.os.Bundle
import android.view.View.GONE
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import com.example.kotlinprac.BaseActivity
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ActivityGithubPagingBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber

@AndroidEntryPoint
class GithubPagingActivity :
    BaseActivity<ActivityGithubPagingBinding>(R.layout.activity_github_paging) {

    private val githubViewModel: GithubViewModel by viewModels()
    private val repoListAdapter = RepoListAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        bind {
            viewModel = githubViewModel
            lifecycleOwner = this@GithubPagingActivity

            rvRepoList.apply {
                addItemDecoration(
                    DividerItemDecoration(
                        this@GithubPagingActivity,
                        DividerItemDecoration.VERTICAL
                    )
                )
                adapter = repoListAdapter
            }

            etSearchQuery.setOnEditorActionListener { _, actionId, _ ->
                if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                    val query = etSearchQuery.text.toString()
                    if (query.isNotEmpty()) {
                        githubViewModel.searchRepos(query)
                    }
                    true
                } else {
                    false
                }
            }

            btnSearch.setOnClickListener {
                val query = etSearchQuery.text.toString()
                if (query.isNotEmpty()) {
                    githubViewModel.searchRepos(query)
                }
            }
        }

        githubViewModel.repos.observe(this@GithubPagingActivity) { pagingData ->
            lifecycleScope.launch {
                repoListAdapter.submitData(pagingData)
            }
        }

        repoListAdapter.addLoadStateListener { loadState ->
            val errorState = when {
                loadState.prepend is LoadState.Error -> loadState.prepend as LoadState.Error
                loadState.append is LoadState.Error -> loadState.append as LoadState.Error
                loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
                else -> null
            }
            errorState?.let {
                Timber.e("## 에러 : ${it.error}")
                Toast.makeText(this, "\uD83D\uDE28 에러 : ${it.error}", Toast.LENGTH_LONG).show()
            }
        }

    }
}

 

BaseActivity 파일은 아래와 같다.

 

abstract class BaseActivity<T : ViewDataBinding>(
    @LayoutRes private val layoutId: Int
) : AppCompatActivity() {
    protected val binding: T by lazy(LazyThreadSafetyMode.NONE) {
        DataBindingUtil.setContentView(this, layoutId)
    }

    init {
        addOnContextAvailableListener {
            binding.notifyChange()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.lifecycleOwner = this
    }

    override fun onDestroy() {
        binding.unbind()
        super.onDestroy()
    }

    protected inline fun bind(block: T.() -> Unit) = binding.apply(block)
}

 

이제 앱을 실행하고 아무 단어나 검색해보면 아래와 같이 작동할 것이다.

 

 

처음 android를 입력하고 검색 버튼을 눌러 잠시 기다리면 android 관련 레포 검색 결과들이 표시된다. 그리고 다음 검색어를 미리 입력해놓고 스크롤하면 특정 위치까지 스크롤하면 계속해서 페이징 데이터들을 받아와 리사이클러뷰에 표시한다.

ios라는 다른 검색어로 검색하면 잠시 후 아이템의 위치가 미묘하게 바뀌고 내용이 업데이트된다.

이제 이 기본 코드를 바탕으로 자신이 원하는 페이징 기능을 구현할 수 있다.

반응형
Comments