[Android] RecyclerView, ViewHolder 패턴, DiffUtil 클래스란?

WonseokOh·2022년 5월 8일
4

Android

목록 보기
11/16
post-thumbnail

RecyclerView

  예전 안드로이드 개발 시에 리스트 형태로 데이터를 표현해주는데 사용되는 클래스는 ListView 였습니다. 하지만 데이터의 크기가 커질수록 뷰를 생성함으로써 메모리 부족 현상이 발생하게 되고 getView 메소드에서는 계속된 findViewById를 사용하였기에 비효율적이었습니다. 이를 개선한 위젯이 RecyclerView로 화면이 보여지는 뷰까지만 생성한 후 스크롤 시에 가려지게 되는 뷰들로 재사용하여 새로운 뷰들을 보여줍니다. 또한, RecyclerView는 findViewById의 값 비싼 비용을 방지하고자 ViewHolder 패턴을 강제화 시킨 ListView라고 볼 수도 있습니다.


ViewHolder 패턴이란?

ViewHolder 패턴을 이해하기 위해서는 ViewHolder 패턴이 왜 필요한지부터 이해하면 좋습니다.

ListAdapter의 문제점

class ListAdapter(private val items : List<String>): BaseAdapter() {
    override fun getCount(): Int = items.size

    override fun getItem(position: Int): String = items.get(position)

    override fun getItemId(position: Int): Long = position.toLong()

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        var view = convertView
        
        if(view == null){
            view = LayoutInflater.from(parent?.context).inflate(R.layout.item_list, parent, false)
        }

        view?.findViewById<TextView>(R.id.textView).text = items.get(position)
        
        return view!!
    }
}

  위의 코드는 ListView의 어댑터인 ListAdapter 클래스로 getView 메소드가 position에 해당하는 View를 반환하는 메소드입니다. getView 메소드의 파라미터로 convertView가 존재하는데 convertView는 재사용되는 뷰로서 ListView가 스크롤하여 이전에 생성했던 뷰가 가려졌을 때 해당 뷰로 재사용하게 됩니다. 즉, 보여주고자 하는 모든 데이터 갯수만큼 뷰를 생성하면 메모리가 낭비하게 되므로 화면에 보여지는 뷰만큼만 생성하고 재사용한다는 것입니다.

  뷰를 재사용하여 메모리 낭비는 방지하였지만 getView 메소드마다 findViewById가 호출됩니다. 간단한 뷰면 크게 문제되지 않지만 복잡한 뷰일수록 findViewById의 호출도 많아지고 findViewById의 비용도 커지게 됩니다. findViewById의 원리가 궁금하시면 아래 링크를 통해 확인할 수 있습니다.


RecyclerView의 Adapter

class CustomAdapter(private val dataSet: Array<String>) :
        RecyclerView.Adapter<CustomAdapter.ViewHolder>() {

    /**
     * Provide a reference to the type of views that you are using
     * (custom ViewHolder).
     */
    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val textView: TextView

        init {
            // Define click listener for the ViewHolder's View.
            textView = view.findViewById(R.id.textView)
        }
    }

    // Create new views (invoked by the layout manager)
    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
        // Create a new view, which defines the UI of the list item
        val view = LayoutInflater.from(viewGroup.context)
                .inflate(R.layout.text_row_item, viewGroup, false)

        return ViewHolder(view)
    }

    // Replace the contents of a view (invoked by the layout manager)
    override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {

        // Get element from your dataset at this position and replace the
        // contents of the view with that element
        viewHolder.textView.text = dataSet[position]
    }

    // Return the size of your dataset (invoked by the layout manager)
    override fun getItemCount() = dataSet.size
}

  위의 코드는 Android 공식문서에 있는 코드로 추상 클래스인 RecyclerView.Adapter를 상속받은 CustomAdapter 클래스입니다. CustomAdapter 내에는 내부 클래스인 ViewHolder 클래스가 존재하는데 RecyclerView.Adapter를 상속받기 위해서는 RecyclerView.ViewHolder가 정의되어야 하기 때문에 ViewHolder 클래스를 생성하였습니다. 이 ViewHolder 클래스가 findViewById의 단점을 개선시킬 수 있는 방법으로 해당 클래스에서 레이아웃에 존재하는 모든 뷰를 선언하고 할당까지 한 상태로 View를 가지고 있게 됩니다.

