우아한 테크 코스/레벨 2

ListView 는 ViewHolder 재사용을 못하는 거 아니었나요??? (아님) feat 우테코

sh1mj1 2024. 5. 17. 11:19

 

우테코에서는 "과속, 과식하지 말것" 이라는 권장 목표가 있다.

즉, 수업에서 다루는 것부터 공부하고 미션에 적용해보고 나서, 그 다음 기술을 사용하는 것이다.

 

이번 미션은 '영화 티켓 예매'이다. 

오른쪽과 같은 화면을 `RecyelrView`가 아니라 `ListView`로 구현해야 한다.

 

나는 리스트뷰를 사용해본 적이 없다.

그렇다고, 이 미션에서 리사이클러뷰를 사용할 수는 없다.

그래서 진행 전부터 난관이 예상되었다.

 

이번 글에서는

  1. 리스트뷰를 통해 화면을 구성하고
  2. 뷰홀더 패턴을 적용, 뷰홀더를 뷰의 tag 에 캐싱하도록 리팩토링.
  3. 뷰의 tag 에 캐싱하는 게 아닌, 다른 객체에 캐싱하도록 리팩토링.

참고로

  1. MVVM 이 아닌, MVP 패턴을 적용했고,
  2. viewBidning, dataBinding 을 사용하지 않고 앱을 만들었다.

단순 리스트뷰

먼저 단순히 리스트뷰로 화면을 구성한 코드이다.

class ScreenActivity : AppCompatActivity(), ScreenContract.View {
    private val listView: ListView by lazy { findViewById(R.id.lv_screen) }

    private lateinit var adapter: ScreenAdapter
    private val screenPresenter: ScreenContract.Presenter by lazy { ScreenPresenter(this, DummyScreens()) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initAdapter()
        screenPresenter.loadScreens()
    }

    private fun initAdapter() {
        adapter = ScreenAdapter(emptyList()) { screenId ->
            ScreenDetailActivity.startActivity(this, screenId)
        }
        listView.adapter = adapter
    }

    override fun showScreens(screens: List<Screen>) {
        adapter.updateScreens(screens)
    }
}
class ScreenAdapter(
    private var item: List<Screen>,
    private val onScreenClickListener: OnScreenClickListener,
) : BaseAdapter() {
    override fun getCount(): Int = item.size
    override fun getItem(position: Int): Screen = item[position]
    override fun getItemId(position: Int): Long = position.toLong()

    override fun getView(
        position: Int,
        convertView: View?,
        parent: ViewGroup,
    ): View {
        val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.holder_screen, parent, false)
        initBinding(view, position)
        initClickListener(view, position)

        return view
    }

    private fun initBinding(view: View,position: Int) {
        /* findViewById */
        /* view 에 값 넣기 (setText or setImageResource .. ) */
    }

    private fun initClickListener(view: View,position: Int) {
        val reserveButton = view.findViewById<Button>(R.id.btn_reserve_now)
        reserveButton.setOnClickListener {
            onScreenClickListener.onClick(item[position].id)
        }
    }

    fun updateScreens(screens: List<Screen>) {
        item = screens
        notifyDataSetChanged()
    }
}

위는 안드로이드의 BaseAdapter를 상속받아 ListView에 사용되는 코드이다.

이 때 `getCount`, `getItem(int position)`, `getItemId(int position)`, `getView(int, View, ViewGroup)` 를 오버라이드해야 한다.

  • `getCount()`: 리스트의 아이템 개수를 반환
  • `getItem(int position)`: 특정 위치의 Screen 객체를 반환
  • `getItemId(int position)`: 아이템의 ID를 반환 여기서는 위치 값이 곧 ID.
  • `getView(int, View, ViewGroup)`: 리스트의 각 아이템에 대해 뷰를 생성하거나 재사용.
    convertView가 null이면 새로운 뷰를 인플레이트한다. 이 메소드 내에서 `initBinding`과 `initClickListener`를 호출하여 뷰의 내용을 초기화하고 클릭 리스너를 설정.
  • `initBinding(View, int)`: 각 아이템에 해당하는 뷰를 데이터와 바인딩. 포스터 이미지, 영화 제목, 상영 날짜, 상영 시간 등을 설정.
  • `initClickListener(View, int)`: 예약 버튼에 클릭 리스너를 설정.
    버튼이 클릭되면 `OnScreenClickListener` 인터페이스를 통해 정의된 `onClick` 메소드를 호출
  • `updateScreens(List<Screen>)`: 새로운 데이터로 아이템 리스트를 업데이트하고 `notifyDataSetChanged()` 메소드를 호출하여 리스트 뷰를 새로고침한다.

