[Android] ViewHolder 패턴을 쓰는 이유

H43RO·2021년 9월 29일
21

Android 와 친해지기

목록 보기
16/26
post-thumbnail

ViewHolder... 몰라 이거 쓰라던데?

안드로이드 앱 개발자라면, 거의 모든 앱에 필수적으로 들어가는 ViewGroup 을 하나 대보라고 했을 때 'RecyclerView' 이야기를 하곤 한다. 리사이클러뷰를 구현해봤으면 알다시피, Adapter 패턴을 통해 리스트 형태로 표시할 데이터와 리스트 아이템 각각의 레이아웃의 연결을 하는 것이 가장 큰 특징이다.

RecyclerView.Adapter 를 상속하여 자신만의 어댑터를 만들 때 알 수 있는 것들이 여러 가지 있다. RecyclerView.ViewHolder 를 넘겨줘야 한다는 점과, onCreateViewHolder() 그리고 onBindViewHolder() 등의 추상 메소드를 반드시 구현해야 한다는 점 등이 있다.

그런데 계속해서 보이는 이 ViewHolder, 솔직히 이게 왜 필요하고, 왜 꼭 구현해줘야 하고, 왜 RecyclerView 에는 항상 ViewHolder 가 있는지에 대해 궁금즘을 품어본 적이 있는가?

예제 코드를 따라해서 동작을 구현하는 것은 누구나 할 수 있다. 그러나, 이것이 왜 필요한 지에 대해 알고 있어야 더욱 풍부한 동작을 목적에 맞게 구현할 수 있을 것이다.

이번 포스팅에선, 이 ViewHolder 라는 것이 왜 등장했고, 왜 필요한 지에 대해 알아보고자 한다. 만약 이에 대해 정확히 몰랐다면, 해당 포스팅으로 기본 개념을 잡아보자.

머나먼 옛날 ListView 떠올려보기

RecyclerView 이전에는 ListView 가 있었다. 리사이클러뷰의 조상인 셈이다. 앱 개발에 처음 입문했을 적에 최소한 한 번쯤은 다뤄본 컴포넌트인데, 이 ListView 에서 특정 데이터들을 리스트 형태로 보여주는 원리는 다음과 같았다.

데이터 각각에 대해 아이템의 레이아웃을 구성하는 View 를 inflate 하고, Inflating 된 뷰에서 findViewById() 를 통해 데이터를 끼워맞춰주면서 리스트 형태로 만들어준다.

그런데 뷰를 매번 인플레이팅하는 작업은 매우 무거운 작업이었기 때문에 매끄러운 스크롤을 보장하지 못했다. 따라서 getView() 메소드 내의 convertView 라는 녀석을 활용하여, 스크롤이 내려가면서 맨 위에 있던 아이템들은 화면에서 사라지고 다른 새로운 아이템을 구성해야할 때 뷰를 새롭게 인플레이팅하기보다, 기존에 사용하던 View 를 다시 갖다 쓰는 방법을 사용하곤 했었다. 재활용성을 강조한 것이다.


이걸로 성능 최적화가 될까?

convertView 를 도입하여 재사용성을 높였지만, 여전히 뷰를 구성하기 위한 findViewById() 호출 역시 매우 고비용 작업이기 때문에 데이터의 개수가 늘어나는만큼 더더욱 퍼포먼스 저하가 발생하게 된다.


findViewById() 는 고비용 작업일까

일반 뷰 (그냥 TextView, ImageView 등 단일 뷰 자체) 에 대한 findViewById() 는 비용이 클 이유는 없다. 왜냐하면 자기 자신의 ID만 확인하기 때문이다. 아래는 내부 구현이다.

@Nullable
public final <T extends View> T findViewById(@IdRes int id) {
  if (id == NO_ID) {
    return null;
  }
  return findViewTraversal(id);
}

protected <T extends View> T findViewTraversal(@IdRes int id) {
  if (id == mID) {
    return (T) this;
  }
  return null;
}

그런데 그냥 일반 뷰면 몰라도 여러 개의 자식 뷰를 포함하고 있는 레이아웃은, 자신과 자신의 자식들까지 모두 확인하는 과정이 들어간다. 이 과정은 트리 DFS 탐색과도 같은데, 매번 Child View를 모두 확인해서 가져오는 과정은 어쩔 수 없이 비용이 크게 발생한다.

@Override
protected <T extends View> T findViewTraversal(@IdRes int id) {
    if (id == mID) {
        return (T) this;
    }

    final View[] where = mChildren;
    final int len = mChildrenCount;

    for (int i = 0; i < len; i++) {
        View v = where[i];

        if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
            v = v.findViewById(id);

            if (v != null) {
                return (T) v;
            }
        }
    }

    return null;
}

