ListView 는 ViewHolder 재사용을 못하는 거 아니었나요??? (아님) feat 우테코
우테코에서는 "과속, 과식하지 말것" 이라는 권장 목표가 있다.
즉, 수업에서 다루는 것부터 공부하고 미션에 적용해보고 나서, 그 다음 기술을 사용하는 것이다.
이번 미션은 '영화 티켓 예매'이다.
오른쪽과 같은 화면을 `RecyelrView`가 아니라 `ListView`로 구현해야 한다.
나는 리스트뷰를 사용해본 적이 없다.
그렇다고, 이 미션에서 리사이클러뷰를 사용할 수는 없다.
그래서 진행 전부터 난관이 예상되었다.
이번 글에서는
- 리스트뷰를 통해 화면을 구성하고
- 뷰홀더 패턴을 적용, 뷰홀더를 뷰의 tag 에 캐싱하도록 리팩토링.
- 뷰의 tag 에 캐싱하는 게 아닌, 다른 객체에 캐싱하도록 리팩토링.
참고로
- MVVM 이 아닌, MVP 패턴을 적용했고,
- 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
https://www.youtube.com/watch?v=LqBlYJTfLP4
훈수, 지적 환영입니다~