[Android] 여러 개로 이뤄진 데이터의 목록을 그리는 방법 - 2/3

Kame·2025년 1월 9일

Android

목록 보기
2/9
post-thumbnail

들어가며

  • 이전 편을 읽고 오시는 것을 권장드립니다.
  • 본 설명은 지난 게시물의 [코드 5]를 기반으로 진행됩니다.

모바일, 웹, 데스크탑을 가리지 않고, 거의 모든 GUI 애플리케이션에서 동일한 형태를 가진 많은 데이터가 나열되어 있는 요소(이하 데이터 세트)를 볼 수 있습니다. 즉 클라이언트 개발자 입장에서, 데이터 세트를 그려야 하는 상황은 필연적으로 생기게 됩니다. 본 게시글에서는 안드로이드 프레임워크, 그 중 View System을 활용하여 어떻게 데이터 세트를 보여줄 수 있는지에 관하여 다룹니다.

이 글은 ListView, RecyclerView를 접해보지 않은 사람들이 그 원리, 사용이 필요한 상황, 필요성 등을 이해할 수 있도록 하는 것을 목표로 하고 있습니다. 총 세 편에 걸쳐서 발행될 예정이고, 특정 방식을 사용했을 때 발생하는 문제와 그것을 다른 방식으로 해결하는 모습을 step by step 방식으로 설명할 예정입니다.

대상 독자는 처음 안드로이드 개발을 공부하는 사람이지만, 원활한 이해를 위해 Kotlin 문법과 안드로이드 View, ViewGroup 기초 지식 학습이 선행되어야 합니다.


As-Is 2. 비효율적인 렌더링

이전 편에서, 데이터 세트를 보여주기 위해 모든 아이템을 XML 코드로 작성하는 방법의 한계점을 살펴보았습니다. 그리고 이를 극복하기 위한 수단으로 ListView를 사용하는 방법을 알아보았습니다.

ListView를 사용하기 위하여 다음과 같은 과정을 거쳤습니다.

1. 데이터 셋을 이루는 데이터를 객체 형태로 만든다.
⇨ BenefitListItem -> BenefitListViewItem -> Benefit, Advertisement

2. 데이터 셋을 이루는 데이터를 담을 레이아웃을 만든다.
⇨ item_benefit, item_advertisement

3. <ListView>를 사용할 레이아웃 파일에 추가한다.
⇨ activity_benefits

4. ListView의 데이터를 뷰 형태로 보여주기 위해 Adapter 클래스를 만든다.
⇨ BenefitAdapter

5. Activity/Fragment 등에서 Adapter를 초기화하고 ListView를 사용하고자 하는 레이아웃과 연결한다.
⇨ BenefitsActivity

ListView를 사용하면서, 다음 세 가지 측면에서 이점을 보았습니다.

  • 큰 규모의 데이터 세트를 보여준다.
  • 데이터 세트 변화(Item & Structural Changes)에 대응한다.
  • 여러 타입의 데이터를 보여준다.

하지만 [코드 5] 중, BaseAdapter를 상속받은 BenefitAdapter는 여러 개선이 필요합니다. 잠시 스크롤을 멈추고 아래 코드를 보면서 개선이 필요한 사항들을 추측해 보시는 것을 권장드립니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {

    // getCount

    override fun getItem(position: Int): BenefitListViewItem = benefits[position].viewItem

    // getItemId

    override fun getItemViewType(position: Int): Int = benefits[position].viewItem.viewType

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        // 개선 필요 #1.
        val view = when (val viewType = getItemViewType(position)) {
            BenefitListViewItem.VIEW_TYPE_BENEFIT -> View.inflate(parent.context, R.layout.item_benefit, null)
            BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> View.inflate(parent.context, R.layout.item_advertisement, null)
            else -> throw RuntimeException("Unknown view type")
        }

        // 개선 필요 #2.
        when (val viewItem = getItem(position)) {
            is BenefitListViewItem.Benefit -> {
                view.findViewById<TextView>(R.id.tv_benefit_button_title).text = viewItem.title
                view.findViewById<TextView>(R.id.tv_benefit_button_description).text = viewItem.description
            }
            is BenefitListViewItem.Advertisement -> {
                view.findViewById<TextView>(R.id.tv_advertisement).text = viewItem.content
            }
        }

        return view
    }

    // ...
}

To-Be 2-1. 아이템 뷰 재사용하기

재사용, 왜 필요한가?

모든 상황에서 항상 좋은 것은 아니지만 개발을 할 때 코드를 재사용하면 효율성 등 측면에서 이점을 볼 수 있는 것과 비슷한 이치인 것 같습니다.

데이터의 수를 2000개 가량으로 늘리고, 아이템 뷰를 재사용하지 않았을 때와 재사용을 하였을 때의 성능을 비교해 보겠습니다. 성능을 평가할 수 있는 지표는 여러 가지가 있지만, 여기서는 GPU 렌더링 프로파일링을 진행해보도록 하겠습니다.
(뭔지 잘 모르시겠다면 간단히 스마트폰 주사율이랑 Hz 단위 사용하고 화면 부드럽게 넘어가는거 관련 있는 것이라고 생각하시면 좋을 것 같습니다.)

[그림 1] 재사용 전(코드 5)[그림 2] 재사용 후(코드 6)

