나는 인생네컷 공유 보관 서비스 포포리의 Android 개발자로 있어서 인스타그램과 비슷한 뷰를 구현하는 개발하는 업무를 맡았다.
나는 아무 생각 없이
“아이템이 같은 형식으로 여러 개 들어오니 RecyclerView를 사용하고 화면 전체가 스크롤 되어야 하니 스크롤 뷰를 사용해야겠다! 그런데 RecyclerView가 스크롤 될 수 있으니 RecyclerView는 스크롤을 막으면 되겠지?”
라고 생각을 하고 NestedScrollView 속에 RecyclerView를 넣고 RecyclerView의 스크롤을 막아서 구현을 해버렸다.
해당 뷰를 열심히 구현하고 팀 리더에게 검사를 맡으러 갔는데 결과는….
뒤지게 혼났습니다…
결론부터 말을 하자면 내가 구현하려는 뷰에서는 RecyclerView의 본질을 살리기 힘들고 메모리 낭비가 너무 심해진다는 것이다.
안드로이드 공식문서에서는 RecyclerView의 아래와 같이 정의하고 있다..
RecyclerView는 개별 요소를 재활용합니다. 항목이 스크롤 되어 화면에서 벗어나더라도 RecyclerView는 뷰를 제거하지 않습니다. 대신 RecyclerView는 화면에서 스크롤된 새 항목의 뷰를 재사용합니다. 이렇게 뷰를 재사용하면 앱의 응답성을 개선하고 전력 소모를 줄이기 때문에 성능이 개선됩니다
이 그림이 리사이클러뷰의 이해를 도울 수 있는데
ListView는 사용자가 스크롤 할 때마다 사라지는 뷰는 삭제되고 아래서 새로 등장하는 뷰는 생성되는 것을 반복하여 메모리 사용이 과도해지는 반면
RecyclerView는 화면에 보이는 정도의 View만 생성하고 스크롤 할 때마다 삭제하지 않고 객체를 이동시켜 재사용을 하여 메모리 사용을 줄일 수 있다.
요약하면 RecyclerView는 일반적으로 사용자가 스크롤할 때 필요한 아이템만 생성하고, 화면 밖으로 이동한 아이템은 재사용하여 메모리 사용량을 최적화를 하는 것이 본질이다.
안드로이드 공식문서에서는 NestedSrolledView를 ScrollView와 거의 비슷하고 부모와 자식 모두 스크롤을 지원한다고 말하고 있다.
Nested scrolling이 기본값이 enable이기 때문에 이걸 비활성화시키면 단순하게 문제가 해결될 줄 알았다.
하지만 NestedScrollView는 스크롤이 가능한 모든 자식 View의 크기를 측정하려는 특징을 가지고 있다.
이 특징 때문에 일련의 과정을 진행하게 되는데 아래와 같다.
- NestedScrollView가 스크롤이 가능한 모든 자식 View의 크기를 측정하려고 시도한다
- 이 때문에 NestedScrollView 속의 RecyclerView의 레이아웃 매니저가 모든 아이템을 미리 생성한다.
- RecyclerView가 원래 가지고 있던 뷰 홀더의 재사용(Recycling)이라는 특성을 잃어버린다.
위에서 발생하는 문제점 때문에 내가 구현하려는 뷰 처럼 많은 아이템을 가진 RecyclerView를 사용할 때 메모리 문제가 발생할 수 있었던 것이다.
기본적으로 RecyclerView는 단일 뷰 타입만 처리한다. 즉, 목록의 모든 아이템이 동일한 레이아웃을 가지는 것이다. 하지만 동일한 목록 내에서 서로 다른 레이아웃을 가진 아이템들을 표현해야 하는 경우에 multi-view type 기능을 사용할 수 있다.
즉, 뷰 타입을 정의하고 뷰 타입에 따라 다른 뷰 홀더를 생성하여 하나의 RecyclerView안에 서로 다른 뷰를 존재 할 수 있게 만드는 것이다.
나는 기존에 NestedSrolledView로 만든 뷰를 아래와 같이 설계하고 리펙토링을 진행하였다.
나는 뷰 타입을 Profile, Photo, Empty를 정의하였고 사용자가 사진 있으면 Photo, 없으면은 Empty가 보여지도록 하였다.
multi-view type 제작 과정
- 보여질 multi-view type을 정의한다.
companion object { const val VIEW_TYPE_PROFILE = 0 const val VIEW_TYPE_PHOTO = 1 const val VIEW_TYPE_EMPTY = 2 }
각 뷰 타입에 해당하는 ViewHolder Class를 정의한다.
getItemViewType() 메소드를 만들어 각 아이템의 타입에 따라 해당하는 뷰 타입을 반환한다.
override fun getItemViewType(position: Int): Int { return when (getItem(position)) { is MyPageDisplayItem.Profile -> VIEW_TYPE_PROFILE is MyPageDisplayItem.Photo -> VIEW_TYPE_PHOTO is MyPageDisplayItem.Empty -> VIEW_TYPE_EMPTY } }
- onCreateViewHolder() 메소드에서 각 뷰 타입에 해당하는 ViewHolder(2번에서 제작한)를 생성하고 반환한다.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val context = parent.context return when (viewType) { VIEW_TYPE_PROFILE -> ProfileViewHolder(...) VIEW_TYPE_PHOTO -> PhotoViewHolder(...) VIEW_TYPE_EMPTY -> EmptyViewHolder(...) else -> throw IllegalArgumentException("Invalid view type") } }
- onBindViewHolder() 메소드에서 각 뷰 타입에 해당하는 ViewHolder에 데이터를 바인딩한다.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (val item = getItem(position)) { is MyPageDisplayItem.Profile -> (holder as ProfileViewHolder).bind(item) is MyPageDisplayItem.Photo -> (holder as PhotoViewHolder).bind(item) else -> {} } }
성공적으로 리펙토링을 진행한 화면의 모습 😆
짧은 시간 스프린트를 하며 서비스 제작에 들어가면 기능 구현에만 매몰되기 쉽다. 그럴수록 미리 설계를 진행해보며 내가 사용하려는 기술들이 서로 영향을 주지는 않는지, 메모리 관리 측면에서 효율적인지 판단하는 과정이 필요하다는 것을 느끼는 순간이었다.
이 글을 읽는 개발자들도 나와 같이 두 번 일을 해야 하는 순간이 오지를 않기를 바란다.
이해하기 너무 좋은 글이였습니다. 리사이클러뷰 마스터 화이팅!