본문 바로가기
📱| Android/📘 | 기록

[Android, Kotlin] RecyclerView를 ListAdapter를 이용해 효율적으로 사용하기

by immgga 2024. 7. 15.

출처: unsplash.com

 

이번 포스팅에는 내일배움캠프에서 공부하면서 알게 된 ListAdapter를 사용하면서 알게 된 ListAdapter를 이용해 RecyclerView를 구현한 예제를 사용법과 함께 적어보겠다.

사실 금요일 특강을 전부 듣지 못해서 개인적으로 공부한 내용과 노션에 적힌 글, 녹화 강의를 참고해서 적은 혼합물(?)이다.

 

ListAdapter를 알아보기 전에 왜 ListAdapter가 생기게 되었는지 간단하게 알아보자.

 

예전의 RecyclerView 작동 체제

기존의 RecyclerView에서는 사용자가 리스트의 데이터를 추가, 삭제하고자 할 때, adapter에 notifyDataSetChanged()를 사용해 adapter에 리스트가 변경되었음을 알려야 한다.

알림을 받은 adpater에서는 추가, 삭제된 데이터를 포함해 멀쩡히 있던 다른 모든 데이터들도 새로 reloading 하는 작업을 하기 때문에 infinite scroll(무한 스크롤) 리스트에 notifyDataSetChanged()를 사용하면 어떻게 될지는 모두가 알 수 있을 것이다.

앱이 엄청난 리스트의 reloading을 감당하지 못하고 터져 버리거나,

reloading이 되었다고 해도 멀쩡한 데이터값까지 새로고침하기 때문에 메모리를 많이 잡아먹는 것은 당연한 일.

 

이러한 점에서 기존의 RecyclerView.Adapter는 불변 리스트를 띄울 때는 최고의 성능으로 이용할 수 있지만,
데이터 추가, 삭제가 있을 때는 비효율적인 성능을 내는 금쪽이가 될 것이다.

 

이런 사태를 알아본 구글이 출시한 최신형 Adapter인 ListAdapter를 출시해 주었다.

 

ListAdapter

ListAdapter는 리스트의 값 추가, 삭제로 값이 변경되었을 때, 전체 목록을 모두 Load 하던 기존에 방식이 아닌, 변경된 부분만을 Load 할 수 있도록 해주는 Adapter이다.

설명만 봐도 기존의 방식보다 좋다는 건 모두가 알 수 있을 것이다.

 

ListAdapte에는 DiffUtil이 내장되어 있어서 데이터 변경을 감지하고 UI를 자동으로 갱신시킨다.

기존에 UI 갱신에 사용한 notifyDataSetChanged()를 사용하지 않고도 DiffUtil에서 감지해서 데이터값을 변경해 준다.

notifyDataSetChanged() 보다 효율적으로 데이터를 관리할 수 있다.

DiffUtil은 데이터가 변경되었을 때, 변경된 부분만을 Loading 할 수 있도록 해준다.
ListAdapter는 Adapter에 DiffUtil이 첨가된 Adapter라고 생각하는 게 편하다(일단 나는 그렇게 생각하고 있다).

 

유일한 단점(?)은 초보자가 배우기 힘들다는 것인데.

나의 경우에는 한 번 만들어 보니까 어떻게 작성해야 데이터를 정상적으로 load 하는지 감을 잡을 수 있었다.

초보자 분들은 기존 Adapter를 완벽히 숙지하고 ListAdapter를 공부하도록 하자.

 

 

ListAdapter 사용하기

본격적으로 ListAdapter를 사용해 보자.

기존의 RecyclerView.Adapter를 ListAdapter<리스트의 데이터 타입, 뷰 홀더>로 교체해 주고, 콜백을 작성해 준다.

class GoodsAdapter(
    private val onItemClick: (GoodsData) -> Unit,
    private val onLongItemClick: (Int) -> Unit
): ListAdapter<GoodsData, GoodsAdapter.ViewHolder>(
    object: DiffUtil.ItemCallback<GoodsData>() {
        override fun areItemsTheSame(oldItem: GoodsData, newItem: GoodsData): Boolean = oldItem.id == newItem.id

        override fun areContentsTheSame(oldItem: GoodsData, newItem: GoodsData): Boolean = oldItem == newItem
    }
) {
    // 여기는 RecyclerView.Adapter의 override 함수가 들어감.
}

 

어떤 데이터가 변경되었는지 체크하려면, 각 아이템의 기준이 되는 데이터(데이터베이스의 primary key와 같은 기준이 되는 데이터)가 있어야 한다.

나는 GoodsData 데이터 클래스에 id라는 기준이 되는 데이터를 만들어서 areItemsTheSame()에 사용해 주었다.

위 부분의 구현은 구 리스트와 신 리스트를 비교하기 위한 함수이므로 코드는 크게 어려운 게 없다.

 