getView 메서드

`getView`가 집중할 포인트다.

`convertView`는 재사용 가능한 이전 뷰를 의미한다. null이면 새로운 뷰를 인플레이트해야 한다.

여기서는 `LayoutInflater`로 `R.layout.holder_screen`레이아웃을 인플레이트하여 뷰를 생성한다.

 

`parent`는 뷰가 속할 부모의 컨텍스트를 제공한다. 여기서 `parent`는 `ViewGroup`이며 `ListView`가 된다.

`parent`는 실제로 새로 생성된 뷰를 직접 자식으로 추가하지 않는다!

`AdapterView`가 `getView`로부터 반환된 뷰를 자신의 자식으로 자동으로 추가하는 것을 막아야 한다.

실제로는 그 일은 라이브러리 깊은 곳에서 `AdapterView`가 자식을 추가해준다고 한다.

바로 아래 코드처럼 `inflate`를 호출할 때 `attachToRoot` 인자를 `false`로 설정해야 하는 것이 그 이유이다.

val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.holder_screen, parent, false)

 `inflate` 메서드는 이러한 방식으로 사용될 때, 뷰를 인플레이트하고 측정하며, 레이아웃을 준비하지만, 실제로 뷰 계층 구조에 추가하지는 않는다. 

리스트 뷰를 사용하는 것만으로도, 뷰를 재사용할 수 있다.

ViewHolder 패턴 적용

위 코드로도 뷰는 잘 재사용할 수 있다.

그런데 뷰를 재사용하더라도, `initBinding` 이라는 코드에서 뷰마다 `findViewById` 를 호출해야 한다.

private fun initBinding(view: View,position: Int,) {
    val poster = view.findViewById<ImageView>(R.id.iv_poster)
    val title = view.findViewById<TextView>(R.id.tv_title)
    val date = view.findViewById<TextView>(R.id.tv_screen_date)
    val runningTime = view.findViewById<TextView>(R.id.tv_screen_running_time)
    
    // 값 할당해주기 ..
}

`findViewById` 는 꽤 리소스가 많이 드는 작업이다. 이를 개선하는 방법이 없을까?

그 방법이 바로 제목처럼 뷰홀더 패턴을 적용하는 것이다.

바로 뷰홀더 패턴을 적용한 코드를 봅시다

class ScreenAdapter(
    private var item: List<ScreenPreviewUI>,
    private val onScreenClickListener: OnScreenClickListener,
) : BaseAdapter() {
     /* getCount, getItem, getItemId method  ... */
    override fun getView(
        position: Int,
        convertView: View?,
        parent: ViewGroup,
    ): View {
        val view: View
        val viewHolder: ScreenViewHolder

        if (convertView == null) {
            view = LayoutInflater.from(parent.context).inflate(R.layout.holder_screen, parent, false)
            viewHolder = ScreenViewHolder(view, onScreenClickListener)
            view.tag = viewHolder
        } else {
            view = convertView
            viewHolder = view.tag as ScreenViewHolder
        }
        
        viewHolder.bind(item[position])
        return view
    }

    fun updateScreens(screens: List<ScreenPreviewUI>) {
        item = screens
        notifyDataSetChanged()
    }
}
class ScreenViewHolder(
    view: View,
    private val onScreenClickListener: OnScreenClickListener,
) {
    /* findViewById... */

    fun bind(screen: ScreenPreviewUI) {
        initView(screen)
        initClickListener(screen)
    }

    private fun initClickListener(screen: ScreenPreviewUI) {
        reserveButton.setOnClickListener {
            onScreenClickListener.onClick(screen.id)
        }
    }

    private fun initView(screen: ScreenPreviewUI) { /* 뷰에 값 할당*/ }
}

 위 코드를 간단히 설명하면, 

  • 뷰 재사용 로직: `convertView`가 `null`인지 검사하여, `null`일 경우 새로운 뷰를 인플레이트한다.
    `ScreenViewHolder` 객체를 생성하여 `view.tag`에 저장한다.
  • 뷰홀더 사용: `convertView`가 `null`이 아닌 경우, 즉 재사용할 뷰가 있는 경우, `view.tag`에서 `ScreenViewHolder`를 추출하여 사용한다.
  • 바인딩: `ScreenViewHolder`의 `bind` 메소드를 호출하여 `ScreenPreviewUI` 객체를 뷰에 바인딩한다.

즉, 뷰홀더 패턴을 사용함으로써 각 뷰의 객체를 ViewHolder 에 보관하고,

