ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • RecyclerView를 AsyncListDiffer 을 이용하여 효율 높이기
    Android Studio 2024. 6. 26. 21:17
    728x90

    레이아웃 구성 compose X , xml 파일 O

     

    RecyclerView 에 적용된 아이템에 대해서 상태 혹은 갯수가 변경되었을 때 마다 notifyDataSetChanged() 을 호출하여 갱신을 하였었는데, 여러 아이템 중 1개만 상태가 변하거나, 증감이 될 때마다 전체 아이템을 갱신한다는 점에서 비효율적 이라는 생각을 가지게 되었습니다.

     

    이런 비효율적인 방법을 개선을 하고 싶었고, 이미 많은 개발자분들이 느끼셨는지 해당 비효율적인 방법을 효율적으로 사용할 수 있도록 만들어 두셨고, 해당 글들도 매우 많이 작성되어 있었습니다. 덕분에 저도 해당 글들을 보면서 현재 진행하고 있는 프로젝트 내 RecyclerView 내 아이템을 효율적으로 표시할 수 있게 되었습니다.

     

    왜 효율적이죠?

    notifyDataSetChanged() 는 아이템의 속성 값이 하나만 변경되더라도 호출하였을 경우 리사이클러뷰 전체를 다시 그린다는 비효율적인 로직을 가지고 있습니다. 이를 효과적으로 사용하려면 개발자가 notifyItemRangeInserted(), notifyItemRangeRemoved()등을 호출하여 갱신 시켜야하는 번거러움이 있었고, 이러한 번거러움을 줄이기 위하여 DiffUtil  가 개발되었습니다.  DiffUtil 을 사용하면 이전 데이터 상태와 현재 데이터 간의 상태 차이를 확인하고, 차이가 있는 데이터에 한해서만 갱신을 하기 때문에 notifyDataSetChanged() 보다 데이터 업데이트 횟수가 줄고 업데이트 횟수 또한 최소한으로 가져가기 때문입니다. 

     

    그러면 성능은 어떻죠?

    DiffUtil 은 기존 데이터와 현재 데이터 목록 간의 추가, 제거를 수행하는 작업의 최소 값을 찾기 위해 O(N) 공간이 필요하고, 예상 성능은 O(N + D^2) 정도를 가집니다.

    N: 추가 및 제거된 데이터의 총 수

    D: 스크립트 길이

     

    그래도 불편한데....

    DiffUtil 을 사용하기 위해서는 AsnycListDiffer을 사용하는데 이 또한 매번 비슷한 패턴으로 코드를 구현하게 되는데 이를 보다 편리하게 개발 할 수 있도록  androidx 는 ListAdapter 라는 추상클래스를 제공하고 있습니다.

    이로 인해 개발자가 필수로 구현해야하는 부분은 onCreateViewHolder(), onBindViewHolder() 뿐 입니다.

     

    이제부터는 AsyncListDiffer을 이용하여 간단한 예제를 만들어 보겠습니다.

     

    activity_main.xml

    <?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"
        android:transitionGroup="true"
        tools:context=".MainActivity">
    
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/foodOrdersRecyclerview"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginBottom="100dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0">
    
        </androidx.recyclerview.widget.RecyclerView>
    
        <Button
            android:id="@+id/addButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="28dp"
            android:text="추가하기"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.232"
            app:layout_constraintStart_toStartOf="parent" />
    
        <Button
            android:id="@+id/removeButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="32dp"
            android:text="제거하기"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.803"
            app:layout_constraintStart_toStartOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

     

    리사이클러뷰 내 들어갈 food_order_item.xml

     

    <?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:padding="12dp"
        android:transitionGroup="true">
    
        <TextView
            android:id="@+id/foodNameTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:layout_marginBottom="12dp"
            android:text="음식명"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.011"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0" />
    
        <TextView
            android:id="@+id/foodNameTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:text="불러오는 중"
            app:layout_constraintBottom_toBottomOf="@+id/foodNameTitle"
            app:layout_constraintStart_toEndOf="@+id/foodNameTitle"
            app:layout_constraintTop_toTopOf="@+id/foodNameTitle" />
    
        <TextView
            android:id="@+id/foodOrderCountTitleTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="184dp"
            android:text="주문량"
            app:layout_constraintBottom_toBottomOf="@+id/foodNameTextView"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="@+id/foodNameTextView"
            app:layout_constraintVertical_bias="1.0" />
    
        <TextView
            android:id="@+id/orderCountTextview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:text="0"
            app:layout_constraintBottom_toBottomOf="@+id/foodOrderCountTitleTextView"
            app:layout_constraintEnd_toStartOf="@+id/orderCountMinusButton"
            app:layout_constraintStart_toEndOf="@+id/orderCountPlusButton"
            app:layout_constraintTop_toTopOf="@+id/foodOrderCountTitleTextView" />
    
        <ImageView
            android:id="@+id/orderCountPlusButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            app:layout_constraintBottom_toBottomOf="@+id/foodOrderCountTitleTextView"
            app:layout_constraintStart_toEndOf="@+id/foodOrderCountTitleTextView"
            app:layout_constraintTop_toTopOf="@+id/foodOrderCountTitleTextView"
            app:layout_constraintVertical_bias="0.0"
            app:srcCompat="@drawable/add_24px" />
    
        <ImageView
            android:id="@+id/orderCountMinusButton"
            android:layout_width="24dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="24dp"
            app:layout_constraintBottom_toBottomOf="@+id/orderCountPlusButton"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="@+id/orderCountPlusButton"
            app:layout_constraintVertical_bias="0.0"
            app:srcCompat="@drawable/remove_24px" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>

     

     

    이제 레이아웃 설정은 끝났습니다.

    이후 음식 주문 정보를 가지고 있을 데이터 클래스를 하기와 같이 생성합니다.

    data class FoodOrder(
        val orderId: Int,
        val name: String,
        val count: Int
    )

     

     

    이제 리사이클러뷰에 적용할 어댑터를 구성하기 전에 DiffCallback 를 작성해보겠습니다.

    DiffCallback 은 DiffUtil 에 포함되어 있는 데이터간의 차이점을 계산하고, 결과를 RecyclerView.Adapter에 전달하는 역할을 가지고 있습니다.

     

    package com.example.diffutil
    
    import androidx.recyclerview.widget.DiffUtil
    
    class OrderAdapterDiffCallback : DiffUtil.ItemCallback<FoodOrder>() {
        override fun areItemsTheSame(
            oldItem: FoodOrder,
            newItem: FoodOrder,
        ): Boolean {
            return oldItem.orderId == newItem.orderId
        }
    
        override fun areContentsTheSame(
            oldItem: FoodOrder,
            newItem: FoodOrder,
        ): Boolean {
            return oldItem == newItem
        }
    }

     

    OrderAdapterDiffCallback 은 추상클래스인  DiffUtil의 ItemCallBack 을 상속받아 아이템을 비교하는 함수를 작성하면 됩니다.

    FoodOrder 를 만약 데이터 클래스가 아닌 일반 클래스로 만들었을 경우에는 .equals(), .hashCode() 를 수정해야 할 것 입니다.

     

     

    이제 생성자를 통해서 만든 DiffCallback 클래스를 주입받고, androidx 에서 제공하는 ListAdapter 상속받는 adapter 파일을 만들어 보겠습니다.

    해당 클래스를 만든 후 implement members를 이용하여 onCreateViewHolder(), onBindViewHolder() 를 만들어 줍니다.

    package com.example.diffutil
    
    import android.view.LayoutInflater
    import android.view.ViewGroup
    import androidx.recyclerview.widget.ListAdapter
    import androidx.recyclerview.widget.RecyclerView
    import com.example.diffutil.databinding.FoodOrderItemBinding
    
    class OrderAdapter(
        private val diffCallback: OrderAdapterDiffCallback,
    ) : ListAdapter<FoodOrder, RecyclerView.ViewHolder>(diffCallback) {
        override fun onCreateViewHolder(
            parent: ViewGroup,
            viewType: Int,
        ): RecyclerView.ViewHolder {
    
        }
    
        override fun onBindViewHolder(
            holder: RecyclerView.ViewHolder,
            position: Int,
        ) {
    
        }
    }

     

    MainActivity.kt 내 생성 부분

    private lateinit var adapter: OrderAdapter
    private val orderAdapterDiffCallback = OrderAdapterDiffCallback()
    .
    .
    .
    adapter = OrderAdapter(orderAdapterDiffCallback)
    binding.run {
        foodOrdersRecyclerview.adapter = adapter
        foodOrdersRecyclerview.layoutManager = LinearLayoutManager(applicationContext)
    }

     

    이걸로 세부구현을 제외한 껍데기는 모두 만들었습니다.

     

    이제부터는 임시 데이터를 추가해보겠습니다.

    리사이클러뷰가 어떤 리스트를 갱신시키는 것은 submitList() 를 이용하여 리스트를 적용시킵니다.

    그러면 이제 내부적으로 기존에 있던 리스트와 비교하여 최소한의 업데이트 작업을 수행합니다.

    (백그라운드 스레드에서 비교하고 메인 쓰레드에서 리사이클러뷰 갱신)

    private var orders = listOf(
            FoodOrder(0, "스파게티", 0),
            FoodOrder(1, "치킨", 0),
            FoodOrder(2, "피자", 0),
    )
    .
    .
    .
    .
    adapter.submitList(orders)

     

    저는 아이템의 + 버튼과 -버튼을 눌렀을 때 어떻게 행동할지를 어댑터나 holder 내부가 아닌 MainActivity 파일 내에서 만들어서 전달하는 과정으로 만들어 보겠습니다.

     

    val plusListener: (FoodOrder) -> Unit = { foodOrder ->
                val newList = mutableListOf<FoodOrder>()
                adapter.currentList.forEach {
                    if (it == foodOrder) {
                        newList.add(
                            it.copy(
                                count = it.count + 1,
                            ),
                        )
                    } else {
                        newList.add(it)
                    }
                }
                adapter.submitList(newList)
            }
    val minusListener: (FoodOrder) -> Unit = { foodOrder ->
        val newList = mutableListOf<FoodOrder>()
        adapter.currentList.forEach {
            if (it == foodOrder && it.count > 0) {
                newList.add(
                    it.copy(
                        count = it.count - 1,
                    ),
                )
            } else {
                newList.add(it)
            }
        }
        adapter.submitList(newList)
    }
    adapter = OrderAdapter(orderAdapterDiffCallback, plusListener, minusListener)
    
            adapter = OrderAdapter(orderAdapterDiffCallback, plusListener, minusListener)
            adapter.submitList(orders)

     

    package com.example.diffutil
    
    import android.view.LayoutInflater
    import android.view.ViewGroup
    import androidx.recyclerview.widget.ListAdapter
    import androidx.recyclerview.widget.RecyclerView
    import com.example.diffutil.databinding.FoodOrderItemBinding
    
    class OrderAdapter(
        private val diffCallback: OrderAdapterDiffCallback,
        private val plusButtonClickListener: (FoodOrder) -> Unit,
        private val minusButtonClickListener: (FoodOrder) -> Unit
    ) : ListAdapter<FoodOrder, RecyclerView.ViewHolder>(diffCallback) {
        override fun onCreateViewHolder(
            parent: ViewGroup,
            viewType: Int,
        ): RecyclerView.ViewHolder {
            val inflater = LayoutInflater.from(parent.context)
            val binding = FoodOrderItemBinding.inflate(inflater, parent, false)
            return OrderHolder(binding, plusButtonClickListener, minusButtonClickListener)
        }
    
        override fun onBindViewHolder(
            holder: RecyclerView.ViewHolder,
            position: Int,
        ) {
            when (holder) {
                is OrderHolder -> holder.bind(currentList[position])
            }
        }
    }
    package com.example.diffutil
    
    import androidx.recyclerview.widget.RecyclerView
    import com.example.diffutil.databinding.FoodOrderItemBinding
    
    class OrderHolder(
        private val foodOrderItemBinding: FoodOrderItemBinding,
        private val plusButtonClickListener: (FoodOrder) -> Unit,
        private val minusButtonClickListener: (FoodOrder) -> Unit
    ) : RecyclerView.ViewHolder(foodOrderItemBinding.root) {
        fun bind(foodOrder: FoodOrder) {
            foodOrderItemBinding.run {
                foodNameTextView.text = foodOrder.name
                orderCountTextview.text = foodOrder.count.toString()
                orderCountPlusButton.setOnClickListener {
                    plusButtonClickListener(foodOrder)
                }
                orderCountMinusButton.setOnClickListener {
                    minusButtonClickListener(foodOrder)
                }
            }
        }
    }

     

    adapter.currentList 를 이용하면 현재 어댑터가 리사이클러뷰에 그리고 있는 리스트를 확인할 수 있습니다.

    상기코드를 보면 알 수 있듯이 리스너를 MainActivity 에서 리스너를 만들어 주입 시킨 이유는 홀더 내부에서 구현 할 경우 변경되었다는 사실을 어댑터에 알리는 기능을 작성해야할 것이고, 어댑터 내부에 작성할 경우에는 홀더 내부에 구현하는 것보다 훨씬 간편하게 작성할 수 있겠지만, 만약 해당 어댑터 파일을 나중에 재사용을 하기가 어렵다는 판단하에 외부에서 리스너를 주입하여 작성하였습니다.

    다음은 작동 영상입니다.

     

     

    이번에는 리스트 내의 자료를 추가 수정하는 기능을 작성해보겠습니다.

    MainActivity.kt

    binding.run {
                foodOrdersRecyclerview.adapter = adapter
                foodOrdersRecyclerview.layoutManager = LinearLayoutManager(applicationContext)
    
                addButton.setOnClickListener {
                    val newList = adapter.currentList.toMutableList()
                    newList.add(
                        FoodOrder(newList.last().orderId + 1, "추가된 음식${newList.last().orderId + 1}", 0),
                    )
                    adapter.submitList(newList)
                }
    
                removeButton.setOnClickListener {
                    val newList =
                        adapter.currentList.toMutableList().subList(0, adapter.currentList.size - 1)
                    adapter.submitList(newList)
                }
            }

     

     

     

     

     

     

    지금 까지의 코드를 잘보면 currentList를 직접 수정하면 안되나? 라는 생각이 들 수 도 있습니다.

     

    마지막으로 주의사항을 설명하겠습니다.

    currentList 의 형태는 List 로 반환이 되기 때문에 추가, 삭제, 수정등은 당연히 불가능 하며, 또한 상기 코드 기준으로 만약 orders 가 mutableList 였을 경우 데이터 추가 및 수정 삭제를 한 뒤에 submitList()을 이용하여도 반영이 되지 않습니다.

    orders.removeLast()
    adapter.submitList(orders)

     

    해당 이유는 반영되어 있는 리스트의 주소값과 반영하려는 리스트의 주소값이 동일 할 경우에는 변경 할 일 이 없다고 판단하기 때문입니다.

    그렇기 때문에 List를 갱신 시키려면 속성값이 변경되었거나  갯수가 변경된 새로운 리스트를 만들어(deep Copy) 적용시켜야 합니다.

     

    또한 공식문서에서도 반드시 주의해야할 점 이라고 소개하고 있습니다.

     

    이상 매우 요약한 사용방법과 주의 사항이였습니다.

     

    전체 코드 git: https://github.com/Lst-1995-kotlin/AOS_STUDY/tree/master/diffutil

     

    참고 문서 및 블로그

    https://dev.gmarket.com/79

    https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter

Designed by Tistory.