버튼 수집상

[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 1 본문

TIL - 안드로이드

[안드로이드] ListAdapter DiffUtil 제대로 쓰기 - 1

cocokaribou 2023. 7. 13. 12:49

배경

리사이클러뷰에서 부분적으로 UI를 업데이트할 때 (ex: 찜하기)

업데이트한 리스트를 submitList()로 세팅해도 DiffUtil이 제대로 돌아가지 않는 경우가 있었다.

리스트 변경사항을 제대로 감지하는 경우를 알기 위해 샘플 프로젝트를 구성해봤다.

 

샘플 프로젝트 구조

리스트 아이템을 클릭하면 api를 변경된 index를 호출한 뒤 리스트에 반영.

api의 결과로 새 리스트를 뿌리는 구조는 비효율적이라고 판단했다.
참고한 앱도 변경값만 리턴하고 있었다.

 

샘플 데이터 클래스

data class SimpleObject(
    var name : String,
    var isChecked : Boolean = false
)

BaseActivity.kt

abstract class BaseActivity(layout : Int): AppCompatActivity(layout) {
    init {
        observeEvent()
    }

    // 공통함수
    abstract fun observeEvent()
}

EventBus.kt

// 뷰홀더에서 발생한 이벤트를 액티비티 단에서 관리하기 위한 오브젝트
object EventBus {
    private val clickEvent_ = MutableLiveData<ClickEvent>()
    val clickEvent: LiveData<ClickEvent>
        get() = clickEvent_

    fun fire(event: ClickEvent) {
        clickEvent_.postValue(event)
    }
}

class ClickEvent(
    val index: Int,
    val message: String
) {
    override fun toString(): String {
        return "ClickEvent - [${index}] \"${message}\""
    }
}

MainActvity.kt

class MainActivity : BaseActivity(R.layout.actvity_main) {
    // 기존 리스트
    private var originalObjects = List(10) { i -> SimpleObject("item$i") }

    private val rv by lazy { findViewById<RecyclerView>(R.id.list) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        rv.adapter = SimpleAdapter().apply {
            submitList(originalObjects)
        }
    }
    
    override fun observeEvent() {
        EventBus.clickEvent.observe(this) { event ->
            // api 호출해서 결과를 받아왔다고 가정
            (rv.adapter as? SimpleAdapter)?.submitList(updatedList(event.index))
        }
    }

    private fun updatedList(index: Int) : List<SimpleObject> {
        // api 결과값을 리스트 업데이트
        // submitList not working!
        return originalObjects.toList().mapIndexed{ i, it ->
            it.apply { isChecked = i == index }
        }
    }
}

SimpleAdapter.kt

class SimpleAdapter : ListAdapter<SimpleObject, SimpleAdapter.SimpleHolder>(object : DiffUtil.ItemCallback<SimpleObject>() {
    override fun areContentsTheSame(oldItem: SimpleObject, newItem: SimpleObject): Boolean {
        return oldItem == newItem
    }

    override fun areItemsTheSame(oldItem: SimpleObject, newItem: SimpleObject): Boolean {
        return oldItem.name == newItem.name
    }

    // isChecked가 true인 뷰홀더 체크
    override fun getChangePayload(oldItem: SimpleObject, newItem: SimpleObject): Any? {
        return if (oldItem.isChecked == newItem.isChecked) {
            super.getChangePayload(oldItem, newItem)
        } else {
            newItem.isChecked
        }
    }
}) {
    override fun getItemViewType(position: Int) = position

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleHolder =
        SimpleHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_grid, parent, false))

    override fun onBindViewHolder(holder: SimpleHolder, position: Int) {
        holder.itemView.findViewById<TextView>(R.id.title).text = currentList[position].name
        holder.itemView.setOnClickListener {
            // 클릭 이벤트 (api 호출)
            EventBus.fire(ClickEvent(position, "isClicked"))
        }
    }

    override fun onBindViewHolder(holder: SimpleHolder, position: Int, payloads: MutableList<Any>) {
        if (payloads.isEmpty()) {
            super.onBindViewHolder(holder, position, payloads)
        } else {
            (payloads[0] as? Boolean)?.let {
                holder.turnOn(it)
            } ?: run {
                super.onBindViewHolder(holder, position, payloads)
            }
        }
    }

    class SimpleHolder(private val view: View) : RecyclerView.ViewHolder(view) {
        // isChecked 일 때 이미지뷰에 빨간색 컬러필터 적용
        fun turnOn(isChecked: Boolean) {
            val bgColor = if (isChecked) Color.parseColor("#30ff0000") else Color.parseColor("#00ff0000")
            view.findViewById<ImageView>(R.id.iv).setColorFilter(bgColor)
        }
    }
}

 

