리사이클러뷰를 사용하다 보면 뷰 홀더에 있는 데이터를 변경해야 할 때가 있습니다. 그럴 때마다 notifyItemChanged()를 이용해서 리사이클러뷰의 리스트를 갱신해 왔습니다. 오늘은 다른 방법으로 리스트를 갱신하는 것과 왜 써야 하는지에 대해 샘플코드로 알아보겠습니다.
DiffUtil이 뭔데?
DiffUtil is a utility class that calculates the difference between two lists and outputs a list of update operations that converts the first list into the second one.
해석하면 두 목록 간의 차이를 계산하고 첫 번째 목록을 두 번째 목록으로 알아서 변환하는 업데이트 작업목록을 출력하는 유틸리티 클래스입니다.
여기서 중요한 부분은 "업데이트 작업목록을 출력한다"입니다.
'notifyItemChanged()도 똑같이 업데이트하는데 쓸 필요 있나?'라고 반박할 수 있습니다.
만들어진 이유
리사이클러뷰에서 뷰 홀더의 데이터를 업데이트하고 싶을 때마다 notifyItem메서드들을 써왔습니다.
notifyItem 메서드들의 단점은 명확합니다.
- 데이터가 변경될 때마다 써줘야 하는 번거로움이 있습니다.
- 데이터가 변경되면 넘겨주는 리스트를 수정하고 다시 리사이클러뷰를 렌더링 하는 과정을 거칩니다.
결국 번거로움 + 구조의 한계 때문에 대안책이 나오게 됐습니다.
public final void notifyItemChanged(int position) {
mObservable.notifyItemRangeChanged(position, 1);
}
public void notifyItemRangeChanged(int positionStart, int itemCount) {
notifyItemRangeChanged(positionStart, itemCount, null);
}
public void notifyItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
// since onItemRangeChanged() is implemented by the app, it could do anything, including
// removing itself from {@link mObservers} - and that could cause problems if
// an iterator is used on the ArrayList {@link mObservers}.
// to avoid such problems, just march thru the list in the reverse order.
for (int i = mObservers.size() - 1; i >= 0; i--) {
mObservers.get(i).onItemRangeChanged(positionStart, itemCount, payload);
}
}
코드를 보면 리스트를 다시 읽어오는 것을 볼 수 있습니다.
코드
뷰 바인딩을 이용한 샘플 코드로 한 번 살펴보겠습니다.
Layout
레이아웃 코드입니다.
main_activity
<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">
<Button
android:id="@+id/addButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="100dp"
android:text="추가"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
app:layout_constraintTop_toBottomOf="@+id/addButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
main_viewholder
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="100dp">
<ImageView
android:id="@+id/vhImage"
android:layout_width="100dp"
android:layout_height="100dp"/>
<TextView
android:id="@+id/vhTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="30sp"
/>
<TextView
android:id="@+id/vhContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
DTO
data class RCDto(
var image: Int,
var title: String,
var content: String
)
MainAdapter
class MainAdapter(val dataset: ArrayList<RCDto>): RecyclerView.Adapter<MainAdapter.MainViewHolder>() {
inner class MainViewHolder(view: View): RecyclerView.ViewHolder(view){
var binding: ViewholderMainBinding
init {
binding = ViewholderMainBinding.bind(view)
}
}
//뷰 그룹의 뷰를 뷰 홀더를 넣어줌
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.viewholder_main, parent, false)
return MainViewHolder(view)
}
//뷰 홀더에 데이터 셋팅
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
//이미지 로드를 위한 라이브러리 사용
Glide.with(holder.itemView.context).load(dataset[position].image).into(holder.binding.vhImage)
holder.binding.vhTitle.text = dataset[position].title
holder.binding.vhContent.text = dataset[position].content
}
override fun getItemCount() = dataset.size
}
어댑터에서 뷰 바인딩으로 뷰를 가져옵니다. 내부 클래스로 뷰 홀더를 선언했습니다.
MainModel
class MainModel(private val cnx: Context, private val binding: ActivityMainBinding) {
private val dataset = ArrayList<RCDto>()
private lateinit var adapter : MainAdapter
fun initAdapter(){
adapter = MainAdapter(dataset)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(cnx)
}
fun setData(){
dataset.add(RCDto(R.drawable.pic1,"1", "배고프다"))
dataset.add(RCDto(R.drawable.pic2,"2", "점심 먹으러 가고싶다"))
dataset.add(RCDto(R.drawable.pic3,"3", "퇴근시켜줘"))
}
}
MainModel 클래스입니다. 데이터 관련된 코드를 넣었습니다.
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var mainModel: MainModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding()
initRecycler()
}
private fun viewBinding(){
binding = ActivityMainBinding.inflate(layoutInflater)
mainModel = MainModel(this, binding)
setContentView(binding.root)
}
private fun initRecycler(){
mainModel.apply {
setData()
initAdapter()
}
}
}
화면에 보여주는 코드입니다.
실행시키면 다음과 같이 나옵니다.
이제 업데이트를 추가하면 됩니다.
기존 notify 메서드
기존 notify메서드를 이용한 업데이트입니다.
private fun addData(){
dataset.add(RCDto(R.drawable.pic4, "4", "밥은 최고야"))
dataset.add(RCDto(R.drawable.pic5, "5", "추가된 레이아웃"))
}
fun onClickEventbyNotfy(){
binding.addButton.setOnClickListener {
if(flag) {
addData()
adapter.notifyItemChanged(3)
flag = false
}
}
}
메인 모델에 리스트에 데이터를 추가하는 메서드와 클릭 시 동작할 메서드를 정의합니다.
private fun setOnClick(){
mainModel.onClickEventbyNotfy()
}
이제 MainActivity에서 onClick이벤트를 세팅해 주면 됩니다.
DiffUtil
DiffUtil에서 사용하는 클래스는 2개입니다.
- DiffUtil.CallBack : 두 목록 간의 차이를 계산하는 동안 DiffUtil에서 사용하는 콜백 클래스입니다.
- DiffUtil.ItemCallBack : 목록에서 null이 아닌 두 항목 간의 차이를 계산하기 위한 콜백입니다.
CallBack은 목록 인덱싱과 항목비교 두 가지 역할을 합니다.
ItemCallback은 이 중 항목비교만 처리하므로 프레젠테이션 계층에서 배열 또는 목록으로 인덱싱하는 코드와 콘텐츠별 차이계산 코드를 분리할 수 있습니다.
각 클래스 별로 가지고 있는 메서드도 간략히 알아봅시다.
DiffUtil.CallBack 메서드
리턴타입 | 이름 | 기능 |
int | getOldListSize() | 원래 리스트의 사이즈를 반환합니다. |
int | getNewListSIze() | 변경될 리스트의 사이즈를 반환합니다. |
Boolean | areItemsTheSame (int oldItemPosition, int newItemPosition) |
두 개체가 동일한 항목을 나타내는지 여부를 결정하기 위해 호출됩니다. |
Boolean | areContentsTheSame (int oldItemPosition, int newItemPosition) |
areItemsTheSame이 true일 때 두 항목의 데이터가 동일한지 여부를 확인하려고 할 때 호출됩니다. |
@Nullable Object | getChangePayload (int oldItemPosition, int newItemPosition) |
areItemsTheSame 메서드가 두 아이템에 대해 true를 반환하고, areContentsTheSame에 대해 false를 리턴하면, DiffUtil은 이 메서드를 호출하여 변경 사항에 대한 페이로드를 가져옵니다. |
DiffUtil.ItemCallBack 메서드
리턴타입 | 이름 | 기능 |
abstract<boolean> | areContentsTheSame (@NonNull T oldItem, @NonNull T newItem) |
areItemsTheSame이 true일 때 두 아이템이 같은 데이터를 가지고 있는지 체크합니다. |
abstract<boolean> | areItemsTheSame (@NonNull T oldItem, @NonNull T newItem) |
두 객체가 같은 아이템을 참조하고 있는지 체크합니다. |
구현
DiffUtil을 쓰기 위해선 CallBack 클래스를 먼저 작성해줘야 합니다.
DiffUtilCallBack
class DiffUtilCallBack(private val mOldItem: List<RCDto>, private val mNewItem: List<RCDto>): DiffUtil.Callback() {
override fun getOldListSize(): Int = mOldItem.size
override fun getNewListSize(): Int = mNewItem.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
mOldItem[oldItemPosition].title == mNewItem[newItemPosition].title
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
mOldItem[oldItemPosition].content == mNewItem[newItemPosition].content
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
return super.getChangePayload(oldItemPosition, newItemPosition)
}
}
DiffUtilItemCallBack과 다르게 자체적인 리스트를 가지고 있지 않으므로 프로퍼티로 DTO리스트를 2개 선언해 줍니다.
비교 메서드에선 타이틀과 콘텐츠를 비교해 줍니다.
Adapter에 추가
//diffUtil
fun updateListItem(dataset: ArrayList<RCDto>){
val diffUtilCallBack = DiffUtilCallBack(this.dataset, dataset)
val diffResult = DiffUtil.calculateDiff(diffUtilCallBack)
this.dataset.clear()
this.dataset.addAll(dataset)
diffResult.dispatchUpdatesTo(this)
}
현재의 DTO리스트와 변경된 DTO리스트를 비교하는 코드입니다. dispatchUpdateTo() 메서드로 현재의 어댑터에 업데이트해 줍니다.
위 코드에선 패러미터로 받은 dataset을 콜백에 넘겨주기만 하고 addAll()을 해주면 변경된 값이 반영됩니다. 이미 DTO리스트 객체가 메모리 상에 올라가 있고 주소 값을 통해 다른 클래스에서 참조하기 때문에 정상적으로 반영됩니다. 이를 Call by Reference(참조에 의한 호출)라고 합니다.
MainModel에 추가
private lateinit var diffAdapter : DiffUtilAdapter
//DiffUtil을 이용한 어댑터
fun initDiffAdapter(){
diffAdapter = DiffUtilAdapter(ArrayList(dataset))
binding.recycler.adapter = diffAdapter
binding.recycler.layoutManager = LinearLayoutManager(cnx)
}
fun onClickEventbyDiffUtil(){
binding.addButton.setOnClickListener {
if(flag) {
addData()
diffAdapter.updateListItem(ArrayList(dataset))
flag = false
}
}
}
여기서 중요한 점!!
adapter에 넘겨주는 DTO리스트와 updateListItem에 넘겨주는 DTO리스트는 반드시 새 리스트여야 합니다. 그냥 dataset을 넘겨주면 같은 dataset을 비교하는 것이므로 반영 안 됩니다. (주소값이 같기 때문)
MainActivity에 추가
private fun initRecycler(){
mainModel.apply {
setData()
initDiffAdapter()
}
}
private fun setOnClick(){
mainModel.onClickEventbyDiffUtil()
}
잘 되는 것을 볼 수 있습니다.
기본 리사이클러뷰와 크게 다르지 않습니다.
어댑터 정의 -> 뷰 홀더 정의(뷰 바인딩) -> 오버라이딩된 메서드 정의(onCreateViewHolder, onBindViewHolder) -> 어댑터 할당
에서 DiffUtilCallBack 정의 -> 업데이트 메서드 정의 가 추가된 게 전부입니다.
다음 게시글에선 ListAdapter을 이용하여 RecyclerView의 목록을 갱신하는 법을 알아보겠습니다. ListAdapter을 사용할 때는 ItemCallBack 클래스로 구현해 보겠습니다.
참고
↑ 전체 코드입니다.
'안드로이드 > RecyclerView' 카테고리의 다른 글
RecyclerView의 notifyItem메서드들의 문제점을 해결해보자! 3탄(ListAdapter) (0) | 2023.03.22 |
---|---|
RecyclerView의 notifyItem메서드들의 문제점을 해결해보자! 2탄(AsyncListDiffer) (0) | 2023.03.21 |
RecyclerViewItemClickEvent (0) | 2022.06.13 |
RecyclerView (0) | 2022.06.13 |