한 번 생성하여 저장했던 뷰는 다시 `findViewById()`를 통해 불러오지 않아도 된다!  결국 퍼포먼스가 더 개선되는 것이다.

이렇게 생성한 뷰홀더를 재사용함으로써 일종의 뷰홀더 캐싱을 적용할 수 있다.

 

그런데 위 코드에서도 더 개선할 수 있다고 한다.

뷰홀더를 캐싱하고 있는 객체

그렇다면 어떻게 캐싱해야 할까?

이 부분에서 뷰홀더가 뷰를 감싸는 형태로 나름대로 리팩토링을 했지만, 근본적인 문제는 해결되지 않았다.

그래서 힌트를 부탁드렸고 아래처럼 답변을 받았다.

이 피드백을 보고, `ViewHolder`들을 담고 있는 하나의 객체를 만들면 괜찮을 것 같다는 생각이 들었다!

어댑터가 뷰홀더 컨테이너를 갖고, 뷰홀더가 뷰를 갖도록 하는 것으로 변경했다.

class ScreenAdapter(
    private var item: List<ScreenPreviewUI>,
    private val viewHolderContainer: ViewHolderContainer<ScreenPreviewUI>,
) : BaseAdapter() {
     /* getCount, getItem, getItemId method  ... */

    override fun getView(
        position: Int,
        convertView: View?,
        parent: ViewGroup,
    ): View {
        val viewHolder = viewHolderContainer.viewHolder(convertView, parent)
        viewHolder.bind(item[position])
        return viewHolder.view()
    }

    fun updateScreens(screens: List<ScreenPreviewUI>) {
        item = screens
        notifyDataSetChanged()
    }
}
  • `getView()`: 리스트의 각 아이템에 대한 뷰를 생성하거나 재사용하기 위해 `ViewHolderContainer`를 통해 `ViewHolder`를 가져온다. 가져온 `ViewHolder`에 데이터를 바인딩하고, 해당 `ViewHolder`의 뷰를 리턴한다.
class ScreenViewHolderCaches(private val onScreenClickListener: OnItemClickListener<Int>) :
    ViewHolderContainer<ScreenPreviewUI>() {
    // cache viewHolders
    override val viewHolders = mutableListOf<ViewHolder<ScreenPreviewUI>>()

    override fun viewHolder(
        convertView: View?,
        parent: ViewGroup,
    ): ViewHolder<ScreenPreviewUI> =
        viewHolders.find { it.view() == convertView }
            ?: createViewHolder(parent, onScreenClickListener)

    private fun createViewHolder(
        parent: ViewGroup,
        onScreenClickListener: OnItemClickListener<Int>,
    ): ScreenViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.holder_screen, parent, false)
        val viewHolder = ScreenViewHolder(view, onScreenClickListener)
        viewHolders.add(viewHolder)
        return viewHolder
    }
}
  • `ViewHolderContainer`역할: `ViewHolder` 객체를 캐싱.
  • `viewHolder()`: 재사용 가능한 `ViewHolder`를 찾거나, 없을 경우 새로 생성한다.
    캐싱은 `mutableListOf<ViewHolder<ScreenPreviewUI>>`를 사용하여 구현된다.
  • `createViewHolder()`: 새로운 `ViewHolder`를 생성하고, 캐시 리스트에 추가한다.
  • 뷰홀더 관리: `ScreenViewHolderContainer`는 `ViewHolderContainer` 인터페이스를 구현하여 뷰홀더를 관리한다.
class ScreenViewHolder(
    private val view: View,
    private val onScreenClickListener: OnItemClickListener<Int>,
) : ViewHolder<ScreenPreviewUI> {
    /* findViewById... */

    override fun view(): View = view

    override fun bind(item: ScreenPreviewUI) {
        initView(item)
        initClickListener(item)
    }

    private fun initView(screen: ScreenPreviewUI) { /* 뷰에 값 할당*/}

    private fun initClickListener(screen: ScreenPreviewUI) {
        reserveButton.setOnClickListener {
            onScreenClickListener.onClick(screen.id)
        }
    }
}

이제 view 의 tag 속성에 뷰홀더를 저장하지 않는다!!

즉, 실수로 저장되어 있던 뷰홀더가 아닌 다른 값을 저장하지 않을 수 있다.

 

추가로 `ViewHolder` 와 `ViewHolderContainer` 인터페이스를 사용해서 구현했다.

interface ViewHolderContainer<T> {
    fun viewHolder(
        convertView: View?,
        parent: ViewGroup,
    ): ViewHolder<T>
}

interface ViewHolder<T> {
    fun view(): View