현재 이 코드로는 리스트가 제대로 변경되지 않는다.

private fun updatedList(index: Int) : List<SimpleObject> {
    // api 결과값을 리스트에 업데이트
    // submitList not working!
    return originalObjects.toList().mapIndexed{ i, it ->
        it.apply { isChecked = i == index }
    }
}

toList()를 호출하면 별개의 리스트가 생성되는 줄 알았는데,

리스트 요소 객체가 바뀌지 않으면 리스트의 주소가 달라지지 않는다.

두 리스트의 주소가 같기 때문에

복사본의 map 블럭 안에서 요소 객체에 apply 를 적용하면 원본의 요소도 바뀐다!

 

두 리스트를 비교하기 위해 equals() 함수를 호출.

private fun updatedList(index: Int) : List<SimpleObject> {
    // api 결과값을 리스트에 업데이트
    // submitList not working!
    val copiedObjects = originalObjects.toList().mapIndexed{ i, it -> 
        if (i == index) it.apply { isChecked = true } 
        else it 
    }

    // 리스트 객체 비교
    Log.v("check", "${originalObjects.equals(copiedObjects)}") // true

    // zip - 두 리스트 요소를 pair 로 묶어서 1차원 리스트로 만들어줌
    val changedIndex = copiedObjects.zip(originalObjects).mapIndexedNotNull { i, (item1, item2) ->
        // 리스트 요소 객체 비교
        if (item1.equals(item2)) null else i
    }
    Log.v("check", "$changedIndex") // []

    return copiedObjects
}

 

객체의 필드를 수정하는 게 아니라 객체 자체를 갈아치워야 한다.

map 블럭 안에서 객체를 새로 생성하면 리스트의 주소가 달라지므로 toList() 를 다시 호출할 필요가 없다.

private fun updatedList(index: Int) : List<SimpleObject> {
    // api 결과값을 리스트에 업데이트
    val copiedObjects = originalObjects.mapIndexed{ i, it ->
    	SimpleObject(
            name = it.name,
            isChecked = i == index
        )
    }
    
    // 리스트 객체 비교
    Log.v("check", originalObjects.equals(copiedObjects)) // false
    
    return copiedObjects
}

코틀린 data class가 기본으로 제공하는 copy() 함수를 써도 똑같다.

private fun updatedList(index: Int) : List<SimpleObject> {
    // api 결과값을 리스트에 업데이트
    return originalObjects.mapIndexed{ i, it ->
        it.copy().apply { isChecked = it == index }
    }
}

 

그러면 UI 업데이트가 잘 되는 것을 확인할 수 있다.

리스트 변경사항 체크 코드로 돌려보기

 

만약 클릭이벤트를 누적시키고 싶으면

업데이트된 리스트를 원본 리스트 변수에 할당한다.

private fun updatedList(index: Int) : List<SimpleObject> {
    // api 결과값을 리스트에 업데이트
    val copiedObjects = originalObjects.toList().mapIndexed{ i, it ->
        SimpleObject(
            name = it.name,
            isChecked = if (i == index) !it.isChecked else it.isChecked // on/off
        )
    }

    originalObjects = copiedObjects
    return copiedObjects
}

그러면 이전 클릭 기록을 남길 수 있다.

2편에서 DiffUtil.ItemCallback의 함수들을 자세히 살펴보겠다.

 

 

참고

 

ListAdapter, DiffUtil

ListAdapter는 RecyclerView.Adapter의 확장기능으로 리스트내에 노출할 아이템의 변경 여부를 백그라운드 쓰레드에서 판단할 수 있는 기능을 제공한다. 생성자에는 DiffUtil.ItemCallback의 구체 클래스를 넘

selfish-developer.com

 

728x90