💡 Intro
레벨2 첫 번째 미션으로 영화 티켓 예매 미션을 진행했다.
안드로이드에서는 보통 위와 같은 뷰를 구성하기 위해 RecyclerView를 사용한다. 그런데 미션 요구사항에 ListView를 사용하라는 프로그래밍 요구 사항이 있었고, 이를 만족하기 위해 ListView를 사용했다. (일단 ListView에 영화 목록과 광고가 함께 들어가있는 것은 배제하고, 단순하게 하나의 뷰로 ListView를 사용한다고 가정하자.)
이 요구사항을 통해 ListView를 직접 사용해보면서 어떠한 이유로 ViewHolder 패턴이 나오게 되었고, RecyclerView를 사용하게 되었는지에 대해 공부하라는 취지로 이해를 했기 때문에 "왜 ListView를 써야해"와 같은 질문은 넣어두고 요구사항을 만족하려고 노력했다.
그리고 이 글에서는 내가 ListView를 사용한 과정과 느낀 부분들에 대해 기록하려고 한다.
❗️ ListView
ListView | Android Developers
developer.android.com
ListView는 한 화면에 표시하기 힘든 많은 수의 데이터를 스크롤 가능한 리스트로 표시해주는 위젯이며 View들을 리스트처럼 보여주는 컨테이너(뷰그룹)이다. ListView는 ViewHolder의 사용이 선택 사항(권장)인데, 이 이유와 관련된 동작에 대해서도 다룰 예정이다.
ListView를 사용하기 위해서 필수적으로 존재해야 하는 것이 Adapter다. 여기서 Adapter는 실제로 ListView에 들어가는 ItemView들을 생성하고 알맞는 데이터를 바인딩해주는 작업을 담당한다.
모든 아이템에 대해 View를 inflate하는 것은 무거운 작업이기 때문에 View를 재사용하는 방법으로 ListView의 성능을 개선한다. 여기서 View를 재사용한다는 측면에서 성능 개선 방법은 2가지다.
1. convertView의 사용
2. ViewHolder 사용
각각에 대해 알아보도록 하자.
ConvertView
ListView는 convertView의 배열을 이용하여 아이템을 관리한다.
Adapter는 스크롤을 통해 ListView에서 화면 밖의 새 아이템을 가져올 때 getView() 메서드를 호출한다. 그리고 이 메서드가 호출될 때 처음에 화면에 보이는 아이템 수만큼 convertView를 생성하고, 이후 호출에서는 이전에 생성했던 convertView를 재사용하는 방식이다.
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
...
}
getView() 함수의 파라미터로 넘어온 convertView가 null이라면 view를 다시 inflate해준 뒤 데이터를 세팅해주고, null이 아니라면 inflate 하지않고 데이터만 다시 세팅해준다.
미션에 적용한 Adapter의 getView()함수를 보면 다음과 같다.
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = convertView ?: View.inflate(
parent?.context,
R.layout.include_movie_list_item,
null,
)
// view를 findViewById를 통해 찾는 과정
val posterView = view.findViewById<ImageView>(R.id.movie_poster)
val titleView = view.findViewById<TextView>(R.id.movie_title)
val releaseDateView = view.findViewById<TextView>(R.id.movie_release_date)
val runningTimeView = view.findViewById<TextView>(R.id.movie_running_time)
val reservationButton = view.findViewById<Button>(R.id.movie_reservation_button)
// view에 데이터를 binding하는 과정
with(Cinema[position]) {
posterView.setImageResource(poster)
titleView.text = title
releaseDateView.text = DateUtil(context).getDateRange(startDate, endDate)
runningTimeView.text = context.getString(R.string.movie_running_time).format(runningTime)
reservationButton.setOnClickListener {
val intent = Intent(context, MovieReservationActivity::class.java)
intent.putExtra(MovieReservationActivity.KEY_MOVIE_SCHEDULE, this)
startActivity(context, intent, null)
}
}
return view
}
convertView를 사용하면 View를 재사용할 수 있다. 즉, 스크롤이 내려갈 때마다 매번 새로운 View를 inflate하는 것이 아니라 화면에서 없어진 뷰를 재사용하는 것이고 이 때문에 모든 View에 대해 inflate하는 비용을 줄일 수 있다.
하지만 데이터를 View에 연결해주기 위해서는 findViewById를 매번 호출해줘야 하는 문제점이 있다.
그럼 이것은 어떻게 해결할 수 있을까❓
ViewHolder
위 질문에 대한 대답은 바로 ViewHolder다. 일단 findViewById()의 호출빈도를 줄여야 하는 이유에 대해 알아보면 다음과 같다.
findViewById()는 ViewGroup 하위에 있는 모든 View들을 전부 한 번씩 순회하면서 id값을 비교하는 과정을 거치기 때문에 자원이 많이 든다. id가 일치하는 View를 찾기 위해 ViewGroup 하위에 있는 모든 View들을 브루트포스 방식으로 순회하기 때문에 findViewById() 함수의 호출 자체가 자원이 많이 드는 일이다.
미션에 적용한 코드를 보면 다음과 같다.
class ViewHolder(
val posterView: ImageView,
val titleView: TextView,
val releaseDateView: TextView,
val runningTimeView: TextView,
val reservationButton: Button,
)
위처럼 ViewHolder 클래스를 정의해준다. 이는 Adpater 내부에 nested class로 구현해도 좋고, 외부에 따로 구현해도 상관없다.
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val viewHolder: ViewHolder
// convertView가 있다면 태그에 저장된 viewHolder를 가져옴
val view = if (convertView != null) {
convertView.also { viewHolder = it.tag as ViewHolder }
} else {
// convertView가 없다면 inflate
val v = View.inflate(
parent?.context,
R.layout.include_movie_list_item,
null,
)
// viewHolder를 새로 생성하고 tag에 연결
viewHolder = ViewHolder(
v.findViewById(R.id.movie_poster),
v.findViewById(R.id.movie_title),
v.findViewById(R.id.movie_release_date),
v.findViewById(R.id.movie_running_time),
v.findViewById(R.id.movie_reservation_button),
)
v.tag = viewHolder
v
}
// view에 데이터를 binding하는 과정
with(Cinema[position]) {
viewHolder.posterView.setImageResource(poster)
viewHolder.titleView.text = title
viewHolder.releaseDateView.text = DateUtil(view.context).getDateRange(startDate, endDate)
viewHolder.runningTimeView.text = view.context.getString(R.string.movie_running_time).format(runningTime)
viewHolder.reservationButton.setOnClickListener {
val intent = Intent(view.context, MovieReservationActivity::class.java)
intent.putExtra(MovieReservationActivity.KEY_MOVIE_SCHEDULE, this)
startActivity(view.context, intent, null)
}
}
그리고 Adapter의 getView()함수는 위와 같다.
convertView가 없는 것은 재활용할 뷰가 없다는 말이고, 새롭게 뷰와 뷰홀더를 생성해야 한다는 말이다. 그래서 convertView가 null인 경우 View를 inflate해주고, ViewHolder도 새로 만들어 tag에 넘겨준다.
convertView가 null이 아닌 경우는 재활용할 뷰가 있다는 말이고, 뷰의 tag에서 뷰홀더를 꺼내와 값만 다시 세팅해주면 뷰를 inflate 하거나, findViewById() 함수의 호출을 할 필요가 없어진다.
사실 View에 데이터를 binding하는 과정의 코드는 그렇게 중요하지 않고, tag에서 받아온 ViewHolder 인스턴스 혹은 새로 생성해준 ViewHolder 인스턴스가 가지고 있는 프로퍼티에 값을 세팅해준다 정도로 이해하면 된다.
여기서 중요한 것은 ViewHolder를 사용함으로써 어떻게 findViewById() 의 호출을 줄였는가 이다.
여담
위에서 살펴본 코드처럼 View의 tag로 ViewHolder를 넘기는 방법으로 구현했는데, 다음과 같은 리뷰가 달렸다.
tag에는 평생 viewHolder가 들어가지 않으니 불필요합니다.
View가 ViewHolder를 가지고 있는 것은 매우 어색합니다. tag에 viewHolder를 할당하지 않고 구현해보세요.
내 리뷰는 아니었지만 고려해볼만한 사항같아서 적용했다.
class MovieListAdapter(
private val movieModelUi: List<MovieModelUi>,
) : BaseAdapter() {
private val movieViewHolder: MutableMap<View, MovieViewHolder> = mutableMapOf()
...
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = convertView ?: initView(position, parent)
movieViewHolder.getOrPut(view) { MovieViewHolder(view) }
.bind(movieModelUi[position] as MovieModelUi.MovieScheduleUi, onReservationClickListener)
return view
}
private fun initView(position: Int, parent: ViewGroup?): View {
return View.inflate(parent?.context, R.layout.item_movie_list, null)
}
}
map을 만들어서 View와 ViewHolder를 1:1로 관리하도록 수정했다.
ItemViewType
가장 처음에 봤던 예시 사진을 보면 뷰가 영화 정보를 보여주는 뷰, 광고를 보여주는 뷰로 2가지인 것을 볼 수 있다. 이렇게 하나의 ListView에서 여러 개의 뷰를 보여주려면 어떻게 할 수 있을까? ItemViewType을 활용할 수 있다.
ItemViewType를 사용하기 위해서 Adapter에서 getItemViewType(), getViewTypeCount() 두 메서드를 추가로 구현해주면 된다. 두 함수의 역할은 다음과 같다.
getItemViewType() : position 위치에 있는 뷰의 타입을 반환한다.
getViewTypeCount() : ListView가 가지고 있는 ViewType의 수를 반환한다.
미션에 적용한 코드는 다음과 같다.
class MovieListAdapter(
private val movieModelUi: List<MovieModelUi>,
private val onReservationClickListener: (MovieModelUi.MovieScheduleUi) -> Unit,
) : BaseAdapter() {
private val movieViewHolder: MutableMap<View, MovieViewHolder> = mutableMapOf()
private val adViewHolder: MutableMap<View, AdViewHolder> = mutableMapOf()
override fun getCount(): Int {
return movieModelUi.size
}
override fun getItem(position: Int): MovieModelUi {
return movieModelUi[position]
}
override fun getItemViewType(position: Int): Int {
return when (movieModelUi[position]) {
is MovieModelUi.MovieScheduleUi -> ITEM_VIEW_TYPE_MOVIE.value
is MovieModelUi.AdUi -> ITEM_VIEW_TYPE_AD.value
}
}
override fun getViewTypeCount(): Int {
return ITEM_VIEW_TYPE_MAX
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = convertView ?: initView(position, parent)
when (ItemViewType.of(getItemViewType(position))) {
// ViewType이 영화인경우
ITEM_VIEW_TYPE_MOVIE -> movieViewHolder.getOrPut(view) { MovieViewHolder(view) }
.bind(movieModelUi[position] as MovieModelUi.MovieScheduleUi, onReservationClickListener)
// ViewType이 광고인경우
ITEM_VIEW_TYPE_AD -> adViewHolder.getOrPut(view) { AdViewHolder(view) }
.bind(movieModelUi[position] as MovieModelUi.AdUi)
}
return view
}
private fun initView(position: Int, parent: ViewGroup?): View {
return when (ItemViewType.of(getItemViewType(position))) {
ITEM_VIEW_TYPE_MOVIE -> View.inflate(parent?.context, R.layout.item_movie_list, null)
ITEM_VIEW_TYPE_AD -> View.inflate(parent?.context, R.layout.item_ad_list, null)
}
}
}
영화 뷰홀더, 광고 뷰홀더를 따로 관리해야 하기 때문에 앞에서 진행했던 방식인 View와 ViewHolder를 1:1로 관리하는 방법을 사용하기 위해 2개의 map을 만들었다.
그리고 getView() 함수에서 현재 position에 있는 아이템의 ViewType에 따라 다른 뷰홀더를 생성하도록 분기처리 해주었다. 이로써 2가지의 뷰를 하나의 ListView에서 보여줄 수 있게 되었다.
정리
ListView는 성능 개선을 위해 ConvertView와 ViewHolder를 사용한다. convertView는 View의 inflate 횟수를 줄여줘 성능을 개선하고, ViewHolder는 findViewById() 함수의 호출 빈도를 줄여 성능을 개선했다.
ListView에서 ViewType을 통해 여러 개의 뷰를 보여줄 수 있다.
그리고 추가로 고민해볼 리뷰를 적으며 ListView 적용기를 마친다.
View와 ViewHolder를 1대 1 매핑해서 관리하지 않고, ViewHolder만을 관리해볼 수 있을까요?
다른 어댑터들이 같은 ViewHolder를 공유해볼 수 있을까요?
위 내용들을 RecyclerView에서 어떻게 해결했을지 잘 참고해보세요!