    fun bind(item: T)
}
  • `ViewHolderContainer`, `ViewHolder` 인터페이스와 다형성을 사용함으로써 다른 유형의 뷰를 쉽게 추가할 수 있으며, 어댑터와 뷰홀더의 코드를 재사용할 수 있다.

리사이클러뷰, 리스트뷰의 모습 비교

위 기능을 리사이클러뷰를 사용해서 구현해봅시다.

먼저 `RecyclerView.Adapter`와 `RecyclerView.ViewHolder`를 상속받는 클래스를 구현해야 한다.
`RecyclerView`에서는 각 뷰 타입에 대해 `onCreateViewHolder`에서 뷰홀더를 생성하고, `onBindViewHolder`에서 데이터를 해당 뷰홀더에 바인한다.

class ScreenRecyclerViewAdapter(
    private var items: List<ScreenPreviewUI>,
    private val onScreenClickListener: OnItemClickListener<Int>
) : RecyclerView.Adapter<ScreenRecyclerViewAdapter.ScreenViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScreenViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.holder_screen, parent, false)
        return ScreenViewHolder(view, onScreenClickListener)
    }

    override fun onBindViewHolder(holder: ScreenViewHolder, position: Int) {
        holder.bind(items[position])
    }

    override fun getItemCount(): Int = items.size

    fun updateScreens(screens: List<ScreenPreviewUI>) {
        items = screens
        notifyDataSetChanged()
    }

    class ScreenViewHolder(
        private val view: View,
        private val onScreenClickListener: OnItemClickListener<Int>
    ) : RecyclerView.ViewHolder(view) {
       /* findViewById... */

        fun bind(item: ScreenPreviewUI) {
            /* 뷰에 값 할당*/ 
            reserveButton.setOnClickListener {
                onScreenClickListener.onClick(item.id)
            }
        }
    }
}

 뷰홀더 캐싱

리사이클러뷰는 자체적으로 뷰홀더를 효율적으로 관리하고 캐싱한다.

반면, ListView의 구현에서는 개발자가 직접 뷰홀더 캐싱을 관리해야 한다.

리사이클러뷰는 뷰 타입이라는 기능을 통해 다양한 뷰 타입을 캐싱할 수도 있다. 

내부에서 Recycler 와 ViewCacheExtension 을 사용해서 뷰홀더와 뷰를 캐싱한다고 한다.

ViewHolder 의 다형성

위에서 리스트뷰에서 뷰홀더 캐싱을 구현한 것과 리사이클러뷰를 사용한 것의 코드를 비교해보면 비슷해보인다.

 

완전히 똑같은 모습은 아니지만 뷰홀더에 다형성을 적용한 것까지 모습이 꽤 비슷하다.

리사이클러뷰, 리스트뷰의 오해와 진실

일반적으로 `RecyclerView`의 등장 배경 중 하나로, 뷰홀더 패턴의 필요성이 강조된다.

`RecyclerView`는 이 패턴을 기본 구조로 채택하여 각 아이템의 뷰를 보다 효율적으로 재사용할 수 있게 설계되었다.

하지만 사실! `ListView` 역시 뷰홀더 패턴을 구현할 수 있다. 그 방법이 위에서 지금까지 해온 코드이다.

실제로 `getView()` 메소드에서 `convertView`를 통해 재사용할 뷰가 있으면 해당 뷰를 재활용하고, 없다면 새로 만드는 방식으로 메모리 사용을 최적화할 수 있었다.

 

즉, `ListView`에서 뷰홀더 패턴을 사용할 경우, 뷰와 뷰홀더 모두를 재사용할 수 있다.

이는 `RecyclerView`에서 보여주는 뷰 재사용의 개념과 매우 유사하다.

 

하지만 `RecyclerView`는 기본적으로 뷰홀더 패턴을 쓰도록 강제하기 때문에 더욱 사용하기 좋다.

또한 유연성과 기능성 면에서 `ListView`보다 많은 장점을 가지고 있다.

예를 들어, 아이템 추가, 삭제, 목록 변경 애니메이션 등을 내장하고 있으며, 수평 스크롤과 더 다양한 레이아웃 매니저를 지원한다.

 

이 글은 리사이클러뷰의 기능을 중점적으로 다루는 글이 아니기 때문에 관련해서 궁금하시다면 아래 글, 영상을 참고하세요~

https://medium.com/jaesung-dev/android-recyclerview-deep-dive-1-470a5ec74ada

 

(Android) RecyclerView Deep Dive #1

ListView vs RecyclerView

medium.com

https://www.youtube.com/watch?v=LqBlYJTfLP4

 

훈수, 지적 환영입니다~