GDG 판교의 ANDROID & CHAIN에서 Paging: Paged List Adapter를 발표하였는데 발표 내용을 정리한다.

표지

페이징 처리를 해주는 안드로이드의 구현, PagedListAdapter를 소개하는 자리를 가졌다.

발표가 20분의 길이기 때문에 PagedListAdapter에 대해 자세히 설명할 수는 없었다. 최대한 간단하게 사용법을 소개해주는 것을 목표로 했다.

PokeAPI

REST API로 페이징을 하는 것을 보여주려는 것이 목적인데 간단한 API를 직접 만드는 것은 비효율적이라 생각했다. 그래서 무료 REST API가 있을지 검색해보니 포케몬을 검색할 수 있는 PokeAPI를 찾았다.

JSON 요청을 정리하면 아래와 같은 형식이다.

{
 "count": 949,
 "previous": null,
 "results": [
   {
   "url": "https:\/\/pokeapi.co\/api\/v2\/pokemon\/1\/"
  ,
   "name": "bulbasaur"
   },
   ...
   {
   "url": "https:\/\/pokeapi.co\/api\/v2\/pokemon\/20\/"
  ,
   "name": "raticate"
   }
 ],
 "next": "https:\/\/pokeapi.co\/api\/v2\/pokemon\/?limit=20&offset=20"
}
data class Response(
 val count: Int,
 val previous: String,
 val next: String,
 val results: List<Result>
)
data class Result(
 val url: String,
 val name: String
)

results에 들어있는 urlnameResult 객체로 정리했고 results, count, previous, next를 묶어 Response로 정리했다.

이 부분과 아래 부분은 Paging 라이브러리를 쓰지 않을 때와 동일하다.

interface PokeAPI {
 @GET("pokemon/")
 fun listPokemons(): Call<Response>

 @GET("pokemon/")
 fun listPokemons(
   @Query("offset") offset: String,
   @Query("limit") limit: String
 ): Call<Response>
}

REST 인터페이스를 정의하는 방식도 일반적인 어댑터 패턴을 쓸 때와 크게 다르지 않다.

private class DiffItemCallback : DiffUtil.ItemCallback<Result>() {
 override fun areItemsTheSame(oldItem: Result, newItem: Result): Boolean =
 oldItem.url == newItem.url
 override fun areContentsTheSame(oldItem: Result, newItem: Result): Boolean =
 oldItem.name == newItem.name && oldItem.url == newItem.url
}

페이징 라이브러리를 쓸 때 느껴지는 첫번째 큰 차이는 DiffUtil.ItemCallback을 구현해야한다는 점이다. DiffUtil을 사용하여 변경점을 확인한다.

areItemsTheSame은 ID가 같은지 확인하여 같은 대상을 의미하는 객체인지 확인한다. 같은 대상이라도 갱신 시점 등에 따라 데이터의 내용이 다를 수 있기 때문에 areContentsTheSame을 호출하여 데이터의 변경을 확인하는 것이다.인스턴스 자체가 동일한지 비교하는 것은 의미가 없다 데이터가 외부환경에서 오는 경우 같은 항목인데 인스턴스가 다른 일이 종종있기 때문이다.

private class DataSource(private val pokeAPI: PokeAPI) : PageKeyedDataSource<String, Result>() {
  override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<String,
Result>) {
    val body = pokeAPI.listPokemons().execute().body()
    callback.onResult(body!!.results, body.previous, body.next)
 }

  override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<String, Result>) {
    val map = handleKey(params.key)
    val body = pokeAPI.listPokemons(map["offset"]!!, map["limit"]!!).execute().body()
    callback.onResult(body!!.results, body.previous)
  }

  override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, Result>) {
    val map = handleKey(params.key)
    val body = pokeAPI.listPokemons(map["offset"]!!, map["limit"]!!).execute().body()
 callback.onResult(body!!.results, body.next)
  }
  // 중략

페이징 라이브러리에서 데이터를 가져오는 과정은 DataSource로 위임한다. loadInitial에는 처음에 가져와야할 데이터를 담당하며, loadBeforeloadAfter는 각각 이전 페이지와 다음 페이지를 가져온다.

데이터를 가져온 후 callback.onResult를 호출하여 받아온 데이터와 다음에 사용할 키를 전달한다.

주의할 점은 DataSource가 자체적으로 데이터를 비동기상태로 가져오기 위해 백그라운드 스레드를 사용하기 때문에 loadXXX에서 UI 스레드등에서 사용하는 메서드를 호출해서는 안된다는 점이다.

private class Adapter : PagedListAdapter<Result, VieHolder>(DiffItemCallback()) {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VieHolder =
 VieHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_recyclerview, parent,
false))

  override fun onBindViewHolder(holder: VieHolder, position: Int) {
    getItem(position)?.let { (_, name) ->
      holder.title = name
    }
  }
}

어댑터는 PagedListAdapter를 상속받고 생성자는DiffUtil.ItemCallback을 전달해야 한다.

데이터를 가져오는 과정을 PagedListAdapterDataSrouce에서 하기 떄문에 우리가 구현해야할 코드의 분량은 매우 적다.

private fun createLiveData(): LiveData<PagedList<Result>> {
  val config = PagedList.Config.Builder()
    .setInitialLoadSizeHint(20)
    .setPageSize(20)
    .setPrefetchDistance(10)
    .build()
  return LivePagedListBuilder(object : android.arch.paging.DataSource.Factory<String,
  Result>() {
    override fun create(): android.arch.paging.DataSource<String, Result> {
      return MainActivity.DataSource(pokeAPI)
    }
  }, config).build()
}

설정을 만들어 초기에 가져올 사이즈(setInitialLoadSizeHint)와 페이즈 당 아이템의 개수(setPageSize), 얼마만큼 남았을 때 다음 데이터를 가저올지(setPrefetchDistance)를 지정한다.

다음으로 팩토리를 만들어 데이터소스를 생성한다. Dagger등을 사용할 경우에는 여기에 해당 부분을 주입시킨다.

주의할 점은 반환 값이 LiveData<PagedList<Result>> 타입이라는 것이다. 반환값은 Result 타입이 PagedList에 담겨 있는데 이 리스트는 사용자가 추가하거나 삭제할 수 없고 데이터 소스에 의해 자동으로 관리된다. 그리고 LiveData의 형태로 PagedList의 변경을 클라이언트에게 알린다.

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)
  recyclerView.apply {
    adapter = this@MainActivity.adapter
    layoutManager = LinearLayoutManager(this@MainActivity)
  }
  createLiveData().observe(this, Observer(adapter::submitList))
}

LiveData<PagedList<Result>>를 구독(observe)하여 변경 사항을 확인하고 어댑터의 submitList에 전달한다. 이 메서드는 PagedList에 원래 로직이 구현되어 있고 우리가 해야할 일은 별로 없다.

라이브 데이터는 새로운 페이지를 가져올 때 갱신되지 않는데 완전히 새로운 데이터 셋을 가져올 때만 변경되기 때문이다. 데이터 셋을 처음 가져올 때와 완전히 새로운 데이터를 가져올 때 만 observe가 호출된다.

발표자료와 소스코드는 Github 저장소를 참고하라.