먼저 RecyclerView의 동작 순서를 살펴보겠습니다.

1. 화면에 보여질 뷰보다 2~5개 더 많게 뷰홀더를 생성합니다. (onCreateViewHolder)
2. 각 뷰홀더에 position에 해당하는 데이터를 바인딩합니다. (onBindViewHolder)
3. 스크롤 시에 가려져서 보이지 않은 뷰홀더에 새로운 position의 데이터를 바인딩합니다.

RecyclerView는 기본적으로 재사용 뷰를 사용하게 되는데 getView 메소드와 차이점은 findViewById를 하지 않아도 레이아웃 내에 존재하는 뷰에 대한 참조를 할 수 있어서 데이터를 바인딩만 시키면 됩니다. 이로 인해 findViewById는 onCreateViewHolder가 호출되었을 때만 사용하기에 성능 개선이 되었다고 볼 수 있습니다. 😀


notifyDataSetChanged

  위의 내용만 볼 때에 메모리 낭비도 개선하였고 findViewById도 개선하여서 RecyclerView는 문제점이 모두 개선이 되었다고 볼 수 있습니다. 하지만 RecyclerView에서도 비효율적인 구조가 존재하는데 그것은 바로 새로운 데이터 리스트들을 적용할 때입니다. RecyclerView를 한 번이라도 사용하신 분들은 notifyDataSetChanged 메소드를 호출하면 기존에 보여줬던 리스트를 갱신시켜 새로운 데이터들로 보여준다는 것을 알고 있을 것입니다.

        public void notifyChanged() {
            // since onChanged() is implemented by the app, it could do anything, including
            // removing itself from {@link mObservers} - and that could cause problems if
            // an iterator is used on the ArrayList {@link mObservers}.
            // to avoid such problems, just march thru the list in the reverse order.
            for (int i = mObservers.size() - 1; i >= 0; i--) {
                mObservers.get(i).onChanged();
            }
        }

notifyDataSetChanged 메소드 내부에는 notifyChanged 메소드가 호출되는데 모든 데이터들을 하나씩 변경을 하고 있습니다. 기존에 바인딩 했던 데이터들을 지우고 다시 처음부터 바인딩하고 렌더링 하는 과정을 거치게 됩니다. 만약 100개의 데이터 중 1개의 데이터 값만 변경했지만 전체 데이터를 바인딩하고 렌더링 과정을 거친다면 매우 비효율적인 구조가 됩니다. 이를 개선시켜줄 수 있는 방법이 DiffUtil을 이용하는 것입니다.


DiffUtil 클래스

  위와 같은 문제를 해결하기 위해 사용되는 DiffUtil 클래스는 Eugene W.Myers's difference algorithm을 이용하여 데이터의 변경된 부분만 업데이트하도록 되어 있습니다.

DiffUtil.Callback

    /**
     * A Callback class used by DiffUtil while calculating the diff between two lists.
     */
    public abstract static class Callback {
 
        public abstract int getOldListSize();

        public abstract int getNewListSize();

        public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition);

        public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition);

        @Nullable
        public Object getChangePayload(int oldItemPosition, int newItemPosition) { return null; }
    }

위의 코드는 DiffUtil.Callback 추상클래스로 4개의 추상메소드와 1개의 메소드가 존재합니다.

  • getOldListSize : 기존에 있던 리스트의 크기 반환
  • getNewListSize : 변경될 리스트의 크기 반환
  • areItemsTheSame : 두 개의 아이템이 같은지 확인, 고유의 값(ID)을 통해 비교
  • areContentsTheSame : 두 개의 아이템 항목이 같은지 확인
  • getChangePayLoad : 변경 내용에 대한 페이로드를 가져옴

  위 메소드에서 중요한 것은 areItemsTheSame과 areContentsTheSame 메소드를 구별할 줄 알아야 하는데 areItemsTheSame은 아이템이 같은지 판단해야 하기 때문에 주로 고유의 값이나 ID, 해쉬값으로 비교를 합니다. 그리고 areItemsTheSame 메소드가 true를 반환해야 areContentsTheSame 메소드를 호출합니다. 당연하듯이 아이템 자체가 다르면 아이템 내 데이터를 확인할 필요도 없고 아이템이 같더라도 데이터는 다른 값으로 세팅할 수 있어서 areContentsTheSame 메소드로 확인합니다. 결론적으로 areItemsTheSame과 areContentsTheSame 메소드가 모두 true이면 같은 아이템으로 판단하여 업데이트 하지 않게 됩니다.


