본문 바로가기
Android/View

[Android] 리싸이클러뷰(RecyclerView) (1) - 기본 개념

by 태크민 2024. 10. 24.

1. 안드로이드 리사이클러뷰(RecyclerView)

"수많은 데이터의 집합을 지정된 영역 내에서 유연하게(flexible) 표시되도록 만들어주는 위젯"입니다.

 

안드로이드 개발자 문서에 작성된 리사이클러뷰(RecyclerView)의 설명입니다. 이해를 돋기 위해 설명을 덧붙이자면, 리사이클러뷰(RecyclerView)는 "사용자가 관리하는 많은 수의 데이터 집합(Data Set)을 개별 아이템 단위로 구성하여 화면에 출력하는 뷰그룹(ViewGroup)이며, 한 화면에 표시되기 힘든 많은 수의 데이터를 스크롤 가능한 리스트로 표시해주는 위젯"입니다.

 

등장 배경

RecyclerView 이전에는 스크롤되는 리스트를 표현하기 위해서 ListView를 사용했습니다. ListView를 이용하면 간단하고 빠르게 리스트를 만들 수 있습니다.

하지만 이런 ListView에는 몇가지 단점이 있었습니다.

  • 스크롤 시 버벅임 : ListView는 데이터의 아이템 만큼 뷰를 생성하게 됩니다. 이렇게 매번 뷰를 만들고 findViewById() 메서드를 사용하는 것은 많은 비용이 들게 됩니다. (재사용을 하지 않기 때문에)
  • 기본 애니메이션의 지원이 없다 : ListView는 애니메이션에 대한 기본 지원이 제공되지 않으며 개발자가 직접 구현을 해야합니다.
  • 수직 스크롤만 지원 : ListView는 오로지 수직 스크롤만 가능합니다.

이런 문제점들을 해결하기 위해서 등장한 것이 RecyclerView입니다.

 

ListView와 어떤 차이가 있을까?

 

높은 재사용성

ListView는 스크롤할 때마다 화면에서 사라지는 가장 위의 아이템을 삭제하고 가장 아래에 새로운 아이템을 생성합니다. 따라서, 아이템 삭제 및 생성 횟수가 늘어날수록 cost가 매우 높아지게 됩니다.

 

반면 RecyclerView는 가장 위의 아이템을 삭제하지 않고 재활용(recycle)해서 맨 밑으로 옮깁니다. 아이템의 데이터만 수정하고 View1 자체는 재활용하는 구조입니다. 즉, 새로운 View 객체를 생성하지 않기 때문에 ListView 대비 많은 cost를 아낄 수 있게 됩니다.

 

유연함

RecyclerView의 장점으로 꼽히는 것에 "유연함(flexibility)"이 있습니다. 

프로그래밍 분야에서 유연함이란, "구현 요소" 또는 구현에 따른 "결과물"이 쉽게 변경되거나 확장될 수 있음을 의미한다고 합니다.. 그렇다면 어떤 점에서 유연함이 보장되는지 ListView와 비교해보겠습니다.

 

ListView의 기본 구현으로 아이템들은 수직 방향으로만 나열됩니다. 수평으로도 나열할 수 있지만 이럴 경우 ListView가 아닌 다른 View를 사용하거나, ListView를 재 구현해야 합니다. 문제는 아무런 가이드도 제공되지 않기 때문에 재구현에 따른 오류 처리들은 오롯이 개발자의 몫이 됩니다.

 

그러나 RecyclerView는 이러한 단점들을 보완했습니다. 수직뿐만 아니라 수평 및 2차원 격자(Grid) 형태로도 아이템들을 나열할 수 있고, 아이템 뷰의 동적(Dynamic) 구성을 용이하게 해 주며, 이를 런타임에 바꿀 수도 있습니다. 이런 특징들이 RecyclerView의 유연함을 나타내는 장점들입니다.

 

 

2. RecyclerView의 구성 요소

RecyclerView는 데이터 목록을 아이템 단위의 View로 생성해서 화면에 표시하기 위해 Adapter를 사용합니다. Adapter에 의해 생성된 View 객체를 어떤 방식으로 나열할지 결정하기 위해서 Layout Manager를 이용합니다. 끝으로 Layout Manager가 제공하는 레이아웃 형태로, Adapter를 통해 각 아이템 뷰는 ViewHolder 객체에 저장되어 화면에 표시하게 되고, 재활용됩니다.

 

 