성능이 어떤지 자세히 논하기는 힘들지만, 한 눈에 봐도 확연한 차이를 느낄 수 있습니다. 막대의 높이는 프레임을 렌더링하는 데 소요된 시간을 의미합니다. 레이아웃을 뷰 객체 형태로 만드는 inflate 작업은 매우 많은 비용이 소요됩니다. 그림을 통해 재사용을 하지 않으면 스크롤을 진행할 때마다 막대 그래프가 높이 치솟는 반면, 재사용을 하면 초반을 제외하고는 비교적 안정적으로 낮은 높이를 보이는 것을 확인할 수 있습니다.

결국 재사용을 하지 않으면 스크롤을 할 때마다 각종 명령을 처리하는 데 시간이 걸리게 되고, 렌더링 성능 저하로 이어질 수 있다는 것입니다. 요즘 스마트폰들 제조사에서 초당 프레임을 늘리면서 자사 기기의 부드럽게 넘어가는 화면을 강조하는데, 이러한 점을 고려하면 앱 개발 차원에서도 렌더링 성능을 해치지 않으려는 노력이 필수적일 것입니다.

필요성을 알아보았으니, 이제 방법을 알아보겠습니다.

convertView

getView의 매개변수 중, 약간 생소한 개념인 convertView가 보입니다. 공식문서에서 설명하는 convertView의 목적과 사용 방법을 살펴보겠습니다.

View: The old view to reuse, if possible.
Note: You should check that this view is non-null and of an appropriate type before using. If it is not possible to convert this view to display the correct data, this method can create a new view. ...(계속)...

재사용이라는 가치를 통해 어댑터의 성능 향상을 이끌어낼 수 있는 존재이며, Nullable하다는 특징을 가지고 있음을 알 수 있습니다.

getView 메서드의 convertView 파라미터가 nullable한 이유를 ListView의 효율적인 재사용 메커니즘을 통해 추측해볼 수 있습니다.

[그림 3] convertView를 활용한 ListView의 재활용 원리

Adapter는 초기에 ListView의 화면을 채울 만큼의 아이템 뷰들을 생성합니다. 현재 예시에서는 대략 전체 화면 높이에 해당하는 수량이 될 것입니다. 이렇게 초기에 생성된 개수만큼의 아이템 뷰가 메모리에 유지됩니다.

사용자가 리스트를 스크롤할 때, 화면에서 사라지는 아이템들은 나중에 사용하기 위해 메모리에 보관되고, 화면에 새로 나타나는 모든 새로운 행은 메모리에 보관된 이전 행을 재사용합니다.

ListView에서 처음 아이템을 그릴 때는 재사용할 수 있는 뷰가 없으므로 convertView는 null로 전달됩니다. 이후 스크롤로 인해 화면 밖으로 나간 뷰들은 이후 non-null convertView로 전달됩니다. 이 때 다시 넘겨받은 convertView에 필요한 데이터를 할당하는 방식으로 재사용이 진행됩니다.

참고로 화면 밖으로 사라졌다가 다시 나타나기 전까지의 과정을 다음과 같은 상태들로 나타낼 수 있습니다.

  • Scrap View(①) : 화면에서 벗어났지만 완전히 메모리에서 분리되지는 않은 아이템 뷰 (② 과정)
  • Dirty View(③) : 재사용(⑤ 과정) 되기 이전, 새로운 데이터로 바인딩(④ 과정)이 필요한 상태의 뷰

재사용을 통해 성능 상 이점을 얻을 수 있기 때문에, convertView를 사용하는 것이 권장됩니다. 공식문서에도 뷰 객체의 재사용을 권장하는 설명과 함께 다음과 같은 코드가 제공되고 있습니다. (공식문서 원본은 자바 코드이나, 코틀린으로 입맛에 맞춰 번역하였습니다.)

class MyAdapter : BaseAdapter() {
    // override other abstract methods here
    override fun getView(position: Int, convertView: View?, container: ViewGroup): View {
        val view = convertView ?: LayoutInflater.from(container.context).inflate(R.layout.list_item, container, false)
        view.findViewById<TextView>(android.R.id.text1).text = getItem(position)
        return view
    }
}

이 방법을 토대로 convertView를 사용하여 [코드 5]의 BenefitAdapter를 개선해보겠습니다.

[코드 6]

BenefitAdapter.kt

  • TODOs
    아이템 뷰를 생성하는 작업을 함수 분리
    초기에만 사용할 아이템을 만들어 놓고, 이후에는 만들어 놓은 convertView를 재사용하여 새로운 데이터로 갈아끼우기

① 아이템 뷰를 생성하는 작업을 함수 분리

기존 getView()에서 진행하던 뷰 생성 작업을 별도의 함수로 분리합니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {

    // ...

    private fun generateItemView(viewType: Int, parent: ViewGroup): View {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            BenefitListViewItem.VIEW_TYPE_BENEFIT -> inflater.inflate(
                R.layout.item_benefit,
                parent,
                false
            )
            BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> inflater.inflate(
                R.layout.item_advertisement,
                parent,
                false
            )
            else -> throw RuntimeException("Unknown view type")
        }
    }

    // ...
}

② 초기에만 사용할 아이템을 만들어 놓고, 이후에는 만들어 놓은 convertView를 재사용하여 새로운 데이터로 갈아끼우기

