Android

RecyclerView ListAdapter를 통한 DiffUtil 사용법

seokzoo 2022. 9. 13. 23:41
반응형

안드로이드 개발을 한다면 가장 많이, 자주 쓰이는 것들 중 하나가 RecyclerView일것이다. 이번 포스팅을 기회로 완벽하게 정리해두고 기억이 안나거나 참고할 때 볼 수 있도록 해야겠다.

1. 먼저 RecyclerView란?

ListView는 스크롤하여 리스트 항목이 갱신될 때마다, 매번 아이템뷰를 새로 구성해야 했다. 하지만 리사이클러뷰는 이름 그대로 생성한 뷰를 "재활용"한다.

만약 10개의 리스트를 보여준다고 한다면 대략 17~20개정도의 뷰홀더 객체를 만든 뒤 사용자의 화면에서 벗어나는 경우 맨 위의 뷰가 다시 재활용(Recycle)되어 맨 밑으로 가 뷰의 데이터들이 bind된 뒤 다시 보여지게 된다.

2. RecyclerView를 구현하는데 필요한 요소

0.1 사용할 프래그먼트 or 액티비티의 레이아웃에 RecyclerView를 추가해준다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".main.view.MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/recyclerview_item" />

</LinearLayout>

0.2 리사이클러뷰에서 항목 하나하나를 담당해줄 xml을 만든다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:id="@+id/city"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="4dp"
        android:textSize="20dp"
        tools:text="SEOUL" />
</LinearLayout>

0.3 그에 따른 데이터 클래스도 만들어 준다.

data class CityInfo(val city: String) 

0.4 리사이클러뷰와 어댑터를 연결해준다.

val recycler = findViewById<RecyclerView>(R.id.recyclerView)
val recyclerViewAdapter = RecyclerViewAdapter()
recycler.adapter = recyclerViewAdapter

리사이클러뷰를 구현하는데 반드시 필요한 요소는 총 3가지이다.

1.Adapter

리사이클러뷰에 표시될 아이템뷰를 생성하는 역할을 담당, 사용자 데이터 리스트로부터 아이템 뷰를 만든다.

Adapter에서는 getItemCount, onCreateViewHolder, onBindViewHolder 함수를 override하여 구현해야 한다.

override fun getItemCount() = weatherList.size

먼저 getItemCount 함수는 전체 아이템의 개수를 리턴해주는 역할을 한다.
(위의 예제에서는 Adapter 내부에서 아이템의 List를 들고있다고 가정한다!)

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding: RecyclerviewItemBinding = 
            RecyclerviewItemBinding.inflate(inflater, parent, false)
        return WeatherViewHolder(binding)

    }

그 다음 onCreateViewHolder는 viewType에 따른 viewHolder를 생성해준다. 처음에 말했던 것 처럼 보여줄 리스트보다 몇개 많게 생성, 호출되며 그 이상으로는 호출되지 않는다. return에는 view객체 즉, 아이템 하나가 디자인 된 레이아웃을 넘겨준다. (위의 경우 viewBinding 객체를 넘겨주었다.)

이곳에서는 뷰타입에 대해서는 다루지 않으니 뷰 입 예제는 따로 찾아보자!
(뷰 타입은 RecyclerView에서 다양한 타입으로 리스트를 구성할 수 있게 만들어준다!)

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        (holder as WeatherViewHolder).onBind(weatherList[position])
}

onBindViewHolder는 함수의 이름 그대로 뷰홀더에 데이터들을 bind해준다. onBind 함수의 매개변수를 통해 아이템의 값들을 넘겨주면 레이아웃의 xml에 맞게끔 데이터를 세팅해주면 된다. 스크롤을 통해 데이터가 새로 bind된다면, 만약 맨 밑 데이터를 보고 있다가 스크롤을 통해 새 데이터를 본다면 새 데이터의 인덱스는 position을 통해 얻을 수 있다. 새롭게 올라온 데이터가 20번째 데이터라면 position이 20이 된다는 것이다.

몇 번의 onCreateViewHolder 이후 onBind만 호출되는 것을 볼 수 있다.

onBind 함수는 ViewHolder 내부에 선언해두었으니, weatherList[position] 을 통해 아이템 하나의 정보를 넘겨 ViewHolder 내부에서 아이템의 정보를 매칭시켜준다!

추가 내용(2022-03-18)

간혹 recyclerview가 깜빡인다거나, swipe시에 제대로 작동하지 않는 경우
Adapter 내부에 setHasStabledIds(true)와 getItemId를 통해 각각의 id를 지정해주면 잘 작동한다!

init {
        setHasStableIds(true)
     }

override fun getItemId(position: Int): Long {
        return getItem(position).id.hashCode().toLong()
     }