class DiffUtilCallback(
    private val oldList: List<Book>,
    private val newList: List<Book>
): DiffUtil.Callback() {
    override fun getOldListSize(): Int = oldList.size

    override fun getNewListSize(): Int = newList.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList.get(oldItemPosition).id == newList.get(newItemPosition).id
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        oldList.get(oldItemPosition) == newList.get(newItemPosition)
    }
}

  위의 코드처럼 DiffUtilCallback 클래스를 정의하고 Adapter 내부에 changeList라는 별도의 메소드를 만들어서 새로운 리스트 업데이트 시 사용할 수 있습니다. 먼저 DiffUtilCallback으로 이전 리스트와 새로운 리스트의 Diff를 계산한 후 dispatchUpdatesTo 메소드를 통해 Adapter에 변경된 부분을 알려줄 수 있게 됩니다.

 fun changeList(books: List<Book>){
        val diffUtilCallback = DiffUtilCallback(this.books,books)
        val diffResult = DiffUtil.calculateDiff(diffUtilCallback)
        
        this.books.apply { 
            clear()
            addAll(books)
            diffResult.dispatchUpdatesTo(this@BookListAdapter)
        }
    }

AsyncListDiffer

  DiffUtil.Callback 추상클래스를 구현하는 클래스를 직접 만들어서 RecyclerView Adapter에서 사용해도 좋지만 데이터가 많아질수록 Eugene W.Myers's difference algorithm의 시간복잡도가 커지기에 백그라운드 스레드에서 처리하는 것이 좋습니다. AsyncListDiffer는 백그라운드 스레드에서 리스트의 변경사항을 계산하고 업데이트까지 진행을 시켜주기 때문에 효율적으로 사용할 수 있습니다.

class DiffUtilCallback(): DiffUtil.ItemCallback<Book>(){

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

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

먼저 DiffUtil.ItemCallback 클래스를 만듭니다.
DiffUtil.Callback과 다르게 getOldListSize와 getNewListSize 메소드는 메소드로 존재하지 않기 때문에 areItemsTheSame과 areContentsTheSame만 오버라이딩 하였습니다.


class BookListAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    
    private val asyncDiffer = AsyncListDiffer(this, DiffUtilCallback())
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        ...
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        ...
    }

    override fun getItemCount(): Int {
        ...
    }
    
    fun changeList(books: List<Book>){
        asyncDiffer.submitList(books)
    }
}

그리고 Adapter에 AsyncListDiffer 객체를 선언한 뒤 changeList 내부에 submitList를 호출만 해주면 됩니다.


    public void submitList(@Nullable final List<T> newList,
            @Nullable final Runnable commitCallback) {
        // incrementing generation means any currently-running diffs are discarded when they finish
        final int runGeneration = ++mMaxScheduledGeneration;

        if (newList == mList) {
            // nothing to do (Note - still had to inc generation, since may have ongoing work)
            if (commitCallback != null) {
                commitCallback.run();
            }
            return;
        }
        
        ...
      
       
   }

여기서 멈추지 않고 AsyncListDiffer의 submitList를 분석해봅시다. 위 코드는 AsyncListDiffer 클래스의 submitList 메소드의 일부로 newList와 mList(이전 리스트)와 비교를 하게 됩니다. 여기서 중요한 점이 있는데 newList와 mList의 참조가 같으면 변경사항을 체크하지도 않고 종료시킵니다. 새로운 데이터를 대입할 때 서버나 DB로부터 새로운 리스트를 생성하고 데이터를 세팅한 후 submitList를 하게 되면 문제 없지만, 기존에 있던 데이터를 가지고 임의로 약간의 수정을 한 후 submitList를 하면 결국 같은 참조값을 가리키기 때문에 업데이트가 안되는 현상이 발생합니다.

