효율적인 RecyclerView 적용

박재원·2022년 1월 20일
post-thumbnail

RecyclerView란?

  • RecyclerView 는 ListView의 단점을 보완하여 Android 5.0부터 적용
  • 기존 ListView는 아이템들을 생성할 때 마다 뷰 바인딩을 해서 성능저하 발생
  • RecyclerView에서는 ViewHolder라는 개념 적용하여 최초 바인딩 이후 뷰를 재활용하여 성능저하를 막아준다.

RecyclerView 주요 클래스 및 설명

  1. ViewHolder
    각각의 뷰를 보관하는 Holder 객체
    아이템 뷰를 재활용하기 위해 각 요소를 저장해두고 사용할 수 있도록 함
  2. LayoutManager
    아이템의 배치를 담당
    LinearLayoutManager : 가로 또는 세로 스크롤 목록
    GridLayoutManager : 그리드 형식의 목록
    StaggeredGridLayoutManager : 지그재그형의 그리드 형식 목록
  3. Adapter
    기존의 ListView에서 사용하는 Adapter와 같은 개념으로, 데이터와 아이템에 대한 view 생성

기초적인 RecyclerView

기초적으로 RecyclerView를 사용하면, 아래와 같이 Adapter클래스를 생성하고, RecyclerView클래스의 Adapter를 상속받아 구현한다.

class UserRecyclerViewAdapter : RecyclerView.Adapter<UserRecyclerViewAdapter.MyViewHolder>() {
    private val userList = ArrayList<User>()
    class MyViewHolder(private val binding:ItemListBinding) : RecyclerView.ViewHolder(binding.root){
        fun bind(user:User){
            binding.apply {
                binding.user = user
            }
        }
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(
            ItemListBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.apply { bind(userList[position]) }
    }
    override fun getItemCount(): Int = userList.size
    fun setItem(items: ArrayList<User>) {
        userList.clear()
        userList.addAll(items)
        notifyDataSetChanged()
    }
}

이를 fragment등에서 사용하는 방법은 아래와 같다.

val adapter = UserRecyclerViewAdapter()
// 구현한 Adapter를 등록한다.
binding.recyclerView.adapter = adapter
// Item으로 사용될 List(userList)를 넘겨준다.
adapter.setItem(userList)
// RecyclerView 갱신을 위해 사용
adapter.notifyDataSetChanged()

이렇게 사용할 경우, Recyclerview의 데이터가 변하면 notifyDataSetChanged 메소드를 사용해서 ViewHolder 내용을 갱신해줘야 한다.
데이터가 변경될 때 마다 notifyDataSetChanged를 계속 호출 해줘야 하기에 번거롭기도 하고, 또 갱신이 필요없는 항목들까지 전부 갱신하여 메모리 리소스를 많이 사용한다는 단점이 있다. 또한 갱신 시 화면이 깜빡이는 이슈도 있다.
이는 DiffUtil을 사용하여 해결할 수 있다.

DiffUtil

https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil
DiffUtil은 두 Data Set을 받아서 그 차이를 계산해주는 클래스이다.
DiffUtil을 사용하면 두 Data Set을 비교한 뒤 그 중 변한 부분만을 파악하여 RecyclerView에 반영할 수 있다.
참고로 DiffUtil은 Eugene W. Myers의 difference 알고리즘을 이용해서 O(N + D^2)시간 안에 리스트의 비교를 수행한다.
(N:추가 및 제거된 항목의 갯수, D: 스크립트의 길이)
DiffUtil은 아래와 같은 절차를 거친다.

  1. DiffUtil.ItemCallback()을 상속받아 areItemsTheSame으로 비교대상인 두 객체가 동일한지 확인
  2. areContentsTheSame으로 두 아이템이 동일한 데이터를 가지는지 확인
    DiffUtil.ItemCallback 구현
object TermsListDiffCallback : DiffUtil.ItemCallback<User>() {
    override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem == newItem
    }
    override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem.id == newItem.id
    }
}

이 DiffUtil은 위에서 언급한 바와 같이 O(N + D^2)의 시간 복잡도를 가진다고 했으므로, List의 양이 많아지면 그만큼 비교해야 할 대상이 많아지므로, Main Thread에 작성하기엔 어려움이 있다.
Recyclerview 어댑터를 만들때 ListAdapter를 상속하도록 하고, 이 DiffUtil.Callback객체를 받도록 하면, 백그라운드 Thread에서 동작하는 효율적인 RecyclerView를 구현할 수 있다.

ListAdapter

위의 UserRecyclerViewAdapter.kt를 리팩토링한 소스이다.

package com.example.recyclerview2.adapter
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.recyclerview2.data.User
import com.example.recyclerview2.databinding.ItemListBinding
class UserRecyclerViewAdapter :
    ListAdapter<User, UserRecyclerViewAdapter.MyViewHolder>(TermsListDiffCallback) {
    class MyViewHolder(private val binding: ItemListBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(user: User) {
            binding.user = user
        }
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(
            ItemListBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.apply {
            bind(getItem(position))
        }
    }
    object TermsListDiffCallback : DiffUtil.ItemCallback<User>() {
        override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
            return oldItem == newItem
        }
        override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
            return oldItem.id == newItem.id
        }
    }
}

우선 달라진 점은
1. UserRecyclerViewAdapter클래스가 RecyclerView.Adapter를 상속받는 대신, ListAdapter를 상속받는다.
2. DiffUtil을 구현한 TermsListDiffCallback객체를 매개변수로 전달해준다.
3. Item의 size를 반환하는 getItemCount() 메서드와 Items를 Set해주는 setItem(items: ArrayList) 메서드가 사라졌다.
이를 아래와 같이 사용한다.

val adapter = UserRecyclerViewAdapter()
binding.recyclerView. = adapter
adapter.submitList(userList)

기존에 별도로 구현했던 setItems메서드 대신 ListAdapter클래스의 submitList메서드를 사용한 것을 볼 수 있다.
이렇게 RecyclerView를 DiffUtil과 ListAdapter를 활용하여 보다 효율적으로 구현할 수 있다.

추가 (RecyclerView갱신 안되는 Issue 해결 내용 정리)

BindingAdapter를 사용하여 RecyclerViewAdapter를 아래와 같이 연결하는 도중, 화면 갱신이 되지 않는 이슈를 만났다. 이슈를 분석해 본 결과, 원인은 submitList 메서드에 있었다.

submitList(gitUserList)

submitList메서드를 타고 들어가다보면, AsyncListDiffer.java 클래스에서 아래와 같은 로직을 볼 수 있다.

newList와 기존List를 비교하여, 같으면 return 하는 로직이다.
애초에 submitList로 전달하는 객체(여기서는 gitUserList:ArrayList)를 계속 같은 객체에다가 값만 변경하여 넣어주었더니, 같은 주소를 참조하여 저 로직에서 같다고 판단한 것.
따라서 해결 방법은
1. 애초에 submitList를 할 때 새로운 객체를 넣어주던가
2. submitList(gitUserList.toMutableList())와 같이 새로운 객체에 담아 넣어주면 된다.

profile
Android developer.

0개의 댓글