2.LayoutManager

레이아웃 매니저는 리사이클러뷰가 아이템들을 내부에 어떤식으로 배치하여 보여줄지 설정해주는 기능을 한다. 종류에는 Linear, Grid, Staggerd가 있다. 레이아웃 매니저 설정에는 2가지 방법이 있는데,

binding.recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)

첫번째 방법은 직접 코드로 설정해주는 방법이 있다.
LinearLayoutManager를 설정해주고, context, Orientation(VERTICAL, HORIZENTAL), 아래의 데이터부터 보여줄지 여부를 순서대로 체크해줄 값들을 인자로 넘겨준다.

<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/recyclerview_item" />

두번째 방법은 xml에서 직접 설정해주는 방법이다.

개인적으로 xml에 작성해주는 것이 액티비티나 프래그먼트의 코드 줄 수를 줄여줘 더 깔끔하게 보인다고 생각한다! 하지만 말 그대로 개인의 생각이니 정답은 아니다!

3.ViewHolder

뷰홀더는 화면에 표시될 아이템 뷰(View)를 가지고있(Hold)는 객체이다. 필요에 따라 어댑터에 의해 생성되는데, 이미 있는 경우에는 재활용된다. 이때는 onBindViewHolder에서 이미 생성된 뷰홀더에 데이터만 bind해준다. 리사이클러뷰는 ViewHolder 패턴을 강제한다. (뷰홀더를 반드시 만들어서 가지고 있어야한다)

class WeatherViewHolder(private val binding: ItemWeatherBinding) :
    RecyclerView.ViewHolder(binding.root) {
    fun onBind(cityInfo: CityInfo) {
        binding.city.text = cityInfo.city.toString()
    }
}

위의 예제에서는 weatherList[position]로 넘겨준 데이터를 xml 데이터에 bind시켜준다.

이렇게 3가지는 "반드시 필요한"요소이다.

3. 이렇게 좋은 리사이클러뷰도 아직 부족하다?

notifyDataSetChanged를 이용할 때, Adapter가 이미 존재하는 전체 데이터셋의 아이템들 중 어떤 것이 변경된 것인지 몰라 viewHolder를 전부 다시 매칭시켜준다. 이때문에 깜빡임 이슈가 존재한다. (setHasStableIds 또는 각각의 유니크한 id를 주면 해결이 가능하다)

리스트를 수정할 때 모든 부분을 수정하는 방법 보다는 변경할 부분만 변경하는 방법(예를 들어 10개중에서 2개만 바꾸는 방법)이 더욱 효율적일 것이다.

그래서 나온것이 DiffUtil이다.


4. 드디어 나온 DiffUtil

서론이 길었는데, 이 글의 목적은 ListAdapter의 DiffUtil을 이용한 데이터 변경이다 (앞의 리사이클러뷰의 구현, 설명에 대해 모두 안다면 여기부터 읽으면 된다!).

먼저 DiffUtil에서는 이전 리스트와 현재 리스트의 차이를 계산하는 areItemsTheSame과 areContentsTheSame를 override 해주어야 한다. 파라미터로 비교해줄 아이템의 타입을 적어준다.

아래에서는 이미지와 싱크를 맞추기 위해 oldItem과 newItem을 Flower 타입으로 맞추어 줬으나,
각각의 프로젝트에서는 각각 비교해줄 아이템의 타입을 전달해 주어야 한다!

아래 예제에서는 Flower class의 id, name, image, description을 비교한다.


areItemsTheSame()

override fun areItemsTheSame(oldItem: Flower, newItem: Flower): Boolean {
        return oldItem.id == newItem.id
    }

areItemsTheSame은 return값으로 Boolean값을 갖게 되는데, 이를 통해 이전 항목과 동일한지 동일성 테스트를 하게 된다. 동일하다면 true를 리턴, 값이 바뀌었다면 false를 리턴하여 항목이 추가,제거 또는 이동되었는지 확인한다. 보통 고유한 값을 가지고 있는 id를 비교한다.


areContentsTheSame

override fun areContentsTheSame(oldItem: Flower, newItem: Flower): Boolean {
        return oldItem == newItem
    }

areContentsTheSame도 return값을 통해 Boolean값을 가지고 있고, 동등성 검사를 통해 검사한다. Item이 Data Class인 경우 equals를 통해 모든 필드를 검사하고 변경점이 있는 경우 DiffUtil에 list가 업데이트 되었다고 알려준다.


getChangePayload

areItemsTheSame() && !(areContentsTheSame()) 가 true인 경우 해당 메소드가 호출되어 페이로드를 가져온다. default는 null이고 필요한 경우에만 구현해주면 된다.


