소개
이 글의 RecyclerView의 개념과 동작 방식에 관심있는 독자를 대상으로 작성되었습니다.
목차
1. RecyclerView란 무엇인가?
2. RecyclerView의 내부 구조
3. RecyclerView의 성능 최적화
4. 마치며
5. 출처
안드로이드의 ViewSystem을 통해서 UI를 그릴 때, 데이터 세트를 리스트 형태로 표현하기 위해서 사용되는 방법 중 한 가지 수단입니다.
아래는 데이터 세트를 리스트 형태로 표현하는 예시 사진입니다.
위의 예시사진에서 볼 수 있듯이, 데이터 세트를 리스트 형태로 UI를 그릴 때 RecyclerView를 사용할 수 있습니다.
그럼 왜 RecyclerView를 사용해서 표현해야할까요?
RecyclerView를 사용하면 아래와 같은 장점이 있습니다.
데이터의 양이 많아도 효율적으로 표시
뷰 재활용을 통해 메모리와 성능을 최적화
커스텀 레이아웃이나 애니메이션 등 다양한 확장성
종합해보면 코드의 반복을 줄이고, 성능의 최적화, 다양한 확장성을 제공해주기 때문에 우리는 RecyclerView를 사용합니다.
RecyclerView를 사용하면 여러가지 장점을 누릴 수 있는데, 그렇다면 어떤 구조때문에 이러한 장점이 생겨났는지 알아보겠습니다.
RecyclerView는 다양한 컴포넌트들로 이루어져 상호작용하여 동작합니다. 여러가지 컴포넌트들이 있지만 해당 글에서는 LayoutManager, Adapter, ViewHolder를 중심으로 설명합니다.
우선 처음으로 LayoutManager입니다.
아래 사진은 LayoutManager와 RecyclerView간의 간략한 구조도입니다. (다른 컴포넌트는 제외)
LayoutManager는 RecyclerView와 상호작용하여 뷰의 크기를 측정하고 배치하는 역할을 담당합니다.
MeasureSpec API를 통해서 아이템 뷰의 크기를 측정하고, onLayoutChildren()
을 호출하여 어떻게 배치될지를 결정합니다.
또한 RecyclerView는 뷰를 재활용해서 성능을 최적화할 수 있는데, 이 때 재활용 시점을 결정하는 것이 LayoutManager입니다.
이러한 특징을 가진 LayoutManager를 변경하여 RecyclerView의 레이아웃을 다양한 방식으로 나타낼 수 있습니다.
RecyclerView에서 사용하는 LayoutManager는 크게 3가지로 분류됩니다.
LayoutManager의 종류에 따라 아래와 같이 배치됩니다.
Linear Layout Manager
Linear Layout Manager를 사용하면 데이터 세트의 아이템들을 수직이나 수평으로 순차적으로 배치할 수 있습니다.
Layout의 orientation은 생성자를 사용하여 설정하는 방식과 xml을 통해 설정하는 방식이 있습니다.
// 생성자 이용
val layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
recyclerView.layoutManager = layoutManager
// xml 이용
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="vertical" (default 값으로 생략가능)
만약 두 가지 방법으로 설정되어 있다면 어떤 것을 따를까요??
정답은 코드에서 작성한 방식을 따릅니다. 런타임 시점의 설정이 더 최신이 되기 때문입니다.
Grid Layout Manager
Grid Layout Manager는 아이템들을 그리드 형태로 배치할 수 있습니다.
위의 그림1의 무신사 애플리케이션의 UI가 그리드 형태입니다.
Grid Layout Manager도 마찬가지로 두 가지 방법으로 설정할 수 있습니다.
// 생성자 이용
// context와 그리드의 열 개수를 설정
val layoutManager = GridLayoutManager(context, 2) // spanCount = 2
recyclerView.layoutManager = layoutManager
// xml 이용
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/item_view" />
Staggered Grid Layout Manager
Staggered Grid Layout Manager는 Grid Layout과 유사하지만, 아이템들을 불규칙한 크기로 배치할 수 있습니다.
// 생성자 이용
val layoutManager = StaggeredGridLayoutManager(2,StaggeredGridLayoutManager.VERTICAL) // spanCount와 orientation을 지정
recyclerView.layoutManager = layoutManager
RecyclerView에서 ViewHolder는 데이터 세트의 아이템 뷰를 관리하고 재사용을 하기 위해 사용되는 핵심 컴포넌트입니다. 이를 통해 메모리 사용을 줄이고 성능을 최적화 할 수 있습니다. 그렇다면 ViewHolder가 무엇인지, 그리고 왜 RecyclerView의 핵심 컴포넌트인지 알아보겠습니다.
ViewHolder란 화면에 표시하기위해 뷰와 데이터의 위치를 관리하기 위한 객체입니다.
RecyclerView에서 스크롤 했을 때, 화면 밖을 벗어난 View를 재활용하기 위해서 해당 View를 기억하고 있어야 합니다. 이때 기억하기 위해서 ViewHolder 객체를 사용합니다.
RecyclerView는 ViewHolder 패턴을 강제화함으로써 RecyclerView에서 재활용 할 수 있도록 되어있습니다.
ViewHolder 패턴이란?
ViewHolder 객체를 만들어서 아이템 뷰의 하위 뷰에 대한 참조를 저장합니다.
이때, 뷰 객체를 ViewHolder에 보관함으로써 findViewById()와 같이 반복적으로 호출되는 메서드를 줄여 성능적으로 이점을 볼 수 있습니다.
아래 사진은 LayoutManager와 RecyclerPool은 생략된 간략한 구조도입니다.
ViewHolder는 Adapter와 상호작용하여 RecyclerView의 뷰를 관리합니다.
아래는 ViewHolder의 생성 및 재활용 과정을 간단히 나타낸 것입니다.
onCreateViewHolder(), onBindViewHolder()의 구현은 Adapter에서 설명하도록 하겠습니다.
ViewHolder 생성
View Caching
재활용 및 데이터 바인딩
RecyclerView에서 내부적으로 위와 같은 방법을 사용하여 중복된 View 생성과 탐색 비용을 줄입니다.
RecyclerView는 데이터 세트의 아이템들을 직접적으로 알지 못합니다. 그렇다면 어떻게 데이터에 접근하고 화면에 표시할까요?
바로 Adapter를 이용하여 데이터를 가져오고 뷰에 연결합니다.
즉 Adapter가 View와 Data를 Bind하는 역할을 합니다.
RecyclerView의 Adapter는 다음과 같은 기능을 수행합니다.
그럼 각 기능을 어떻게 수행하는지 하나씩 알아보도록 하겠습니다.
RecyclerView Adapter에서 ViewHolder 생성은 두 가지 주요 메서드를 통해 이루어집니다
onCreateViewHolder는 화면에 표시할 아이템 뷰를 생성하는 역할을 합니다.
이 메서드는 LayoutInflater를 사용하여 XML 레이아웃 파일을 실제 뷰 객체로 변환합니다.
뷰 객체를 기반으로 ViewHolder 객체를 만들고 반환합니다. (ViewHolder는 밑에서 설명하도록 하겠습니다.)
이 과정은 주로 새로운 아이템이 화면에 나타날 때 호출되며, 재사용 가능한 뷰가 부족한 경우에만 실행됩니다. 이를 통해 불필요한 뷰 생성을 줄이고, 성능을 최적화할 수 있습니다.
LayoutInflater는 XML 리소스 파일을 메모리로 로딩하고 View 객체로 만드는 과정
ViewHolder 객체가 생성이 되면, onBindViewHolder 메서드를 호출합니다.
ViewHolder와 데이터를 연결하는 과정으로 position의 데이터를 가져와서 ViewHolder에 연결합니다.
위 과정을 예제 코드를 통해 보여드리겠습니다.
우선 ViewHolder를 정의합니다.
class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView = view.findViewById(R.id.textView)
}
ViewHolder를 정의했다면, Adapter를 만들어 onCreateViewHolder와 onBindViewHolder를 오버라이드 합니다.
class MyAdapter(private val items: List<String>) : RecyclerView.Adapter<MyAdapter.MyViewHolder>(){
// 아이템 뷰 생성 생성하고 반환
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_layout, parent, false)
return MyViewHolder(view)
}
// 데이터와 ViewHolder 연결
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.textView.text = items[position]
}
// 데이터 아이템 수 반환
override fun getItemCount(): Int = items.size
// 이외에도 getItemViewType과 같은 다양한 메서드들을 오버라이드 할 수 있습니다.
}
RecyclerView에서 사용되는 데이터 세트가 변경이 되면 이를 반영하기 위해서 RecyclerView에게 알려야 합니다.
다음은 데이터가 변경되었음을 알리는 다양한 메서드들입니다.
여러가지 메서드가 있지만 notifyDataSetChanged()에 대해서만 알아보고 넘어가도록 하겠습니다.
notifyDataSetChanged()는 데이터 세트에 변경사항만 반영하는 것이 아닌 전체 데이터가 변경되었다고 알립니다.
아래와 같은 users 데이터 세트에 아이템이 하나 추가된 경우에도 전체 데이터를 다시 그리게 되는 문제점이 발생합니다.
// 초기 데이터
val users = mutableListOf(
User(1, "Kkosang", 20),
User(2, "Hodu", 19),
User(3, "Crong", 22))
val adapter = MyAdapter(data)
// 데이터가 추가 되었음
users.add(User(4, "James", 23))
adapter.notifyDataSetChanged()
뒤에서 변경된 데이터만 찾아서 업데이트 할 수 있는 방법을 소개하겠습니다.
RecyclerView는 여러 종류의 아이템을 효율적으로 표시하기 위해, 하나의 Adapter에서 다른 타입의 View를 처리할 수 있습니다.
아래는 멀티 뷰타입을 이용한 RecyclerView 사진입니다.
멀티 뷰타입을 구현하기 위해서는 getItemViewType() 메서드를 오버라이드하고 각 타입에 맞는 ViewHolder를 반환하며 데이터를 바인딩해야 합니다.
override fun getItemViewType(position: Int): Int {
return if (position % 3 == 0) AD_TYPE else MOVIE_TYPE
}
companion object {
const val MOVIE_TYPE = 0
const val AD_TYPE = 1
}
ViewType을 정의했다면,
onCreateViewHolder()에서 타입에 맞는 ViewHolder를 반환하고
onBindViewHolder()에서 타입에 맞는 데이터와 바인딩해줍니다.
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): ViewHolder {
return if (viewType == MOVIE_TYPE) {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.movie_item, parent, false)
MovieViewHolder(view)
} else {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.ad_item, parent, false)
AdViewHolder(view)
}
}
override fun onBindViewHolder(
holder: ViewHolder,
position: Int,
) {
when (holder) {
is MovieViewHolder -> // 영화 데이터와 바인딩
is AdViewHolder -> // 광고 데이터와 바인딩
}
}
이처럼 하나의 Adapter에서 여러 종류의 View를 나타낼 수 있습니다.
데이터의 변경을 RecyclerView에게 알리기 위해서는 notifyDataSetChanged()
를 사용할 수 있습니다. 하지만 이 메서드는 전체 데이터 세트를 갱신하기 때문에 UI가 깜빡거리거나 성능 저하의 문제가 발생할 수 있습니다.
이러한 문제점을 ListAdapter와 DiffUtil을 사용하여 해결할 수 있습니다.
RecyclerView에서 데이터를 갱신할 때, oldList(기존 데이터)와 newList(변경된 데이터)를 비교하여 변경된 항목들을 찾아내고 변경 사항만 업데이트할 수 있도록 도와주는 유틸리티 클래스입니다.
두 데이터 리스트(oldList,newList)를 비교하여 차이점을 계산합니다.
이 과정에서 어떤 아이템이 추가 및 삭제, 그리고 변경사항을 파악하고 해당 부분만 RecyclerView에 적용합니다.
이때 변경사항을 파악하기 위해서 아래와 같은 두 가지 메서드를 구현해야 합니다.
이 메서드를 사용하여 두 아이템이 동일한지 판단합니다.
여기서 "동일"하다는 것은 아이템의 고유한 ID값이 같음을 의미합니다.
주로 데이터베이스의 primary key나 객체의 고유한 ID값을 기준으로 비교합니다.
아래 User라는 데이터를 통해서 예시를 들어보겠습니다.
User
ID | Name | Age |
---|---|---|
1 | Kkosang | 20 |
2 | Hodu | 19 |
3 | Crocodile | 22 |
oldList와 newList가 다음과 같을 때, areItemsTheSame() 메서드는 ID값이 같으면 true, 다르면 false를 반환합니다.
// ex)
oldList : [{id=1, name=Kkosang},{id=2, name=Hodu}]
newList : [{id=1, name=KKosang},{id=3, name=Crocodile}]
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].id == newList[newItemPosition].id
}
이 메서드는 두 아이템이 동일한 경우만, 즉 areItemsTheSame이 true값인 경우에 내용이 변경되었는지 확인합니다. 여기서 "내용"이란 객체의 모든 필드값을 의미합니다.
// ex)
oldList : [{id=1, name=Kkosang, age=20},{id=2, name=Hodu, age=19}]
newList : [{id=1, name=KKosang, age=21},{id=2, name=Hodu, age=19}]
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == newList[newItemPosition]
}
위에서 예시를 들었던 내용으로 ListAdapter와 DiffUtil을 연결하겠습니다.
먼저 RecyclerView에 표시될 User 클래스를 작성합니다.
data class User(
val id: Int,
val name: String,
val age: Int
)
그 다음으로 DiffUtil의 ItemCallback을 정의합니다.
class UserDiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
ItemCallback이 정의되었다면, ListAdapter에 연결합니다.
// todo viewHolder : 뷰홀더가 있다고 가정하겠습니다.
class UserAdapter : ListAdapter<User, UserAdapter.UserViewHolder>(UserDiffCallback()) {
// viewHolder 생성
override fun onCreateViewHolder(parent: ViewGroup, viewType:Int): UserViewHolder {
val binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return UserViewHolder(binding)
}
// 데이터와 view를 바인딩
override fun onBindViewHolder(viewHolder: UserViewHolder, position: Int) {
viewHolder.bind(getItem(position))
}
}
마지막으로 UserAdapter의 submitList를 호출하면, DiffUtil이 데이터를 비교하고 변경된 데이터만 RecyclerView에 반영합니다.
class UserActivity : AppCompatActivity() {
private lateinit var userAdapter: UserAdapter
private lateinit var recyclerView: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// RecyclerView 설정
recyclerView = findViewById(R.id.recyclerView)
userAdapter = UserAdapter()
recyclerView.apply {
adapter = userAdapter
layoutManager = LinearLayoutManager(this)
}
val initialUsers = listOf(
User(1, "Kkosang", 20),
User(2, "Hodu", 19),
User(3, "Crocodile", 22)
)
userAdapter.submitList(initialUsers)
val updatedUsers = listOf(
User(1, "Kkosang", 21),
User(3, "Crocodile", 22),
User(4, "NewUser", 25)
)
userAdapter.submitList(updatedUsers)
}
}
이 글을 통해 다음과 같은 내용을 알아갔기를 바랍니다
이번 글을 작성하면서 개인적으로는 아쉬움이 남았습니다.
RecyclerView에서 다룰 내용이 많다보니 글을 쓰면서 뒤죽박죽이 된 느낌을 많이 받았습니다.
전달할 내용을 명확하게 정리하고
이번 글에서 다루지 못한 내용은 추후 시간이 된다면 다뤄보도록 하겠습니다.
이번 글을 작성하면서 RecyclerView에 대한 내용이 방대하고 다양하다 보니 모든 내용을 한 번에 다루기는 어려웠습니다. 그래서 일부 내용이 다소 복잡하게 느껴질 수도 있었을 것 같습니다.
앞으로 전달하고자 하는 내용을 명확하게 잡고 글을 작성하면 좋을 것 같다는 생각을 했습니다.
감사합니다 :)
https://landenlabs.com/android/info/recycler/recycler.html
https://medium.com/@metehan.bolat.ie/layout-managers-recycler-view-28d41ed89baa