생성 횟수를 확인하기 위해, 타입 별로 아이템이 만들어질 때마다 로깅을 진행합니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {

    // ...
    
    private fun generateItemView(viewType: Int, parent: ViewGroup): View {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            BenefitListViewItem.VIEW_TYPE_BENEFIT -> {
                Log.i("BenefitAdapter", "generateItemView: benefit")
                inflater.inflate(
                    R.layout.item_benefit,
                    parent,
                    false
                )
            }

            BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> {
                Log.i("BenefitAdapter", "generateItemView: advertisement")
                inflater.inflate(
                    R.layout.item_advertisement,
                    parent,
                    false
                )
            }

            else -> throw RuntimeException("Unknown view type")
        }
    }

    // ...
}

[결과 6]

[그림 4] 재사용 후 결과

혜택 데이터의 수가 매우 많음에도 불구하고, 혜택 아이템의 생성 관련 로깅이 11회만 출력되고 있습니다. 이제 재사용이 가능해졌습니다!


To-Be 2-2. 중복된 내부 뷰 참조를 캐싱을 통해 줄이기

지금까지 코드를 개선하며, 아이템을 담을 뷰를 재사용할 수 있게 되었습니다. 하지만 여기서 더 성능을 개선해볼 수 있습니다.

각 아이템은 n개의 뷰를 가지고 있습니다. 혜택 데이터를 띄우기 위해 필요한 아이템 레이아웃의 경우, 하위의 텍스트 뷰들이 여기에 해당될 것입니다. 그리고 각 아이템이 렌더링 될 때마다, 리스트의 각 데이터를 UI 요소에 표시하는 작업이 진행됩니다. 이렇게 데이터를 뷰에 연결하는 과정을 바인딩이라는 용어로 부르기도 합니다.

하지만 [코드 6]에서는 이러한 바인딩 작업이 꽤나 비효율적으로 이뤄지고 있습니다. 뷰에 데이터를 바인딩하는 과정을 한 번 살펴보겠습니다.

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    // ...

    when (val viewItem = getItem(position)) {
        is BenefitListViewItem.Benefit -> {
            view.findViewById<TextView>(R.id.tv_benefit_button_title).text = viewItem.title
            view.findViewById<TextView>(R.id.tv_benefit_button_description).text = viewItem.description
        }

        is BenefitListViewItem.Advertisement -> {
            view.findViewById<TextView>(R.id.tv_advertisement).text = viewItem.content
        }
    }

    // ...
}

findViewById() 메서드의 호출이 눈에 띕니다. findViewById의 동작 방식

해당 메서드는 호출하는 뷰 객체를 기준으로 뷰 계층을 순회하여 원하는 뷰를 찾아가는 방식인데, 이러한 순회 방식은 상황에 따라 비효율적일 수 있습니다. (특히 깊은 계층으로 이뤄져 있는 뷰라면)

현재 코드는 데이터 할당을 위해 findViewById()를 활용하여 뷰가 만들어질 때마다 하위 뷰의 참조를 찾아가는 과정을 새로이 진행하고 있습니다. 복잡하지 않은 구조로 되어있는 아이템이라 지금은 괜찮겠지만, 추후를 대비하여 더 효율적인 방법을 고려해 보는 것이 좋을 것입니다. 이것을 해결할 수 있는 방식 중 하나로, ViewHolder 패턴이 있습니다.

ViewHolder : 뷰 참조 캐싱 수단

아이템 뷰를 만들 때 초기에 하위 뷰의 참조까지 캐싱해 놓으면, 이후에는 바인딩 시 재차 뷰를 찾기 위한 순회 과정이 필요 없을 것입니다.

이것을 가능하게 하는 것이 ViewHolder 패턴입니다. 이것을 활용하면 아이템 뷰(여기서는 convertView)가 재사용될 때마다 즉시 하위 뷰에 접근할 수 있게 되므로, 단일 뷰를 표출할 때마다 일일이 findViewById를 호출할 필요가 없게 됩니다.

ViewHolder에는 뷰 참조뿐만 아니라 클릭 리스너와 같은 변경되지 않는 객체들도 함께 캐싱할 수 있습니다. 이렇게 동적 데이터를 제외한 요소들을 미리 저장해두면, 아이템 렌더링 과정을 더욱 효율적으로 만들 수 있습니다.

이제 이 개념을 활용하여 리팩토링을 진행해보겠습니다.

[코드 7]

  • TODOs
    데이터 종류 별 뷰 홀더 클래스 만들기
    어댑터에서 뷰 홀더 활용하기

① 데이터 종류 별 뷰 홀더 클래스 만들기

재사용할 아이템 뷰와 하위 뷰들의 참조를 캐싱하기 위한 ViewHolder 클래스를 만들어보겠습니다.

현재 두 타입의 아이템을 렌더링해야 하는 상황인데, 원활한 분기처리를 위하여 이번에도 sealed class를 활용해보도록 하겠습니다. 혜택 화면 전용 상위 sealed class를 만들고, 그 하위에 필요한 뷰 타입 별로 ViewHolder 클래스를 둡니다.

BenefitScreenViewHolder.kt

sealed class BenefitScreenViewHolder(val view: View) {
    class BenefitViewHolder(view: View) : BenefitScreenViewHolder(view) {
        private val title: TextView = view.findViewById(R.id.tv_benefit_button_title)
        private val description: TextView = view.findViewById(R.id.tv_benefit_button_description)

        fun bind(benefit: BenefitListViewItem.Benefit) {
            title.text = benefit.title
            description.text = benefit.description
        }
    }

    class AdvertisementViewHolder(view: View) : BenefitScreenViewHolder(view) {
        private val content: TextView = view.findViewById(R.id.tv_advertisement)

        fun bind(advertisement: BenefitListViewItem.Advertisement) {
            content.text = advertisement.content
        }
    }
}