1) RecyclerView

RecyclerView는 v7 Support Library에서 관리했지만 현재는 androidX Library에서 관리되고 있습니다. 위에서 설명한 요소들을 통해서 사용자 데이터를 리스트 형태로 화면에 표시하는 컨테이너 역할을 수행합니다. 

 

2) Adapter

Adapter는 RecyclerView에 표시될 아이템 View를 생성해줍니다. 사용자 데이터 리스트로부터 아이템 뷰를 만드는 것, 그것이 Adapter의 역할입니다.

 

3) LayoutManager

LayoutManager는 RecyclerView가 아이템을 화면에 표시할 때, 아이템 View들이 RecyclerView 내부에 배치되는 형태를 관리합니다.

 

안드로이드 SDK에서 다음과 같은 레이아웃매니저들이 기본으로 제공되고 있으며, 각 레이아웃 매니저가 아이템 View들을 배치하는 형태는 아래와 같습니다.

  • 리니어(LinearLayoutManager) : 수평(Horizontal) 또는 수직(Vertical) 방향, 일렬(Linear)로 아이템 뷰 배치.
  • 그리드(GridLayoutManager) : 바둑판 모양의 격자(Grid) 형태로 아이템 뷰 배치.
  • 스태거드그리드(StaggeredGridLayoutManager) : 엇갈림(Staggered) 격자(Grid) 형태로 아이템 뷰 배치.

 

4) ViewHolder

ViewHolder는 화면에 표시될 아이템 View를 저장하는 객체입니다. Adapter에 의해 관리되고 필요에 따라 Adapter에서 생성됩니다. 물론, 미리 생성된 ViewHolder 객체가 있는 경우에는 새로 생성하지 않고, 재활용하는데, 이 때는 단순히 데이터가 ViewHolder의 아이템 View에 바인딩 됩니다.

 

 

 

3. RecyclerView 예제

RecyclerView 레이아웃 설정

activity_main.xml 레이아웃 파일에 RecyclerView 위젯을 추가합니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/todo_list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

아이템 뷰 생성

리스트의 아이템은 간단하게 텍스트뷰와 체크박스를 추가합니다.

이때 최상단 레이아웃의 layout_height wrap_content로 설정해야 아이템의 높이만큼 설정이 됩니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingVertical="16dp">

    <TextView
        android:id="@+id/todo_title_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="할일목록" />

    <CheckBox
        android:id="@+id/completed_check_box"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

DTO 클래스 생성

리스트의 각 아이템은 하나의 텍스트뷰와 체크박스를 가지고 있으므로 

data class의 프로퍼티는 String 타입과 Boolean 타입으로 선언합니다.

data class Todo(
    val title: String,
    var completed: Boolean
)

 

 

Adapter 클래스

RecyclerView.Adapter는 abstract class(추상 클래스)이므로 onCreateViewHolder, onBindViewHolder, getItemCount 총 3개의 메서드를 오버라이딩해야 합니다.

작동 순서는 getItemCount → onCreateViewHolder → onBindViewHolder 입니다.

 

1. onCreateViewHolder 

    // ViewHolder 생성하는 함수, 최소 생성 횟수만큼만 호출됨 (계속 호출 X)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
        Log.d(TAG, "onCreateViewHolder: ")
        val binding =  ItemTodoBinding.inflate(
            LayoutInflater.from(parent.context), // layoutInflater 를 넘기기위해 함수 사용, ViewGroup 는 View 를 상속하고 View 는 이미 Context 를 가지고 있음
            parent, // 부모(리싸이클러뷰 = 뷰그룹)
            false   // 리싸이클러뷰가 attach 하도록 해야함 (우리가 하면 안됨)
        )
        return TodoViewHolder(binding).also { holder ->
            binding.completedCheckBox.setOnCheckedChangeListener { _, isChecked ->
                todos.getOrNull(holder.adapterPosition)?.completed = isChecked
            }
        }
    }

onCreateViewHolderViewHolder를 생성하는 함수로, 최초 생성 횟수만큼만 호출됩니다. (계속 호출 X)

