[번역] RecyclerView의 안티 패턴
안드로이드 개발에서 RecyclerView
는 기존의 ListView
를 대체하는 매우 유용한 first-party 라이브러리입니다. 필자는 RecyclerView의 안티 패턴과 잘못된 어댑터 개념에 대한 지식을 공유 해야겠다고 생각했다고 합니다.
옛날 방식
RecyclerView
가 내부에서 어떤 일을 하고 있는지 파악하려면 먼저 이것 없이 이 기능을 구현하는 방법을 이해해야 합니다. BaseAdapter
를 확장하는 코드를 작성한 적이 있다면 이 코드를 보았을 것입니다. 예를 들어 커스텀 스피너 어댑터를 살펴보겠습니다. 하나의 TextView
를 보여주는 어댑터의 구현에 대해 살펴보겠습니다.
이것은 실행 가능한 해결책이지만 문제점이 존재합니다. 매번 뷰를 inflating하고 있습니다. 이것은 ListView
를 스크롤할 때 성능 저하를 일으킵니다. 이를 최적화하기 위해, 우리는 Adapter
인터페이스와 getView
메소드 코드를 유심히 바라봐야 합니다. convertView
파라미터는 nullable하며, 이렇게 설명할 수 있습니다.
가능하다면, 재사용할 수 있는 이전의 뷰입니다. 우리는 이것을 사용하기 전에 뷰가 Null이 아니며, 적절한 타입인지 확인해야 합니다. 올바른 데이터를 표시하도록 이 뷰를 변환할 수 없는 경우 이 메소드(getView)를 사용하여 새로운 뷰를 만들 수 있습니다.
이것은 “이전의 뷰를 재사용한다” 라고 말하고 있습니다. 어댑터는 사용자의 화면 밖에서 뷰 전체를 inflating
하거나 recreating
하여 재사용합니다. 이는 사용자들에게 더 부드러운 스크롤을 경험하게 하도록 하는 셈입니다.
이제 Adapter
의 이런 재활용하는 행동을 보존하기 위해 위 코드를 최적화 할 것입니다. 아래 작업은 convertView
가 null
인지 여부를 확인하여 수행하며, null
인 경우에만 뷰를 inflate합니다. 만약 null
이 아니라면, 이것은 우리가 재활용된 뷰를 받아서 사용한다는 것을 의미하고 다시 inflate할 필요가 없다는 뜻입니다.
val itemView : View
if (convertView == null) {
itemView =
LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
} else {
itemView = convertView
}
아래는 완전한 코드입니다.
하지만 우리는 더욱 더 최적화 할 수 있습니다. 현재 모든 아이템에 대해 findViewById
를 사용하고 있습니다. 우리는 뷰가 인플레이션된 후 한 번만 이 부분을 실행하게 할 수 있습니다. 바로 ViewHolder
패턴을 사용해서 말입니다. 뷰에 대한 참조를 유지하는 클래스를 만들면 됩니다.
inner class ViewHolder {
lateinit var tvText : TextView
}
뷰의 setTag
기능을 사용합니다. 이제 처음 만들 때, 새로운 ViewHolder
오브젝트를 생성하고 아이템 뷰의 태그에 생성된 ViewHolder
를 할당합니다. 그 다음 뷰를 재사용할 때 이 태그를 ViewHolder
로 타입 캐스트하여 사용합니다. 이렇게 하면 처음 뷰가 inflate될 때만 findViewById
를 사용합니다.
ViewHolder
로 findViewById
로직을 전달하여 조금 더 오류가 발생할 확률을 줄일 수 있습니다. tvText
를 불변 변수로 만들고 ViewHolder
에서 보유하도록 합니다.
inner class ViewHolder(val itemView: View) {
val tvText : TextView = itemView.findViewById(R.id.textView)
}//In getView, use as follows:
viewHolder = ViewHolder(itemView)
이렇게 하면 tvText
를 할당하지 않아도 코드가 오류를 발생시키지 않습니다. 이제 뷰를 재활용하고 불필요한 inflation을 방지하여 findViewById
를 반복하지 않고도 뷰를 재사용할 수 있는 최적화된 어댑터를 얻었습니다. 그러나 최적화의 이점을 얻기 위해 이러한 코드를 모두 작성하는 것은 쉽지 않았습니다. 우리가 ListView
를 사용할 때마다 이 코드를 반복해서 작성해야 한다면, 이것은 순식간에 하나의 boilerplate가 될 것입니다. 그래서 우리는 추상 클래스를 사용하여 이를 쉽게 사용할 수 있도록 만들어 줄 것입니다.
우리가 사용한 이 추상 클래스는 RecyclerView.Adapter
의 일부분에 불과합니다. 물론 RecyclerView
는 더 많은 것들을 포함하고 있겠지만, 우리는 오늘 이것에 대해 더 이상 탐구해보진 않을 것입니다.
조금 더 최근 방식
이제 RecyclerView의 안티 패턴에 대해 설명을 이어나가 보겠습니다. 먼저 아래 코드를 살펴보겠습니다.
여기서도 재활용 할 수 있는게 있을까요? 정답은 OnClickListener
입니다. 현재는 매번 새로운 리스너를 설정하고 있습니다. ViewHolder
초기화 또는 onCreateView
내부에서 한 번만 실행되며 나중에 다시 사용하기 위해 재활용하도록 만들어줘야 합니다. 일단 전체 데이터 클래스를 콜백으로 보내는 대신 현재 ViewHolder
의 포지션만 반환합니다.
inner class MyViewHolder(
itemView: View,
private val onTextViewTextClicked: (position: Int) -> Unit
) : RecyclerView.ViewHolder(itemView) {
val tvText: TextView = itemView.findViewById(R.id.textView)
init {
tvText.setOnClickListener {
onTextViewTextClicked(adapterPosition)
}
}
}
이는itemList
를 호출자에게 노출하고 호출자가 itemList[index]
를 사용하도록 하는 대신 어댑터 내부의 로직을 캡슐화합니다. 어댑터에는 해당 위치의 데이터로 어댑터 위치를 변환하는 데 사용할 수 있는 adapterPosition
이 존재합니다. 이를 사용하여 콜백을 노출하고 호출자에게 데이터를 반환합니다.
이렇게 하면 OnItemClick
이 재활용되고 로직은 여전히 어댑터 내부에 캡슐화되어 있습니다.
두 번째 안티 패턴은 어댑터 내부에 로직을 지니고 있는 것입니다. 어댑터 및 뷰홀더는 뷰홀더를 보여주는 일만 해야 합니다. 나머지 로직은 호출자에게 맡겨야 합니다. 이 예제를 살펴보겠습니다.
앞으로도 동일한 UI를 재사용하려고 했지만, 아이템을 클릭할 때 각각 다른 상호 작용을 해야 한다고 가정해봅시다. 로직이 내부에 결합되어 있기 때문에 어댑터를 더 이상 재활용 할 수 없습니다. 그렇기 때문에 우리는 콜백/인터페이스로 이것들을 노출시켜야 합니다.
최신 방식
세 번째 안티 패턴은 뷰 내에서 직접 뷰의 상태를 변경하는 것입니다. CheckBox
를 사용하여 ViewHolder
에서 상태를 변경하는 예제를 살펴보겠습니다.
The third anti-pattern is the changing the state of the view directly inside the ViewHolder
. For example, let's look into a ViewHolder
that changes the state of a CheckBox
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
// Note: checkbox clickable is set to false to control the. logic ourselves holder.itemView.setOnClickListener {
//Toggle
holder.checkBox.isChecked = holder.checkBox.isChecked.not()
}
}
이런 방법으로 100개의 아이템으로 리스트를 채운 다음, 처음 2~3개의 아이템을 확인 후 스크롤으르 내리면 해당 위치를 클릭한 적이 없는데도 다른 위치가 선택되는 것을 볼 수 있습니다. 이것은 다시 한 번 해당 뷰가 재활용되고 있기 때문입니다. 체크된 상태의 뷰가 재활용될 때, 그것은 그대로 유지됩니다. 따라서 우리는 항상 onBindViewHolder
에서 뷰의 상태를 되돌리는 것을 기억해야 합니다. 그리고 우리는 데이터 클래스에isChecked
변수를 추가하고 false
로 초기화해주어 이를 해결할 수 있습니다.
data class Data(
val text: String,
val isChecked: Boolean = false
)
onBindViewHolder
메소드에서 우리는 이 값을 다시 바인딩 합니다.
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.checkBox.isChecked = itemList[position].isChecked
holder.itemView.setOnClickListener {
holder.checkBox.isChecked= holder.checkBox.isChecked.not()
}
}
다시 데이터를 채우고, 몇 개를 체크 후, 스크롤을 내려서 테스트 해봅시다. 괜찮다고 생각할 수 있습니다. 하지만 다시 스크롤을 올리면 모든 체크된 상태가 사라집니다. 그 이유는 데이터 클래스 내부에서 isChecked
는 변경되지 않다가 RecyclerView
를 스크롤 하면, onBindViewHolder
메소드에서 다시 바인딩 되기 때문에 모두 false
로 변하게 됩니다. 이것을 해결하기 위해 우리는 이 코드를 살짝 수정해줍니다.
이제 재활용된 상태의 문제가 해결되었습니다. 이제 앱의 데이터베이스에 사용자가 “저장” 버튼을 클릭했을 때 다른 클래스에서 SaveToDb(adapter.itemList)
를 호출하는 등 다른 클래스가 이 데이터에 접근하여 사용할 수 있도록 itemList를 퍼블릭 변수로 지정해줍니다. 그리고 선택을 한꺼번에 취소하고 한꺼번에 선택하는 기능을 원한다고 가정 했을 때 다음 처럼 메소드를 선언해주어 사용할 수 있습니다.
fun unselectAll() {
itemList.map { data->
data.copy(isChecked = false)
}
notifyDataSetChanged()
}fun selectAll() {
itemList.map { data ->
data.copy(isChecked = true)
}
notifyDataSetChanged()
}
오직 상태가 바뀐 아이템만 알림을 주어 위 코드를 향상시킬 수 있습니다.
fun unselectAll() {
itemList.mapIndexed { position, data ->
if (data.isChecked) {
notifyItemChanged(position)
data.copy(isChecked = false)
}
}
}fun selectAll() {
itemList.mapIndexed { position, data ->
if (!data.isChecked) {
notifyItemChanged(position)
data.copy(isChecked = true)
}
}
}
현재 어댑터 코드를 보면 너무 많은 작업을 시작하고 있음을 알 수 있습니다. 양방향으로 페이지를 로드한다던가 항목을 제거/숨기는 기능 등을 추가하면 어떻게 될까요? 어댑터 작업은 뷰홀더에서 뷰를 바인딩하는 작업이어야 합니다. 게다가 현재는onBindViewHolder
메소드에서도 로직을 보유하고 있습니다. 어댑터를 최대한 추상화할 필요가 있습니다. 이를 추상화하는 한 가지 방법은 이러한 선택 취소/추가/제거 로직을 presenter/ViewModel에서 정의하도록 하고 호출자가 아이템을 업데이트하려고 할 때마다 어댑터가 새로운 리스트를 로드하는 작업만 허용하도록 하는 것입니다. 다시 한 번 코드를 리팩토링 해보겠습니다. 아래 코드는 재사용 가능하고 로직이 포함되어 있지 않습니다. itemList
는 내부 상태 변경을 방지하기 위해 불변 리스트로 설정됩니다. 현재 상태를 업데이트 하는 유일한 방법을 새로운 리스트를 전달하는 것 뿐입니다.
하지만 지금 어댑터는 아이템의 변경 사용을 알리기에 충분하지 않습니다. 우리는 위치가 업데이트 될 때마다 전체 리스트를 다시 바인딩하고 싶지 않습니다. 우리는 추가, 제거, 변경을 알리는 로직을 차별화할 수 있습니다.
여기서 사용한 방법은 아주 간단한 diffing 메소드일 뿐입니다. 실제로는 중간에 새로운 아이템이 추가되기 때문에 동일한 아이템이 다른 위치로 이동하는 경우를 고려해야 합니다. 위의 구현 또한 비효율 적입니다. 왜냐하면 우리는 100개의 아이템을 가지고 있다면 메인 스레드에서 위 for문을 100번 반복해야한다는 것을 의미하기 때문입니다.
ListAdapter
는 이 방식을 해결하고 더 쉽게 사용할 수 있도록 만들어졌으며, ListView
용 어댑터가 아니라 RecyclerView
의 어댑터입니다. 대부분의 경우 이런 방법으로 충분히 해결할 수 있습니다.
areItemsTheSame
는 아이템이 동일하고 내용이 동일하면 변경되지 않음을 의미합니다. areContentsTheSame
은 아이템이 동일하고 내용이 다르면 아이템이 변경됨을 의미합니다. 아이템이 다른 경우 추가, 제거, 또는 위치 변경 diffing 중 하나가 동작할 수 있습니다. 이것은 모든 이벤트들을 내부에 숨깁니다. 게다가 이 모든 diffing은 백그라운드 스레드에서 이루어집니다. 새로운 리스트를 submitList
에 넘기면 모든 보일레 플레이트 로직이 처리됩니다. 더 관심이 있는 경우 AsyncListDiffer에서 diffing 로직을 확인할 수 있습니다. 대부분의 경우 ViewModel/presenter에서 상태를 유지해야 하므로 ListAdapter
를 더 많이 사용할 것입니다. ListAdapter
를 사용하지 않으셨다면, 사용하시기를 권장합니다. 그렇게 함으로써 코드 품질이 많이 향상될 것입니다.