② 어댑터에서 뷰 홀더 활용하기

BenefitAdapter.kt

다시 Adapter의 대대적인 수정이 필요합니다.

첫 번째로 createViewHolder()를 구현하였습니다. 이는 처음 리스트 뷰를 렌더링 할 때, 재사용할 뷰홀더를 만들어내는 함수입니다.

이렇게 만들어진 뷰홀더들은 재사용을 위하여 캐싱이 필요합니다.

편의상, 아이템 뷰에 뷰홀더를 종속시키는 방식을 활용하도록 하겠습니다. View의 tag 속성(Object/Any 타입)을 사용하여, convertView에 ViewHolder를 캐싱할 수 있습니다.

generateItemView()를 호출하여 만들어낸 아이템 뷰에 뷰홀더를 캐싱하면, 추후 getView()에서 convertView 사용 시 tag 프로퍼티에 접근하여 캐싱된 뷰홀더를 활용할 수 있게 됩니다.

뷰홀더 내부에는 아이템의 하위 뷰(TextView)들을 캐싱했습니다. 이로서 [코드 6]의 getView() 에서 직접 데이터를 할당하던 부분을 뷰홀더의 bind() 함수 내부로 이동시킬 수 있습니다.

또한, 뷰홀더가 만들어지는(= findViewById 호출로 하위 뷰들의 참조가 이뤄지는) 횟수를 파악하기 위해 뷰홀더가 만들어질 때마다 로깅을 진행하는 코드를 추가했습니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {
    // ...
    
    private fun createViewHolder(
        viewType: Int,
        parent: ViewGroup
    ): BenefitScreenViewHolder {
        val view = generateItemView(viewType, parent)
        val holder: BenefitScreenViewHolder =
            when (viewType) {
                BenefitListViewItem.VIEW_TYPE_BENEFIT -> {
                    Log.i("BenefitAdapter", "createViewHolder: benefit")
                    BenefitScreenViewHolder.BenefitViewHolder(view)
                }

                BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> {
                    Log.i("BenefitAdapter", "createViewHolder: advertisement")
                    BenefitScreenViewHolder.AdvertisementViewHolder(view)
                }

                else -> throw IllegalArgumentException("Unknown view type")
            }

        view.tag = holder
        return holder
    }
    
    // ...
}

두 번째는 bindViewHolder()입니다.

데이터를 아이템 뷰에 바인딩하기 위하여 ①에서 만들어둔 뷰홀더들의 bind() 메서드를 호출하는 작업을 수행합니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {
    // ...

    private fun bindViewHolder(viewHolder: BenefitScreenViewHolder, position: Int) {
        val item = getItem(position)
        when (viewHolder) {
            is BenefitScreenViewHolder.BenefitViewHolder -> {
                viewHolder.bind(item as BenefitListViewItem.Benefit)
            }

            is BenefitScreenViewHolder.AdvertisementViewHolder -> {
                viewHolder.bind(item as BenefitListViewItem.Advertisement)
            }
        }
    }

    // ...
}

세 번째는 generateViewHolder()입니다.

아직 만들어진 뷰가 없다면 createViewHolder()를 통해 아이템 뷰와 뷰홀더를 최초로 만들어 놓고 뷰홀더를 아이템 뷰에 종속시킵니다. 이후 재사용 시 convertView의 tag 프로퍼티를 통해 이미 만들어진 뷰홀더를 가져오는 과정을 수행합니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {
    // ...
    
    private fun generateViewHolder(
        position: Int,
        convertView: View?,
        parent: ViewGroup,
    ): BenefitScreenViewHolder {
        val viewType = getItemViewType(position)
        val viewHolder =
            convertView?.tag as BenefitScreenViewHolder? ?: createViewHolder(viewType, parent)

        return viewHolder
    }

    // ...
}

위의 함수들을 조합하여 getView()의 동작을 수정합니다. 새로 만들어지거나 재사용된 뷰홀더를 가져오고, 가져온 뷰홀더를 활용하여 데이터를 하위 뷰에 바인딩합니다.

변경 사항이 반영된 아이템 뷰를 ViewHolder에서 참조하고 있기에, View 반환 시 뷰 홀더 내부의 아이템 뷰를 반환하면 끝입니다.

class BenefitAdapter(
    private var benefits: List<BenefitListItem>,
) : BaseAdapter() {
    // ...
    
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val viewHolder = generateViewHolder(position, convertView, parent)
        bindViewHolder(viewHolder, position)

        return viewHolder.view
    }
    
    // ...
}

[결과 7]

[그림 5] 참조 캐싱 후 결과

아이템 뷰를 만들어내는 횟수와 동일한 횟수만큼 뷰홀더를 만들어내는 데 성공했습니다.
이 작업을 통해 findViewById() 호출 횟수를 줄이며 더욱 효율적인 렌더링을 진행할 수 있게 되었습니다.


As-Is 3. 더 Flexible하게 만들 수는 없을까?

ListView의 성능을 개선하기 위하여, [코드 6] ~ [코드 7]에 걸쳐 많은 작업을 진행하였습니다. 지금까지 단일 화면을 작업하는 데 있어서도 꽤나 많은 수고가 들어갔습니다. 만약 대규모의 앱을 개발하게 된다면 작성해야 할 코드는 더 많아질 것이고 여러 설계 상 고민들도 추가로 수반될 것입니다. 사실 지금까지 당장에 구현하기 편한 방식을 많이 선택했는데, 유지보수 측면에서 현재 코드를 기반으로 앱을 확장시킨다면 꽤 많은 어려움이 있을 것으로 예상됩니다.

