[번역] RecyclerView의 안티 패턴

hongbeom
hongbeomi dev
Published in
13 min readFeb 21, 2021

안드로이드 개발에서 RecyclerView는 기존의 ListView를 대체하는 매우 유용한 first-party 라이브러리입니다. 필자는 RecyclerView의 안티 패턴과 잘못된 어댑터 개념에 대한 지식을 공유 해야겠다고 생각했다고 합니다.

옛날 방식

RecyclerView가 내부에서 어떤 일을 하고 있는지 파악하려면 먼저 이것 없이 이 기능을 구현하는 방법을 이해해야 합니다. BaseAdapter를 확장하는 코드를 작성한 적이 있다면 이 코드를 보았을 것입니다. 예를 들어 커스텀 스피너 어댑터를 살펴보겠습니다. 하나의 TextView를 보여주는 어댑터의 구현에 대해 살펴보겠습니다.

이것은 실행 가능한 해결책이지만 문제점이 존재합니다. 매번 뷰를 inflating하고 있습니다. 이것은 ListView를 스크롤할 때 성능 저하를 일으킵니다. 이를 최적화하기 위해, 우리는 Adapter 인터페이스와 getView 메소드 코드를 유심히 바라봐야 합니다. convertView 파라미터는 nullable하며, 이렇게 설명할 수 있습니다.

가능하다면, 재사용할 수 있는 이전의 뷰입니다. 우리는 이것을 사용하기 전에 뷰가 Null이 아니며, 적절한 타입인지 확인해야 합니다. 올바른 데이터를 표시하도록 이 뷰를 변환할 수 없는 경우 이 메소드(getView)를 사용하여 새로운 뷰를 만들 수 있습니다.

이것은 “이전의 뷰를 재사용한다” 라고 말하고 있습니다. 어댑터는 사용자의 화면 밖에서 뷰 전체를 inflating하거나 recreating하여 재사용합니다. 이는 사용자들에게 더 부드러운 스크롤을 경험하게 하도록 하는 셈입니다.

이제 Adapter의 이런 재활용하는 행동을 보존하기 위해 위 코드를 최적화 할 것입니다. 아래 작업은 convertViewnull인지 여부를 확인하여 수행하며, 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를 사용합니다.

ViewHolderfindViewById 로직을 전달하여 조금 더 오류가 발생할 확률을 줄일 수 있습니다. 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를 사용하지 않으셨다면, 사용하시기를 권장합니다. 그렇게 함으로써 코드 품질이 많이 향상될 것입니다.

읽어주셔서 감사합니다! 🙌

--

--