기존의 RecyclerView.Adapter를 쓸 때 사용했던 함수들에는 변화가 없지만 하나 달라진 점이 있다.

ListAdapter에서는 Activity에서 ListAdapter 객체를 불러올 때 RecyclerView에 띄울 데이터를 미리 받아야 하는데(이후에 코드로 설명),

이때 받은 데이터가 있어서 기존에 필수로 구현해야 했던 getItemCount()를 구현하지 않고 onCreateViewHolder()와 onBindViewHolder()만 구현해도 된다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val binding = LayoutInflater.from(parent.context).inflate(R.layout.item_recycler_view_goods, parent, false)
    return ViewHolder(ItemRecyclerViewGoodsBinding.bind(binding))
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val goods = getItem(position)
    holder.bind(goods)
}

 

ListAdapter에서 Activity에서 받아온 리스트 정보를 저장하고 있기 때문에 기존에 RecyclerView.Adapter에서 파라미터로 리스트를 받아와서 사용하는 것은 하지 않아도 된다.

RecyclerView.Adapter에서 기존에는 파라미터 리스트를 이용해서 사용한 onBindViewHolder()의 goods 변수도 ListAdapte의 내장 함수인 getItem(position)을 이용해 저장한 리스트를 불러올 수 있다.

 

여기까지는 기존의 Adapter와 크게 다를 게 없다.

하지만 ViewHolder 쪽에서 데이터 클릭 이벤트를 받아서 데이터 추가, 삭제 작업을 주로 하게 될 텐데, 이 때는 ListAdapter에서만 사용할 수 있는 갱신 코드가 추가된다.

inner class ViewHolder(private val binding: ItemRecyclerViewGoodsBinding): RecyclerView.ViewHolder(binding.root) {
    fun bind(goods: GoodsData) {
        with(binding) {
            val priceFormat = DecimalFormat("#,###원")

            goodsImage.setImageResource(goods.goodsImage)
            goodsTitle.text = goods.title
            goodsAddress.text = goods.address
            goodsPrice.text = priceFormat.format(goods.price)
            goodsChatCnt.text = goods.chatCnt.toString()
            goodsLikeCnt.text = goods.likeCnt.toString()

            goodsLikeStatusImage.setOnClickListener {
                // todo :: 좋아요 클릭 이벤트.
            }

            binding.root.setOnClickListener {
                onItemClick(goods)
            }

            binding.root.setOnLongClickListener {
                val newList = currentList.toMutableList()
                newList.removeIf { it.id == goods.id }
                updateList(newList)

                return@setOnLongClickListener true
            }
        }
    }
}

여기 평범한 ViewHolder가 있다.

이 ViewHolder에서 나는 RecyclerView Item을 길게 클릭했을 때(longClick) 데이터가 삭제되도록 코드를 구성해 주었다.

 

binding.root.setOnLongClickListener {
    val newList = currentList.toMutableList()
    newList.removeIf { it.id == goods.id }
    updateList(newList)

    return@setOnLongClickListener true
}

LongClickListener 코드를 자세히 보면

ListAdapter에 적용할 새로운 리스트 변수를 만들어 주고(currentList는 ListAdapter에서 지원하는 property이다) 클릭된 아이템을 삭제해 주었다.

그 아래에 어떤 함수가 섞여 있는데 저 함수가 갱신을 담당하는 함수이다.

 

private fun updateList(newList: List<GoodsData>) {
    submitList(newList)
}

이렇게 간단하게 구현할 수 있다(ViewHolder 안에서는 submitList를 쓰지 못함).

함수를 ViewHolder 외부에 구현해 주고, newList를 적용하면

  1. 기존의 oldList였던 Activity에서 받아온 리스트와 새롭게 들어온 리스트인 newList를 DiffUtil로 비교해서 바로바로 RecyclerView에 적용해 주는 것이다.
  2. 다시 삭제를 할 때는 1번의 newList가 oldList가 되고, 새로운 newList를 받아와서 DiffUtil override 함수를 수행해서 RecyclerView에 적용을 반복하게 된다.

 

이제 Activity로 넘어와서 처음에는 ListAdapter에는 아무 리스트도 없을 것이다.

MainActivity.kt

val goodsRecyclerView = binding.mainRecyclerViewGoods
val adapter = GoodsAdapter(
    onItemClick = { goods ->
        val intent = Intent(this, GoodsInfoActivity::class.java)
        intent.putExtra("goods", goods)
        startActivity(intent)
    },
    onLongItemClick = { pos ->

    }
)
adapter.submitList(GoodsObject.goodsList)
goodsRecyclerView.layoutManager = LinearLayoutManager(this)
goodsRecyclerView.adapter = adapter

ListAdapter에 리스트가 존재하지 않기 때문에 처음에 Activity에서 submitList()를 사용해 newList를 받아주는 것이다(oldList는 없음).