안드로이드 개발에서는 데이터 세트를 그리기 위해 ListView 이외에 다른 수단을 고려해볼 수 있습니다.
대표적인 방법은 Jetpack 라이브러리 중 하나인 RecyclerView를 활용하는 것입니다.

RecyclerView

정의

공식문서에서 설명하고 있는 RecyclerView의 정의는 다음과 같습니다.

A flexible view for providing a limited window into a large data set.

여기서 가장 중요한 키워드는 flexible이라고 생각합니다.

이름만 놓고 보면, 아이템을 재사용하는 뷰로 생각할 수 있습니다. 다만 명시적으로 재사용을 정의하지는 않고, 대규모의 데이터 세트를 표시하기 위한 유연한 뷰로 정의하고 있습니다.

RecyclerView는 앞서 사용한 ListView와 상당수의 개념들을 공유하고 있습니다. Adapter, ViewHolder, Position, Binding, Scrap, Dirty 등... 전체적인 구조와 사용되는 용어는 ListView와 유사합니다.

다만 RecyclerView를 활용하면 최적화를 더 쉽게 달성하고, 다양한 형태의 리스트를 더욱 쉽게 구현할 수 있습니다. 따라서 RecyclerView에서 flexible단순한 뷰 재사용을 넘어, 다양한 형태의 리스트를 효율적으로 구현할 수 있는 유연한 프레임워크를 제공한다는 의미로 이해해볼 수 있습니다.

ListView, RecyclerView의 사실과 오해

① 재사용에 관하여

ListView에서는 뷰 재사용을 할 수 없다 : X
ListView에서는 뷰 재사용을 강제하지 않는다 : O

다소 수고가 들어갔을 뿐, ListView에서도 충분히 재사용을 구현해낼 수 있었습니다. ListView 차원에서 제공하는 convertView를 활용하고, 여기에 추가적으로 ViewHolder 패턴을 구현할 수 있었기 때문입니다.

다만 개발자가 ListView에서 직접 구현해야 했던 최적화 기법들을 RecyclerView에서는 더 쉽게, 필수적으로 구현하도록 유도하고 있습니다. 그래서 추측하건대 RecyclerView라는 명칭의 이면에는 사실 아이템 뷰의 Recycling을 강제한다는 의미가 숨겨져 있다고 생각합니다.

② RecyclerView와 ListView 간 관계

[그림 6] RecyclerView와 ListView 관계

ListView와 RecyclerView를 자주 비교하게 됩니다. 이 두 컴포넌트는 비슷한 목적으로 사용되기 때문에 특정 관계를 형성한다고 생각하기 쉽지만, 실제로는 서로 다른 클래스 계층 구조를 가지고 있습니다. ListView와 RecyclerView끼리는 부모-자식 관계가 아니고 독립적인 클래스입니다.

  • ListView: AbsListView를 상속
  • RecyclerView: ViewGroup을 직접 상속

③ ListView와 RecyclerView에서 사용하는 Adapter들 간 관계

[그림 7] ListView와 RecyclerView의 Adapter들 간 관계

RecyclerView는 자체적인 어댑터인 RecyclerView.Adapter를 사용합니다. 이 어댑터는 기본적으로 아이템 뷰를 생성하고 데이터를 바인딩한다는 점에서 BaseAdapter를 포함한 다른 Adapter 클래스들과 유사한 역할을 수행합니다.

ListView-RecyclerView 간 관계와 마찬가지로, RecyclerView.Adapter는 ListView에서 사용되는 android.widget.Adapter 기반 클래스들과 어떠한 관계도 갖지 않습니다. RecyclerView.Adapter는 완전히 독립적으로 설계되어 있습니다.

  • BaseAdapter: ListAdapter 인터페이스를 구현함
  • RecyclerView.Adapter: 직접 구현된 독립적인 클래스

To-Be 3-1. 더 쉽게 성능 보장하기

지금부터 ListView를 RecyclerView로 마이그레이션 해보며 RecyclerView의 장점을 직접 느껴보도록 하겠습니다.

[코드 8]

  • TODOs
    RecyclerView.Adapter를 상속받는 새로운 Adapter 클래스 만들기
    Activity 상의 ListView 사용을 RecyclerView로 변경하기

RecyclerView.Adapter를 상속받는 새로운 Adapter 클래스 만들기

  • ViewHolder : BenefitScreenRecyclerViewHolder.kt

앞서 언급했듯, RecyclerView에서는 ViewHolder 패턴을 강제합니다.
따라서 어댑터를 만들기 앞서 아이템 종류 별로 ViewHolder 클래스를 만들어야 하는데, 이 클래스가 RecyclerView.ViewHolder를 상속받도록 해야 합니다.

[코드 7]에서 ListView 아이템 뷰 캐싱을 위해 사용했던 BenefitScreenViewHolder 클래스를 복사하여, BenefitScreenRecyclerViewHolder로 클래스명 변경 후 RecyclerView.ViewHolder를 상속받도록 하였습니다. 이렇게 상속 코드만 추가하면 RecyclerView에서 ViewHolder를 사용할 준비를 손쉽게 마칠 수 있습니다.