4-1 ListAdapter에서의 DiffUtil은 위의 areItemsTheSame과 areContentsTheSame만 구현해주면 되지만, RecyclerView에서의 DiffUtil 구현방법이 조금 다르다.

DiffUtil (RecyclerView에서 사용하는 방법)

4가지의 함수를 재정의 해야한다.

  • getOldListSize : 현재 리스트의 size
  • getNewListSize : 새로 추가, 갱신할 리스트의 size
  • areItemsTheSame : 현재의 리스트에 노출하고 있는 아이템과 새로운 아이템이 같은지 비교한다. 보통 고유 값을 체크한다(ex: id)
  • areContentsTheSame : 현재 리스트에 노출하고 있는 아이템과 새로운 아이템의 동등성을 검사한다.
private class Diff(
    private val oldItems: List<Any>,
    private val newItems: List<Any>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int =
        oldItems.size

    override fun getNewListSize(): Int =
        newItems.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem.id == newItem.id
    }


    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem == newItem
    }
}

이전의 ListAdapter에서 사용하는 것 보다 조금 더 길고 복잡하다. 하지만 코드는 단순하다. 아이템의 포지션을 통해 값을 비교해준다.

또한 DiffUitl.ItemCallback<값>()를 이용해 areItemsTheSame, areContentsTheSame만 구현해줄 수도 있다.

5. ListAdapter 사용법

이제 필요한 구현이 모두 끝났다. Adapter에서 지금까지 사용했던 RecyclerView.Adapter를 사용할 필요가 없어졌다.

이전에 RecyclerView.Adapter를 썼던 곳에 ListAdapter로 바꾸어주고 첫번째 파라미터에는 비교해줄 때 사용했던 타입인 WeatherModel, 두 번째 파라미터에는 뷰홀더, 그리고 ()안에 사용자 DiffCallback을 넣어주면 편리하게 사용이 가능하다.

ListAdapter<WeatherModel, RecyclerView.ViewHolder>(WeatherDiffCallback)

또한 ListAdapter를 이용하면 이전의 RecyclerView처럼 List를 Adapter에 넘겨주지 않아도 submitList에 List를 보내주면 그 안에서 getItem(position)을 통해 사용할 수 있다.

--MainActivity--
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        val adapter = WeatherAdapter()
        adapter.submitList(weahterList.toList())   
    }

--WeatherAdapter--
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
       // getItem(position) 을 통해 위의 MainActivity에서 넘긴 weatherList를 사용 가능
       (holder as WeatherViewHolder).onBind(getItem(position))
}        

또한 submitList를 할 때, 이전의 리스트와 주소값이 동일하다면 리스트의 갱신이 되지 않을수도 있다. 그러니 .toList() 를 붙여 리스트의 주소값을 계속해서 바꿔주면 즉시 갱신이 되니 갱신이 안된다면 .toList()를 붙여보길 바란다!

6. 끝으로

지금까지는 RecyclerView와 ListAdapter에 대한 "사용법"이였습니다.
사실 리사이클러뷰는 내부적으로 5개의 함수가 더 호출이되어 총 7개의 사이클을 가집니다.

리사이클러뷰가 제일 처음 화면에 붙을 때 onAttachedToRecyclerView, 뷰홀더가 생성될 때 onCreateViewHolder, 뷰홀더에 데이터를 연결해 줄 때 onBindViewHolder, 연결 된 뷰홀더가 화면에 보일 때 onViewAttachedToWindow, 뷰 홀더가 더이상 보이지 않을 때 onDetachedToWindow, 뷰가 재활용 될 때 알려주는 onViewRecycled, 마지막으로 RecyclerView가 더이상 화면에서 필요하지 않을 때 onDetachedFromRecyclerView가 있습니다.

다만 저희는 내부적으로 모두 구현되어 있기에 onCreateViewHolder와 onBindViewHolder만 재선언하여 구성해주면 되는 것일 뿐입니다.

이와 관련해서 위의 내용을 모두 이해하셨다면, 아래 링크를 읽어보시는 것을 추천드립니다!
Recyclerview의 내부 작동

지금까지 RecyclerView와 ListAdapter의 사용법이였습니다. 감사합니다!

7. 출처 및 참고

https://www.ericthecoder.com/2020/08/17/android-developer-2020-skillset-what-you-need-to-know-to-get-any-job-android-trends/
https://wooooooak.github.io/android/2019/03/28/recycler_view/
https://medium.com/androiddevelopers/adapting-to-listadapter-341da4218f5b
https://mashup-android.vercel.app/yuchocopie/recyclerview/ListAdapter/
https://blog.yena.io/studynote/2017/12/06/Android-Kotlin-RecyclerView1.html
https://thdev.tech/kotlin/2020/09/22/kotlin_effective_03/
https://ppizil.tistory.com/38

반응형