따라서, 뷰 인플레이팅도 인플레이팅이지만 findViewById() 의 호출 횟수를 줄이는 것이 가장 큰 관건이었다. 이러한 이유로 탄생하게 된 것이 바로 ViewHolder 패턴이다.

ViewHolder 패턴 개념

위에서 설명했던 것처럼, findViewById() 를 계속하여 호출하는 것을 근본적으로 막기 위해 ViewHolder 라는 디자인 패턴이 등장했다. 이름에서 대강 유추해보면, View 를 담고있는 역할을 하는 그런 뉘앙스다.

ViewHolder 패턴은, 각 뷰의 객체를 ViewHolder 에 보관함으로써 뷰의 내용을 업데이트하기 위한 findViewById() 메소드 호출을 줄여 효과적으로 퍼포먼스 개선을 할 수 있는 패턴이다. ViewHolder 패턴을 사용하면, 한 번 생성하여 저장했던 뷰는 다시 findViewById() 를 통해 뷰를 불러올 필요가 사라지게 된다.

핵심 아이디어는 '전에 만들어 둔거 냅두고 또 만들 필요는 없잖아?' 이다.

만약 데이터가 1번부터 10번까지 10개 있고, 이를 리스트 형태로 보여줄 때 스마트폰의 화면 크기 상 1번부터 5번까지 보여준다고 가정해보자. 그럼 사용자가 스크롤을 하게 되면, 최상단에 있던 1번 및 2번 아이템의 레이아웃은 눈에 보이지 않게 될 것이다. 그와 동시에 6번 및 7번 아이템이 화면에 새롭게 보여지지 않겠는가?

이 때, 6번 및 7번 아이템을 화면에 표시하기 위해 findViewById() 를 일일히 호출하여 레이아웃에 데이터를 바인딩하지 않고, 기존에 1번 및 2번 아이템을 그려줄 때 사용했던 View 를 재사용하여 이미 불러왔었던 레이아웃에 데이터만 채워주는 것이다. 재사용성을 높였을 뿐더러 불필요한 High-Cost 동작을 줄인 것이다.

이렇듯 ListView 에 convertViewViewHolder 패턴 등을 활용하면 무거운 작업들을 최소화시키고, 재사용성을 높여 성능을 최적화시킬 수 있었다. 따라서 안드로이드 차원에서도 ViewHolder 패턴 사용을 권장하는 상황이었다.

하지만, ListView 에서는 일일히 ViewHolder 생성 코드를 직접 작성해줘야 했고, 자칫 깜빡하는 경우가 빈번했다.

그래서, ListView 인데 강제로 ViewHolder 패턴을 구현하도록 해줘야 하는 놈으로 RecyclerView 라는 것이 탄생하게 되었다. 즉 프레임워크 차원에서 ListView 의 성능 최적화 및 향상을 강제하는 것이다.

RecyclerView 에서의 ViewHolder

RecyclerView.Adapter 를 상속하여 어댑터를 만들 때, onCreateViewHolder()onBindVIewHolder(), getItemCount() 이렇게 세 추상 메소드를 반드시 구현해줘야 한다. 뷰 홀더와 관련있어보이는 두 메소드에 대해 알아보자.

onCreateViewHolder()

ViewHolder 를 새로 만들어야 할 때 호출되는 메소드로, 이를 통해 각 아이템을 위한 XML 레이아웃을 활용한 뷰 객체를 생성하고 이를 뷰 홀더 객체에 담아 리턴해준다. 다만 ViewHolder 가 아직 어떠한 데이터에 바인딩된 상태가 아니기 때문에 각 뷰의 내용 (TextView 의 Text 등) 은 채우지 않는다.

뷰의 내용을 채워야 하는 경우 (해당 아이템이 화면에 보여지는 경우) 아래 메소드에서 레이아웃의 내용들을 채우게 된다.

onBindViewHolder()

ViewHolder 를 어떠한 데이터와 연결할 때 호출되는 메소드로, 이를 통해 뷰 홀더 객체들의 레이아웃을 채우게 된다. position 이라는 파라미터를 활용하여 데이터의 순서에 맞게 아이템 레이아웃을 바인딩해줄 수 있다.

아래는 RecyclerView.Adapter 추상 클래스의 내부 코드이다. 참고해봐도 좋을 것 같다.