mConfig.getBackgroundThreadExecutor().execute(new Runnable() {
            @Override
            public void run() {
                final DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
                    @Override
                    public int getOldListSize() {
                        return oldList.size();
                    }

                    @Override
                    public int getNewListSize() {
                        return newList.size();
                    }

                    @Override
                    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
                        T oldItem = oldList.get(oldItemPosition);
                        T newItem = newList.get(newItemPosition);
                        if (oldItem != null && newItem != null) {
                            return mConfig.getDiffCallback().areItemsTheSame(oldItem, newItem);
                        }
                        // If both items are null we consider them the same.
                        return oldItem == null && newItem == null;
                    }

                    @Override
                    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
                        T oldItem = oldList.get(oldItemPosition);
                        T newItem = newList.get(newItemPosition);
                        if (oldItem != null && newItem != null) {
                            return mConfig.getDiffCallback().areContentsTheSame(oldItem, newItem);
                        }
                        if (oldItem == null && newItem == null) {
                            return true;
                        }
                        // There is an implementation bug if we reach this point. Per the docs, this
                        // method should only be invoked when areItemsTheSame returns true. That
                        // only occurs when both items are non-null or both are null and both of
                        // those cases are handled above.
                        throw new AssertionError();
                    }

                    @Nullable
                    @Override
                    public Object getChangePayload(int oldItemPosition, int newItemPosition) {
                        T oldItem = oldList.get(oldItemPosition);
                        T newItem = newList.get(newItemPosition);
                        if (oldItem != null && newItem != null) {
                            return mConfig.getDiffCallback().getChangePayload(oldItem, newItem);
                        }
                        // There is an implementation bug if we reach this point. Per the docs, this
                        // method should only be invoked when areItemsTheSame returns true AND
                        // areContentsTheSame returns false. That only occurs when both items are
                        // non-null which is the only case handled above.
                        throw new AssertionError();
                    }
                });

                mMainThreadExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        if (mMaxScheduledGeneration == runGeneration) {
                            latchList(newList, result, commitCallback);
                        }
                    }
                });
            }
        });

이후 getBackgroundThreadExecutor를 통해 백그라운드에 작업을 실행하고 DiffUtil.Callback을 정의합니다. getOldListSize와 getNewListSize 메소드 내부적으로 리스트로 반환하게 되고 areItemsTheSame과 areContentsTheSame은 DiffUtil.ItemCallback에서 정의한 클래스를 그대로 사용하고 있습니다. 정리하자면 DiffUtil.Callback 메소드를 동일하게 사용하되 백그라운드 스레드에서 변경사항을 계산한다고 보면 됩니다.


ListAdapter

  RecyclerView의 notifyDataSetChanged를 개선하기 위해서 DiffUtil.Callback 추상클래스를 구현하거나 AsyncListDiffer 클래스를 사용하였습니다. 하지만 구글에서 더욱 쉽게 사용하라고 AsyncListDiffer를 래핑한 ListAdapter를 만들었습니다.

class BookAdapter(private val itemClickedListener: (Book) -> Unit) : ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {

    inner class BookItemViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root){

        fun bind(bookModel: Book){
            binding.titleTextView.text = bookModel.title
            binding.descriptionTextView.text = bookModel.description
            binding.root.setOnClickListener {
                itemClickedListener(bookModel)
            }

            Glide
                .with(binding.coverImageView.context)
                .load(bookModel.converSmallUrl)
                .into(binding.coverImageView)
        }
    }

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

    override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
        holder.bind(currentList[position])
    }


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

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

  위 코드는 ListAdapter를 확장한 BookAdapter로 ListAdapter의 제너릭 타입으로 리스트 아이템 타입인 Book과 내부 클래스로 정의한 BookItemViewHolder를 선언하였습니다. 그리고 생성자 매개변수로 companion object로 정의한 DiffUtil.ItemCallback 클래스를 대입하였습니다. ListAdapter에서는 내부 속성 값으로 AsyncListDiffer가 존재하고 매개변수로 대입된 DiffUtil.ItemCallback 클래스를 이용하여 submitList 시 변경사항을 체크하고 업데이트 시켜주게 됩니다. 🥰

하지만 아직까지 ListAdapter에 이슈가 조금 많은 것처럼 보입니다. 변경 내역을 계산하기까지 시간이 소요되어 업데이트 시 끊김이 발생한다거나, scroll position이 자동으로 맨 마지막으로 이동하는 이슈를 겪어본적이 있습니다. scrollToPosition도 정상적으로 수정되지 않아 submitList(null) 이후 submitList(newList)를 호출하여 수정하였습니다. 이러면 ListAdapter를 쓰는 의미가 없긴 한데... 해결하신 분들 있으시면 댓글로 남겨주세요.


참고

profile
"Effort never betrays"

0개의 댓글