[Android] ViewHolder

ErroredPasta·2022년 4월 8일
1

Android

목록 보기
1/5

RecyclerView를 사용할 때, 반드시 구현해야하는 클래스로 RecyclerView.AdapterRecyclerView.ViewHolder가 있습니다. RecyclerView.Adapter는 데이터를 적절히 RecyclerView에 표시하기 위해 필요한 걸 알지만, RecyclerView.ViewHolder는 왜 반드시 구현해야 하는지 의문이 들 수 있습니다.

탄생 배경

RecyclerView 이전에 목록 등을 구현하기 위해서는 ListView를 사용하였습니다. ListViewRecyclerView와 마찬가지로 BaseAdapter 클래스를 구현하여 ListView의 adapter로 설정을 해주어야 했습니다. Kotlin으로 구현을 하게 되면 아래와 같습니다.

class ListViewAdapter(
    private val items: List<ListViewModel>
) : BaseAdapter() {
    override fun getCount(): Int = items.size
    override fun getItem(position: Int): Any = items[position]
    override fun getItemId(position: Int): Long = items[position].id

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        // No ViewHolder
        val view =
            convertView ?: LayoutInflater.from(parent?.context)
                .inflate(R.layout.list_view_item, parent, false)

        bind(view, position)

        return view
    }

    /**
     * View에 알맞은 data를 binding하는 함수
     * @param view data binding을 할 View
     * @param position ListView에서 표시될 View의 위치
     */
    private fun bind(view: View, position: Int) {
        view.findViewById<TextView>(R.id.listViewItemTextView).text = 
        	items[position].content
        
        // Glide를 이용하여 이미지 url에서 이미지를 불러온다.
        view.findViewById<ImageView>(R.id.listViewItemImageView)
        	.load(items[position].imageUrl)
    }
}

여기서 가장 중요한 method는 getView입니다. ListView에서 아이템을 출력할 때 getView를 이용하여 View를 생성 혹은 convertView를 재활용하여 해당 View에 데이터를 binding하게 됩니다. 그냥 봐서는 아무 문제될 것이 없어보이지만 bind에서 view.findViewById가 성능에 영향을 미치게 됩니다.

findViewById

View 클래스에서 findViewById는 아래와 같이 동작하게 됩니다.

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

findViewById 내부에서 findViewTraversal을 호출하여 반환하는 것을 볼 수 있습니다. ViewfindViewTraversal는 단순히 자신의 id와 parameter로 받은 id가 같은지 확인하여 같으면 해당 View를 반환하고 같지 않으면 null을 반환합니다.
하지만 ViewGroup에서 findViewTraversal의 구현은 다릅니다.

// ViewGroup.java
@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;
}

ViewGroup에서 findViewTraversal은 먼저 자신의 id와 parameter로 받은 id가 같은지 확인하고 일치하면 자신을 반환합니다. 다르면 자신이 가진 자식 View들로 findViewById를 다시 호출하여 반환 값을 비교해가며 같은 id를 가진 View를 찾은 경우 해당 View를 반환합니다.
즉, ViewGroup이 자식 ViewGroup을 가질 경우 findViewTraversal가 반복적으로 호출되어 성능에 영향을 미치게 됩니다.
그래서 findViewById의 호출 횟수를 줄일 필요가 있었고 ViewHolder라는 패턴이 생기게 되었습니다.

ListView에서의 ViewHolder

ListView에서 ViewHolder는 별도의 클래스를 상속받을 필요 없이 직접 클래스를 만들어서 구현하면 됩니다.

class ListViewHolder(
    val root: View
) {
    val textView: TextView = root.findViewById(R.id.listViewItemTextView)
    val imageView: ImageView = root.findViewById(R.id.listViewItemImageView)

    /**
     * ViewHolder의 View에 알맞은 data를 binding
     * @param data binding할 data
     */
    fun bind(data: ListViewModel) {
        textView.text = data.content
        imageView.load(data.imageUrl)
    }
}

위와 같이 필요한 View들의 reference를 저장할 수 있도록 클래스를 구현하고 이를 adapter에서 사용하면 됩니다. ViewHolder를 사용하면 getView의 코드를 아래와 같이 작성할 수 있습니다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
    // Using ViewHolder
    val viewHolder = if (convertView == null) {
        val tempViewHolder = ListViewHolder(
            // 재사용할 View가 없으므로 inflate
            LayoutInflater.from(parent?.context)
                .inflate(R.layout.list_view_item, parent, false)
        )

        tempViewHolder.root.tag = tempViewHolder
        tempViewHolder
    } else {
        // 이전에 저장한 ViewHolder를 convertView의 tag에서 가져온다.
        convertView.tag
    } as ListViewHolder

    viewHolder.bind(items[position])

    return viewHolder.root
}

재사용할 convertView가 없으면 LayoutInflater를 이용하여 새로운 Viewinflate하고 ViewHolder를 생성하여 ViewtagViewHolder를 저장합니다. 만약 재사용할 convertView가 존재하면 이전에 저장한 ViewHoldertag에서 가져와서 ViewHolder를 사용하게 됩니다.
이렇게 구현함으로써 findViewByIdViewHolder 생성 시에만 사용하여 호출 횟수를 줄일 수 있습니다.