sealed class BenefitScreenRecyclerViewHolder(view: View)
    : RecyclerView.ViewHolder(view) { // 상속 추가!!
        class BenefitViewHolder(view: View) : BenefitScreenRecyclerViewHolder(view) {
            // 이전과 동일
        }

        class AdvertisementViewHolder(view: View) : BenefitScreenRecyclerViewHolder(view) {
            // 이전과 동일
        }
    }
  • Adapter : BenefitRecyclerViewAdapter.kt

RecyclerView.Adapter<VH>를 상속받은 클래스를 만듭니다. 제네릭 <VH> 자리에는 RecyclerView.ViewHolder를 상속받은 타입을 명시해야 하므로, 앞서 만들어두었던 BenefitScreenRecyclerViewHolder를 활용합니다.

이어서 필요한 메서드를 오버라이드합니다.

  • 오버라이드 필수 : onCreateViewHolder, onBindViewHolder, getItemCount
  • 여러 뷰타입을 지원하는 경우에 한해 오버라이드 : getItemViewType

먼저 여러 뷰 타입을 지원하기 위해 getItemViewType을 오버라이드 하겠습니다. 해당 메서드는 BenefitAdapter에서 사용했던 동명의 메서드와 동일하게 구현합니다.

class BenefitRecyclerViewAdapter(
    private var benefits: List<BenefitListItem>,
) : RecyclerView.Adapter<BenefitScreenRecyclerViewHolder>() {
    // ...

    override fun getItemViewType(position: Int): Int {
        return when (benefits[position].viewItem) {
            is BenefitListViewItem.Benefit -> BenefitListViewItem.VIEW_TYPE_BENEFIT
            is BenefitListViewItem.Advertisement -> BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT
        }
    }
    
    // ...
}

필수 구현 메서드 중 getItemCount의 역할 역시 BenefitAdapter 구현하였던 getCount와 동일합니다. 어댑터에서 들고 있을 데이터 세트의 규모를 반환하면 됩니다.

class BenefitRecyclerViewAdapter(
    private var benefits: List<BenefitListItem>,
) : RecyclerView.Adapter<BenefitScreenRecyclerViewHolder>() {
    // ...

    override fun getItemCount(): Int = benefits.count()
    
    // getItemViewType
    
    // ...
}

마지막으로 뷰홀더 패턴을 위한 onCreateViewHolder, onBindViewHolder를 오버라이드합니다. 각각 앞서 구현했던 BenefitAdapter에서 뷰홀더를 만들어내고, 바인딩하는 동작과 유사합니다. RecyclerView.Adapter에서는 뷰홀더 패턴을 강제하기 위하여 반드시 두 메서드를 오버라이드 해야 하는 것일 뿐입니다.

따라서 기존 코드 BenefitAdapter를 활용하여 큰 변경사항 없이 구현할 수 있었습니다.

BenefitRecyclerViewAdapterBenefitAdapter(BaseAdapter)
onCreateViewHoldergenerateItemView + createViewHolder + generateViewHolder
onBindViewHolderbindViewHolder
class BenefitRecyclerViewAdapter(
    private var benefits: List<BenefitListItem>,
) : RecyclerView.Adapter<BenefitScreenRecyclerViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BenefitScreenRecyclerViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val viewHolder = when (viewType) {
            BenefitListViewItem.VIEW_TYPE_BENEFIT -> {
                val view = inflater.inflate(R.layout.item_benefit, parent, false)
                BenefitScreenRecyclerViewHolder.BenefitViewHolder(view)
            }

            BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT -> {
                val view = inflater.inflate(R.layout.item_advertisement, parent, false)
                BenefitScreenRecyclerViewHolder.AdvertisementViewHolder(view)
            }

            else -> throw RuntimeException("Unknown view type")
        }

        return viewHolder
    }

    override fun onBindViewHolder(holder: BenefitScreenRecyclerViewHolder, position: Int) {
        when (holder) {
            is BenefitScreenRecyclerViewHolder.BenefitViewHolder -> {
                holder.bind(benefits[position].viewItem as BenefitListViewItem.Benefit)
            }

            is BenefitScreenRecyclerViewHolder.AdvertisementViewHolder -> {
                holder.bind(benefits[position].viewItem as BenefitListViewItem.Advertisement)
            }
        }
    }

    override fun getItemCount(): Int = benefits.count()

    override fun getItemViewType(position: Int): Int {
        return when (benefits[position].viewItem) {
            is BenefitListViewItem.Benefit -> BenefitListViewItem.VIEW_TYPE_BENEFIT
            is BenefitListViewItem.Advertisement -> BenefitListViewItem.VIEW_TYPE_ADVERTISEMENT
        }
    }

    // TODO 변경 사항 반영 코드 최적화 필요
    fun refreshBenefitData(newBenefits: List<BenefitListItem>) {
        benefits = newBenefits
        notifyDataSetChanged()
    }
}

② Activity 상의 ListView 사용을 RecyclerView로 변경하기

activity_benefits.xml

레이아웃 xml 파일에서 <ListView><RecyclerView>로 변경합니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_benefits"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/item_benefit"/>

    <!-- buttons -->

</androidx.constraintlayout.widget.ConstraintLayout>

ListView를 사용할 때와는 달리, LayoutManager도 설정해 주어야 합니다. 동적으로 레이아웃 매니저를 설정하고 싶다면, 액티비티 코드에서 진행하면 됩니다.

리사이클러뷰.layoutManager = LinearLayoutManager(this)

BenefitsActivity.kt

Activity에서 사용할 어댑터를 BenefitRecyclerViewAdapter로 변경하기 위해, 어댑터 초기화 코드도 변경해줍니다.