파라미터 중 ViewGroup 타입의 parent는 RecyclerView를 의미합니다.

ViewHolder를 생성할 때 view를 inflate는 과정에서 context가 필요한데 이때는 parent.context를 사용하면 됩니다.

parent는 ViewGroup타입이며, ViewGroup는 View를 상속하고 View는 activity에 inflate 돼있으므로

이미 context를 가지고 있기 때문입니다.

 

※ 주의

가끔 Adpater 클래스의 생성자로 context를 받는 코드를 보곤 하는데 이는 권장하지 않는 방식입니다.

Activity의 context를 다른 곳에서 참조하게 되면 메모리 릭이 발생할 수도 있습니다.

예를 들어, Fragment의 Recycler Adapter에서 Activity의 Context를 사용하게 되면 메모리릭이 발생할 수 있습니다. Fragment는 Fragment의 view보다 생명주기가 깁니다.  RecyclerView는 onDestroyView()에서 파괴되어야 하지만, adapter가 살아있기 때문에(activity의 context를 참조하여) gc가 회수하지 못하게 됩니다. 이로 인해 메모리 누수가 발생합니다.

또한, 결합성이 높아지는 단점이 존재하므로 지양해야 하는 방식입니다.

 

2. onBindViewHolder

    // 만들어진 ViewHolder에 데이터를 바인딩하는 함수
    // position = 리스트 상에서 몇번째인지 의미
    override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
        Log.d(TAG, "onBindViewHolder: $position")
        holder.bind(todos[position])
    }

위 함수는 만들어진 ViewHolder에 데이터를 바인딩하는 역할을 합니다.

onCreateViewHolder와 달리 뷰가 재사용될 때마다 호출됩니다.

매개변수 중 holder는 재사용되는 뷰 객체를 가지고 있으며, position는 리스트 상에서 몇 번째인지를 의미합니다.

 

※ 주의

onBindViewHolder 내에 클릭 리스너를 선언하는 경우가 종종 있는데, 이는 함수가 호출될 때마다

리스너를 새로 등록하므로 객체를 계속해서 새로 생성하는 것과 같습니다.

따라서 메모리가 낭비되며 성능 저하를 불러일으키므로 onCreateViewHolder 함수 또는 ViewHolder 내부에 리스너를 달아줍니다.

 

3. getItemCount

override fun getItemCount(): Int = todos.size

위 함수는 데이터 셋의 개수를 리턴합니다.

 

ViewHolder 클래스

fun bind

fun bind(todo: Todo) {
    binding.todoTitleText.text = todo.title
    binding.completedCheckBox.isChecked = todo.completed
}

위 함수는 인자로 받은 dto 객체를 뷰에 바인딩해주는 역할을 합니다.

ViewBinding을 사용했으므로 binding 객체를 사용하여 뷰에 접근합니다.

 

 

<RecyclerViewAdapter 전체 소스>

class TodoAdapter(private val todos: List<Todo>) : RecyclerView.Adapter<TodoAdapter.TodoViewHolder>() {
    companion object {
        private const val TAG = "TodoAdapter_고기"
    }

    // ViewHolder 생성하는 함수, 최소 생성 횟수만큼만 호출됨 (계속 호출 X)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
        Log.d(TAG, "onCreateViewHolder: ")
        val binding =  ItemTodoBinding.inflate(
            LayoutInflater.from(parent.context), // layoutInflater 를 넘기기위해 함수 사용, ViewGroup 는 View 를 상속하고 View 는 이미 Context 를 가지고 있음
            parent, // 부모(리싸이클러뷰 = 뷰그룹)
            false   // 리싸이클러뷰가 attach 하도록 해야함 (우리가 하면 안됨)
        )
        return TodoViewHolder(binding).also { holder ->
            binding.completedCheckBox.setOnCheckedChangeListener { _, isChecked ->
                todos.getOrNull(holder.adapterPosition)?.completed = isChecked
            }
        }
    }

    // 만들어진 ViewHolder에 데이터를 바인딩하는 함수
    // position = 리스트 상에서 몇번째인지 의미
    override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
        Log.d(TAG, "onBindViewHolder: $position")
        holder.bind(todos[position])
    }

    override fun getItemCount(): Int = todos.size

    class TodoViewHolder(private val binding: ItemTodoBinding) : RecyclerView.ViewHolder(binding.root) {

        fun bind(todo: Todo) {
            binding.todoTitleText.text = todo.title
            binding.completedCheckBox.isChecked = todo.completed
        }
    }
}