위 코드를 수행하면 문제없이 RecyclerView에 데이터가 뜰 것이다.

 

전체 코드

GoodsAdapter.kt(미완성)

class GoodsAdapter(
    private val onItemClick: (GoodsData) -> Unit,
    private val onLongItemClick: (Int) -> Unit
): ListAdapter<GoodsData, GoodsAdapter.ViewHolder>(
    object: DiffUtil.ItemCallback<GoodsData>() {
        override fun areItemsTheSame(oldItem: GoodsData, newItem: GoodsData): Boolean = oldItem.id == newItem.id

        override fun areContentsTheSame(oldItem: GoodsData, newItem: GoodsData): Boolean = oldItem == newItem
    }
) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = LayoutInflater.from(parent.context).inflate(R.layout.item_recycler_view_goods, parent, false)
        return ViewHolder(ItemRecyclerViewGoodsBinding.bind(binding))
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val goods = getItem(position)
        holder.bind(goods)
    }

    inner class ViewHolder(private val binding: ItemRecyclerViewGoodsBinding): RecyclerView.ViewHolder(binding.root) {
        fun bind(goods: GoodsData) {
            with(binding) {
                val priceFormat = DecimalFormat("#,###원")

                goodsImage.setImageResource(goods.goodsImage)
                goodsTitle.text = goods.title
                goodsAddress.text = goods.address
                goodsPrice.text = priceFormat.format(goods.price)
                goodsChatCnt.text = goods.chatCnt.toString()
                goodsLikeCnt.text = goods.likeCnt.toString()

                goodsLikeStatusImage.setOnClickListener {
                    // todo :: 좋아요 클릭 이벤트.
                }

                binding.root.setOnClickListener {
                    onItemClick(goods)
                }

                binding.root.setOnLongClickListener {
                    val newList = currentList.toMutableList()
                    newList.removeIf { it.id == goods.id }
                    updateList(newList)

                    return@setOnLongClickListener true
                }
            }
        }
    }

    private fun updateList(newList: List<GoodsData>) {
        submitList(newList)
    }
}

미완성 코드라고 적혀 있지만, ListAdapter는 정상적으로 작동하는 코드이다.

 

 

정리

컴파일 에러가 없어도 RecyclerView가 정상적으로 작동하지 않을 경우는 2가지 정도가 있겠다.

  • Activity(adapter를 호출하는 곳)에서 ListAdapter를 호출하고 첫 submitList()를 해주지 않은 경우
    이 경우에는 처음에 앱을 실행하면 RecyclerView에 데이터가 아무것도 안 들어 있을 수 있다(백지).
  • ListAdapter 내부에서 newList를 만들어서 submitList()로 갱신을 해주지 않은 경우
    이 경우에는 데이터 삭제, 추가를 위한 트리거를 실행해도 데이터 삭제, 추가가 작동되지 않을 것이다.

나는 2가지의 경우를 모두 겪고 해결을 해봄으로써 처음 구현할 때는 "이게 왜 안되지?" 라는 생각이 들어서 강의 자료와 검색 자료를 많이 찾아보았다.

내 글을 보고 빠르게 갈피를 잡아서 ListAdapter를 사용하는 데 문제가 없을 정도로 사용에 능숙해지는 데 도움이 되었으면 좋겠다.

고칠 점과 반박, 질문은 모두 받고 있으니 댓글로 남겨주세요.

 

참고한 글

https://ppeper.github.io/android/android-diffutil/

 

안드로이드 RecyclerView의 DiffUtil 알아보기

DiffUtil 넌 뭐니 안드로이드를 공부하거나 개발하다보면 대부분 리스트를 보여주기 위하여 RecyclerView 의 사용을 하게되고, 리스트의 데이터가 변하게 되면 notifyDataSetChange() 를 호출하여 리사이클

ppeper.github.io

https://cliearl.github.io/posts/android/recyclerview-listadapter/

 

DiffUtil과 ListAdapter 이해하고 RecyclerView에 적용하기

이번 포스팅에서는 RecyclerView에 ListAdapter를 적용하는 법에 대해 알아보도록 하겠습니다. 들어가기 Recyclerview의 데이터가 변하면 Recyclerview Adapter가 제공하는 notifyItem 메소드를 사용해서 ViewHolder

cliearl.github.io

https://hungseong.tistory.com/24

 

[Android, Kotlin] RecyclerView의 성능 개선, DiffUtil과 ListAdapter

RecyclerView의 Adapter는 RecyclerView에서 다음과 같은 역할을 한다. 데이터 리스트를 관리하여 포지션에 맞게 ViewHolder의 View와 연결하여 표시하는 중간자 기존 RecyclerView.Adapter를 사용할 경우 위 역할

hungseong.tistory.com

728x90