참고) RecyclerView의 LayoutManager를 xml에서 설정할 수 있듯, Adapter 역시 xml에서 설정할 수 있습니다. 다만 DataBinding 라이브러리의 활용이 요구됩니다.

class BenefitsActivity : AppCompatActivity() {
    // ...
    private val adapter = BenefitRecyclerViewAdapter(benefitRepository.benefits)

    // ...

    private fun initializeAdapter() {
        val benefitsList = findViewById<RecyclerView>(R.id.rv_benefits)
        benefitsList.adapter = adapter
        // 동적으로 layoutManager 설정. 이미 xml에서 진행했으므로 사용하지 않음
        // benefitsList.layoutManager = LinearLayoutManager(this)
    }
    
    // ...
}

[결과 8]

  • [결과 7]과 동일합니다.

코드 상 변화

convertView 관리 코드 제거

RecyclerView가 자동으로 뷰 재사용을 관리하기 때문에 관련 코드를 작성할 필요가 없어졌습니다. ListView의 getView() 메서드에서 수행하던 convertView 처리 관련 코드를 제거할 수 있었습니다.

ViewHolder 패턴 사용 유도

수동으로 ViewHolder 패턴을 구현해야 하는 BaseAdapter와는 달리, RecyclerView.Adapter는 onCreateViewHolder와 onBindViewHolder을 오버라이드하도록 함으로써 ViewHolder 패턴의 사용을 자연스럽게 유도합니다. 모든 RecyclerView 구현이 동일한 패턴을 따르도록 강제하기 때문에, 코드의 일관성과 유지보수성을 향상시킬 수 있습니다.


To-Be 3-2. 데이터 갱신 효율화하기

변동된 아이템만 갱신하기

RecyclerView로 마이그레이션을 진행해 보았으나, 아직 해결하지 않은 문제가 있습니다.

마이그레이션 과정 중, Adapter에서 데이터의 갱신을 반영하는 refreshBenefitData 메서드를 그대로 가져다 사용했습니다.

RecyclerView.Adapter에도 notifyDataSetChanged 메소드가 존재했기에, 문제 없이 구현이 가능했고 동작도 의도한 대로 이뤄졌습니다.

// BenefitRecyclerViewAdapter
fun refreshBenefitData(newBenefits: List<BenefitListItem>) {
    benefits = newBenefits
    notifyDataSetChanged() // Warning!
}

그러나 해당 메서드를 사용한 코드라인에서 경고가 표출되는데,

It will always be more efficient to use more specific change events if you can. Rely on notifyDataSetChanged as a last resort.

Inspection info: The RecyclerView adapter's onNotifyDataSetChanged method does not specify what about the data set has changed, forcing any observers to assume that all existing items and structure may no longer be valid. (...계속...)

이전 편에서 notifyDataSetChanged는 어떤 데이터가 변경되었는지 구체적으로 명시하지 않고 모든 뷰를 새로고침하기 때문에 비효율적일 수 있다고 언급하였습니다. 이러한 맥락의 내용이 경고로 표출됨을 확인할 수 있습니다.

재미있는 사실은 BaseAdapter에서 notifyDataSetChanged를 사용할 때는 이러한 경고가 나오지 않았다는 것입니다. 그 이유를 알아보도록 하겠습니다.

🤨 부분 업데이트를 지원하는 RecyclerView

ListView와 RecyclerView는 부분 업데이트 지원 여부에 차이를 보입니다. ListView는 부분 업데이트를 지원하지 않지만, RecyclerView는 부분 업데이트를 지원합니다.

따라서 ListView의 BaseAdapter에서 데이터 갱신을 반영하기 위해서는 notifyDataSetChanged를 사용하여 전체를 업데이트하는 수밖에 없었던 것입니다.

반면 RecyclerView의 Adapter에서는 부분 업데이트를 위한 여러 종류의 메서드를 지원합니다. 변경사항이 발생한 부분만 다시 바인딩하도록 하며 성능을 더욱 향상시킬 수 있기에, 이러한 기능들을 잘 사용하는 것이 좋습니다.

notifyItemChanged(position: Int)특정 위치의 아이템 변경
notifyItemInserted(position: Int)특정 위치에 새 아이템이 삽입
notifyItemRemoved(position: Int)특정 위치의 아이템이 제거
notifyItemMoved(fromPosition: Int, toPosition: Int)아이템의 위치 변경
notifyItemRangeChanged(positionStart: Int, itemCount: Int)연속된 범위의 아이템들 변경
notifyItemRangeInserted(positionStart: Int, itemCount: Int)연속된 범위에 새 아이템들 삽입
notifyItemRangeRemoved(positionStart: Int, itemCount: Int)연속된 범위의 아이템들 제거

이 수단들을 활용해, 이전 코드를 개선해보도록 하겠습니다.

[코드 9]

BenefitRecyclerViewAdapter.kt

전체 데이터를 갱신하는 refreshBenefitData 대신, 두 메서드를 구현합니다.

  • 단일 아이템의 콘텐츠를 갱신할 때 사용할 refreshBenefitItem
  • 아이템의 위치가 변경되었을 때 사용할 changeBenefitItemPosition
