RecyclerView
를 사용할 때, 반드시 구현해야하는 클래스로 RecyclerView.Adapter
와 RecyclerView.ViewHolder
가 있습니다. RecyclerView.Adapter
는 데이터를 적절히 RecyclerView
에 표시하기 위해 필요한 걸 알지만, RecyclerView.ViewHolder
는 왜 반드시 구현해야 하는지 의문이 들 수 있습니다.
RecyclerView
이전에 목록 등을 구현하기 위해서는 ListView
를 사용하였습니다. ListView
도 RecyclerView
와 마찬가지로 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
가 성능에 영향을 미치게 됩니다.
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
을 호출하여 반환하는 것을 볼 수 있습니다. View
의 findViewTraversal
는 단순히 자신의 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
는 별도의 클래스를 상속받을 필요 없이 직접 클래스를 만들어서 구현하면 됩니다.
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
를 이용하여 새로운 View
를 inflate
하고 ViewHolder
를 생성하여 View
의 tag
에 ViewHolder
를 저장합니다. 만약 재사용할 convertView
가 존재하면 이전에 저장한 ViewHolder
를 tag
에서 가져와서 ViewHolder
를 사용하게 됩니다.
이렇게 구현함으로써 findViewById
를 ViewHolder
생성 시에만 사용하여 호출 횟수를 줄일 수 있습니다.
아래와 같이 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)
}
이게 가능한 이유는 자동으로 생성된 ListViewItemBinding
이 ViewHolder
역할을 하게 되기 때문입니다.
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));
}
}
ListViewItemBinding
의 inflate
을 호출하게 되면 View
를 inflate 후 bind
를 호출하여 ListViewItemBinding
을 반환하게 됩니다. 재사용할 View
가 없을 때만 inflate
가 호출되어 findChildViewById
가 binding 객체를 생성할 때만 호출되므로 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()
}
}
}
ViewHolder
는 RecyclerView.ViewHolder
를 상속 받고 ListView
에서 ViewHolder
를 사용하였던 것 처럼 구현해주면 됩니다.
ViewHolder
패턴은 RecyclerView
이전 ListView
를 사용하던 시기에 findViewById
에 의한 성능 저하를 방지하기 위해 만들어진 패턴으로 RecyclerView
에서는 성능을 위해 ViewHolder
구현을 강제로 하였다고 볼 수 있습니다.