ViewBinding 사용

아래와 같이 ViewBinding을 이용하여 ListView에서 ViewHolder를 구현하지 않고 비슷한 성능을 낼 수 있습니다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
    val binding = if (convertView == null) {
        val tempBinding = 
        	ListViewItemBinding.inflate(LayoutInflater.from(parent?.context))

        tempBinding.root.tag = tempBinding
        tempBinding
    } else {
        convertView.tag
    } as ListViewItemBinding

    bind(binding, items[position])

    return binding.root
}

private fun bind(binding: ListViewItemBinding, data: ListItemModel) = with(binding) {
    listItemTextView.text = data.content
    listItemImageView.load(data.imageUrl)
}

이게 가능한 이유는 자동으로 생성된 ListViewItemBindingViewHolder 역할을 하게 되기 때문입니다.

public final class ListViewItemBinding implements ViewBinding {
  @NonNull
  private final LinearLayout rootView;

  @NonNull
  public final ImageView listItemImageView;

  @NonNull
  public final TextView listItemTextView;

  private ListViewItemBinding(@NonNull LinearLayout rootView, @NonNull ImageView listItemImageView,
      @NonNull TextView listItemTextView) {
    this.rootView = rootView;
    this.listItemImageView = listItemImageView;
    this.listItemTextView = listItemTextView;
  }

  ...

  @NonNull
  public static ListViewItemBinding bind(@NonNull View rootView) {
    // The body of this method is generated in a way you would not otherwise write.
    // This is done to optimize the compiled bytecode for size and performance.
    int id;
    missingId: {
      id = R.id.listItemImageView;
      ImageView listItemImageView = ViewBindings.findChildViewById(rootView, id);
      if (listItemImageView == null) {
        break missingId;
      }

      id = R.id.listItemTextView;
      TextView listItemTextView = ViewBindings.findChildViewById(rootView, id);
      if (listItemTextView == null) {
        break missingId;
      }

      return new ListViewItemBinding((LinearLayout) rootView, listItemImageView, listItemTextView);
    }
    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}

ListViewItemBindinginflate을 호출하게 되면 View를 inflate 후 bind를 호출하여 ListViewItemBinding을 반환하게 됩니다. 재사용할 View가 없을 때만 inflate가 호출되어 findChildViewById가 binding 객체를 생성할 때만 호출되므로 ViewHolder를 적용할 때와 비슷한 성능을 낼 수 있습니다.

RecyclerView에서의 ViewHolder

ListView에서는 직접 ViewHolder 패턴을 적용해야 하므로 적용을 하지 않는 경우도 존재하고 ViewHolder 생성, 저장 및 불러오는 코드 그리고 data binding 코드를 getView에 모두 작성하여 코드를 적절히 나누지 않을 경우 꽤나 비직관적일 수 있습니다.
그래서 RecyclerView에서는 ListView에서와는 달리 ViewHolder구현을 강제하고 있고 adapter에서는 ViewHolder의 생성과 data binding, 아이템의 갯수를 반환하는 method만 따로 구현해주면 됩니다.

class RecyclerViewAdapter(
    private val items: List<ListViewModel>
) : RecyclerView.Adapter<RecyclerViewHolder>() {
	// ViewHolder를 생성
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        RecyclerViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.list_view_item, parent, false)
        )

	// ViewHolder에 data를 binding
    override fun onBindViewHolder(holder: RecyclerViewHolder, position: Int) =
        holder.bind(items[position]) {
            Toast.makeText(
                holder.itemView.context,
                "clicked item no.$position",
                Toast.LENGTH_SHORT
            ).show()
        }

	// data의 갯수
    override fun getItemCount() = items.size
}

RecyclerView.Adapter에서 type parameter로 사용할 ViewHolder 클래스를 받고, 새로운 ViewHolder를 생성할 때 호출할 onCreateViewHolder, ViewHolder에 데이터를 binding할 때 호출할 onBindViewHolder을 구현해주면 됩니다.
ListView에서와 달리 ViewHolder 생성과 data binding 코드가 method로 나뉘어 있고, ViewHolder를 저장 및 불러오는 코드를 작성할 필요가 없어서 코드를 읽기가 훨씬 수월합니다.

class RecyclerViewHolder(
    root: View
) : RecyclerView.ViewHolder(root) {
    val textView: TextView = root.findViewById(R.id.listViewItemTextView)
    val imageView: ImageView = root.findViewById(R.id.listViewItemImageView)

    fun bind(data: ListViewModel, listener: () -> Unit) {
        textView.text = data.content
        imageView.load(data.imageUrl)
        itemView.setOnClickListener {
            listener()
        }
    }
}

ViewHolderRecyclerView.ViewHolder를 상속 받고 ListView에서 ViewHolder를 사용하였던 것 처럼 구현해주면 됩니다.

결론

ViewHolder 패턴은 RecyclerView 이전 ListView를 사용하던 시기에 findViewById에 의한 성능 저하를 방지하기 위해 만들어진 패턴으로 RecyclerView에서는 성능을 위해 ViewHolder 구현을 강제로 하였다고 볼 수 있습니다.

profile
Hola, Mundo

0개의 댓글