class BenefitRecyclerViewAdapter(
    private var benefits: List<BenefitListItem>,
) : RecyclerView.Adapter<BenefitScreenRecyclerViewHolder>() {

    // fun refreshBenefitData(newBenefits: List<BenefitListItem>) {
    //     benefits = newBenefits
    //     notifyDataSetChanged()
    // }

    fun refreshBenefitItem(position: Int, newBenefits: List<BenefitListItem>) {
        benefits = newBenefits
        notifyItemChanged(position)
    }

    fun changeBenefitItemPosition(
        prevPosition: Int,
        newPosition: Int,
        newBenefits: List<BenefitListItem>,
    ) {
        benefits = newBenefits
        notifyItemMoved(prevPosition, newPosition)
    }
}

BenefitsActivity.kt

어댑터를 사용하는 액티비티 측에서도 데이터 원본의 변경에 맞는 어댑터 사본 데이터 갱신 로직을 적절히 호출하도록 변경합니다.

class BenefitsActivity : AppCompatActivity() {

    // ...

    private fun initializeButtons() {
        val buttonAlterTitle = findViewById<Button>(R.id.btn_alter_title)
        val buttonAlterPosition = findViewById<Button>(R.id.btn_alter_position)

        buttonAlterTitle.setOnClickListener {
            alterBenefitTitle(3, "변경 완!")
            adapter.refreshBenefitItem(2, benefitRepository.benefits)
        }

        buttonAlterPosition.setOnClickListener {
            alterBenefitData(3, 0)
            adapter.changeBenefitItemPosition(2, 0, benefitRepository.benefits)
        }
    }

    private fun alterBenefitTitle(id: Long, newTitle: String) {
        benefitRepository.updateBenefitTitle(
            id = id,
            newTitle = newTitle,
        )
    }

    private fun alterBenefitData(id: Long, newPosition: Int) {
        benefitRepository.updateBenefitPosition(
            id = id,
            newPosition = newPosition,
        )
    }
}

[결과 9]

[그림 8] 변경된 아이템만 갱신 조치 결과

정상적으로 동작함과 더불어, 별도의 애니메이션 코드를 작성하지 않았음에도 데이터 세트의 변화에 알맞는 애니메이션도 추가되었습니다.

이 사실을 통해 RecyclerView에서는 동적으로 데이터 세트가 변경되는 경우 기본적으로 애니메이션도 지원하고 있음을 확인할 수 있습니다.

관련 링크


To-Be 3-3. (번외) 다양한 아이템 배치 방식 지원하기

ListView의 정의를 다시 살펴보겠습니다.

Displays a vertically-scrollable collection of views, where each view is positioned immediatelybelow the previous view in the list.

ListView는 수직으로 배열된 데이터 세트를 보여주는 데 사용됩니다. 만약 데이터 세트를 격자 레이아웃으로 배열해야 한다면, ListView로는 목적을 달성할 수 없게 됩니다.

이것을 해결하는 방법은 두 가지가 있습니다.

  • ListView의 형제 격인 GridView 사용하기
  • RecyclerView를 사용하기

만약 ListView를 사용하고 있었는데 아이템을 격자로 배열해야 하는 상황이 발생하면, GridView로 전환해야 하는 수고가 클 것입니다. 더 나아가 수직 레이아웃과 격자 레이아웃을 혼합하여 사용해야 한다면 ListView와 GridView를 함께 사용해야 하고, 수평으로 스크롤되는 레이아웃이 필요하다면 또 다른 방법을 찾아야 합니다.

반면 RecyclerView를 사용하면 이러한 문제를 쉽게 해결할 수 있습니다. RecyclerView는 LayoutManager를 통해 다양한 레이아웃 요구사항에 대응할 수 있기 때문입니다.

LayoutManager

[코드 8]에서, RecyclerView를 사용할 때는 Adapter와 LayoutManager를 모두 설정해야만 아이템을 렌더링할 수 있다고 언급하였습니다.

LayoutManager는 RecyclerView 내의 아이템 뷰들을 배치하고 스크롤 동작을 관리하는 역할을 가집니다.

RecyclerView에서는 세 종류의 LayoutManager를 통해 다양한 아이템 배치를 지원할 수 있습니다.

  • LinearLayoutManager
  • GridLayoutManager
  • StaggeredGridLayoutManager

다음 편에 계속

안드로이드 개발을 처음 접하고 처음 RecyclerView를 접했을 때, ListView는 성능 측면에서 불리하기 때문에 사용을 지양해야겠다는 생각을 가지고 있었습니다. 지금 생각해 보면 이것은 반은 맞고 반은 틀린 사실인 것 같습니다.

깊은 고민 없이 구현할 것이라면 일정 수준의 성능을 보장해주는 RecyclerView를 선택하는 것이 좋을 것입니다. 하지만 ListView로도 RecyclerView에 준하는(능력 있으신 분들은 그것을 뛰어넘는) 성능을 구현해낼 수 있고, 상황에 따라서는 RecyclerView보다 ListView로 구현하는 것이 더 효율적인 때도 있기 때문에 잘 고민하고 사용해야 할 것입니다.

지금까지 ListView의 성능을 개선하고, RecyclerView로 마이그레이션을 진행해보며 데이터 세트를 한층 더 모던하게 렌더링하는 방법을 알아보았습니다.

마지막 편에서는 RecyclerView를 더욱 효율적으로 사용할 수 있는 방법을 다뤄보도록 하겠습니다.

코드 저장소

출처

https://developer.android.com/reference/android/widget/ListView
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView
https://developer.android.com/topic/performance/rendering/profile-gpu?hl=ko
https://guides.codepath.com/android/Using-an-ArrayAdapter-with-ListView

profile
Software Engineer

0개의 댓글