RecyclerView 초기화 및 설정

Adapter 클래스, ViewHolder 클래스, dto 클래스와 아이템 뷰 모두 생성이 되었다면

Activity에서 RecyclerView를 설정해준다.

class MainActivity : AppCompatActivity() {

    lateinit var binding : ActivityMainBinding

    private val todos = listOf(
        Todo("리싸이클러뷰 부시기 #1", false),
        Todo("리싸이클러뷰 부시기 #2", false),
        Todo("리싸이클러뷰 부시기 #3", false),
        Todo("리싸이클러뷰 부시기 #4", false),
        Todo("리싸이클러뷰 부시기 #5", false),
        Todo("리싸이클러뷰 부시기 #6", false),
        Todo("리싸이클러뷰 부시기 #7", false),
        Todo("리싸이클러뷰 부시기 #8", false),
        Todo("리싸이클러뷰 부시기 #9", false),
        Todo("리싸이클러뷰 부시기 #10", false),
        Todo("리싸이클러뷰 부시기 #11", false),
        Todo("리싸이클러뷰 부시기 #12", false),
        Todo("리싸이클러뷰 부시기 #13", false),
        Todo("리싸이클러뷰 부시기 #14", false),
        Todo("리싸이클러뷰 부시기 #15", false),
        Todo("리싸이클러뷰 부시기 #16", false),
        Todo("리싸이클러뷰 부시기 #17", false),
        Todo("리싸이클러뷰 부시기 #18", false),
        Todo("리싸이클러뷰 부시기 #19", false),
        Todo("리싸이클러뷰 부시기 #20", false)
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initViews()
    }

    private fun initViews() {
        binding.todoList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
        binding.todoList.adapter = TodoAdapter(todos)
    }
}

RecyclerView의 id는 todo_list 이므로 binding.todoList로 접근합니다.

LayoutManager는 1차원 수직 스크롤을 사용할 것이므로 LinearLayoutManager.VERTICAL 속성을 사용하여 설정합니다.

Adapter은 만들어 놓은 TodoAdapter를 사용하며 Dummy data인 todos를 인자로 넘깁니다.

 

여기까지가 RecyclerView 기본 구현 완성입니다. 코드를 실행해서 확인해봅시다!

 

뷰의 재사용 확인

애뮬레이터 실행 후 로그를 확인하면 초기에는

onCreateViewHolder와 onBindViewHolder가 같은 횟수로 번갈아 호출되지만

ViewHolder가 어느 정도 생성되면 onBindViewHolder만 호출되는 것을 확인할 수 있습니다.

따라서 뷰가 재사용된다는 것을 로그를 통해 눈으로 확인했습니다!

 

 


출처

https://recipes4dev.tistory.com/154

 

안드로이드 리사이클러뷰 기본 사용법. (Android RecyclerView)

1. 안드로이드 리사이클러뷰(RecyclerView) 리사이클러뷰(RecyclerView)는, "많은 수의 데이터 집합을, 제한된 영역 내에서 유연하게(flexible) 표시할 수 있도록 만들어주는 위젯"입니다. [안드로이드 개발

recipes4dev.tistory.com

https://gogigood.tistory.com/56

https://velog.io/@hyeryeong/RecyclerView-%EC%9E%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B01

 

[Android] RecyclerView(1) - 기본 사용법

RecyclerView는 스크롤되는 리스트를 표시할 수 있는 UI 컴포넌트입니다. RecyclerView 이전에는 ListView를 사용하여 리스트를 나타냈습니다.하지만 이런 ListView에는 몇가지 단점이 있었습니다.스크롤 시

velog.io

https://meal-coding.tistory.com/29

 

[Android] 홍드로이드 기초 강의 - 리사이클러뷰(RecyclerView)

1. 안드로이드 리사이클러뷰(RecyclerView) "수많은 데이터의 집합을 지정된 영역 내에서 유연하게(flexible) 표시되도록 만들어주는 위젯"입니다. 안드로이드 개발자 문서에 작성된 리사이클러뷰(Recyc

meal-coding.tistory.com