public abstract static class Adapter<VH extends ViewHolder> {
    private final AdapterDataObservable mObservable = new AdapterDataObservable();
    private boolean mHasStableIds = false;
    private StateRestorationPolicy mStateRestorationPolicy = StateRestorationPolicy.ALLOW;

    /**
     * Called when RecyclerView needs a new {@link ViewHolder} of the given type to represent
     * an item.
     * <p>
     * This new ViewHolder should be constructed with a new View that can represent the items
     * of the given type. You can either create a new View manually or inflate it from an XML
     * layout file.
     * <p>
     * The new ViewHolder will be used to display items of the adapter using
     * {@link #onBindViewHolder(ViewHolder, int, List)}. Since it will be re-used to display
     * different items in the data set, it is a good idea to cache references to sub views of
     * the View to avoid unnecessary {@link View#findViewById(int)} calls.
     *
     * @param parent   The ViewGroup into which the new View will be added after it is bound to
     *                 an adapter position.
     * @param viewType The view type of the new View.
     * @return A new ViewHolder that holds a View of the given view type.
     * @see #getItemViewType(int)
     * @see #onBindViewHolder(ViewHolder, int)
     */
    @NonNull
    public abstract VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType);

    /**
     * Called by RecyclerView to display the data at the specified position. This method should
     * update the contents of the {@link ViewHolder#itemView} to reflect the item at the given
     * position.
     * <p>
     * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method
     * again if the position of the item changes in the data set unless the item itself is
     * invalidated or the new position cannot be determined. For this reason, you should only
     * use the <code>position</code> parameter while acquiring the related data item inside
     * this method and should not keep a copy of it. If you need the position of an item later
     * on (e.g. in a click listener), use {@link ViewHolder#getBindingAdapterPosition()} which
     * will have the updated adapter position.
     *
     * Override {@link #onBindViewHolder(ViewHolder, int, List)} instead if Adapter can
     * handle efficient partial bind.
     *
     * @param holder   The ViewHolder which should be updated to represent the contents of the
     *                 item at the given position in the data set.
     * @param position The position of the item within the adapter's data set.
     */
    public abstract void onBindViewHolder(@NonNull VH holder, int position);

    /**
     * Called by RecyclerView to display the data at the specified position. This method
     * should update the contents of the {@link ViewHolder#itemView} to reflect the item at
     * the given position.
     * <p>
     * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method
     * again if the position of the item changes in the data set unless the item itself is
     * invalidated or the new position cannot be determined. For this reason, you should only
     * use the <code>position</code> parameter while acquiring the related data item inside
     * this method and should not keep a copy of it. If you need the position of an item later
     * on (e.g. in a click listener), use {@link ViewHolder#getBindingAdapterPosition()} which
     * will have the updated adapter position.
     * <p>
     * Partial bind vs full bind:
     * <p>
     * The payloads parameter is a merge list from {@link #notifyItemChanged(int, Object)} or
     * {@link #notifyItemRangeChanged(int, int, Object)}.  If the payloads list is not empty,
     * the ViewHolder is currently bound to old data and Adapter may run an efficient partial
     * update using the payload info.  If the payload is empty,  Adapter must run a full bind.
     * Adapter should not assume that the payload passed in notify methods will be received by
     * onBindViewHolder().  For example when the view is not attached to the screen, the
     * payload in notifyItemChange() will be simply dropped.
     *
     * @param holder   The ViewHolder which should be updated to represent the contents of the
     *                 item at the given position in the data set.
     * @param position The position of the item within the adapter's data set.
     * @param payloads A non-null list of merged payloads. Can be empty list if requires full
     *                 update.
     */
    public void onBindViewHolder(@NonNull VH holder, int position,
            @NonNull List<Object> payloads) {
        onBindViewHolder(holder, position);
    }

		...

}

이번 포스팅에선 ViewHolder 패턴의 등장 배경과 사용 목적에 대해 알아보았다. 이 포스팅을 본 이후로는 RecyclerView 를 더욱 재밌게 접근해볼 수 있을 것이다. 또한, ViewHolder 의 이해가 부족할 때 가끔 RecyclerView 의 아이템이 이상하게 데이터가 뒤죽박죽되어 표시되는 경우가 발생하곤 했을 것이다. 이제는 이러한 현상이 왜 발생하고, 어떻게 해결해야 할 지 알 수 있을 것이다.

profile
어려울수록 기본에 미치고 열광하라

2개의 댓글

comment-user-thumbnail
2023년 1월 30일

좋은 글 감사합니다~

답글 달기
comment-user-thumbnail
1일 전

감사합니다 ^^

답글 달기