본문 바로가기

코딩/안드로이드

Android DiffUtil

리사이클러뷰를 쓰다가 아이템의 구성이 바뀔때,

"아이템이 어디가 바뀌었지... 아... 몰라 notifyDataSetChanged!"

라는 생각을 하며 notifyDataSetChanged()를 호출하곤 해버린다.

 

물론 좋지 않은 일이지만.. 어쩔수 없다. 나는 멍청이니까.

 

하지만 이런 멍청한 코딩이 꽤 많았는지, 안드로이드에는 이러한 걸 도와주는 유틸이 있다.

DiffUtil이라고 해서, 리사이클러뷰의 데이터 셋이 어떻게 바뀌었는지를 파악하고 그러한 변화를 리사이클러뷰에 던져줌으로써

프로그래머가 데이터 셋의 어느 부분이 구체적으로 변하였는지를 신경 쓰지 않게 도와준다.

 

자세한 내용은 뒤로 미루고, 어떤 결과가 나오는지, 어떻게 쓰는지부터 살펴보자.

 

이 움짤은, 5*5의 그리드 뷰에서 단순히 셔플을 하고 DiffUtill에 던져주는 단순한 코딩으로 얻을 수 있는 움짤이다.

그럼, 구현해보자.

전체 코드는 항상 그랬듯이 깃헙에 있다 : https://github.com/5seunghoon/Diff_Util_Example/

 

 


 

먼저 리사이클러뷰에 들어갈 아이템을 구현하자.

 

간단하게, 각각의 아이템을 Tile이라고 하고 아래와 같이 데이터클래스를 만든다.

data class Tile(val number: Int) {
    val color: Int =
        if (number < 100) Color.BLACK + (256 * 256 * 256 * number * (100 - number) / 25) / (100)
        else Color.WHITE


    override fun equals(other: Any?): Boolean {
        (other as? Tile)?.let {
            return this.number == it.number
        } ?: return super.equals(other)
    }
}

number는 타일에 나타날 숫자이고 color는 타일의 배경색이 될텐데, 이것은 아무거나 하여도 된다.

필자는 number에 따라 color가 지정되게 대충.. 구현했다. 중요하지 않은 부분이니 넘어가겠다.

 

먼저 equals를 구현해준다. 

밑에 적겠지만, DiffUtil은 각 아이템이 같은지, 다른지에 대한 정보를 알아야 한다. 

따라서 equals를 구현하여 이것을 이용할 것이다.

여기서는 number가 같으면 같은 아이템이 되게 하였다.

 

 

다음, 리사이클러뷰가 가질 어뎁터를 구현하자.

class MainRecyclerViewAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    private val tileAdapterModel = TileAdapterModel(this)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = TileViewHolder(parent)

    override fun getItemCount(): Int = tileAdapterModel.size()

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        (holder as? TileViewHolder)?.onBind(tileAdapterModel.get(position))
    }

    fun shuffle() {
        tileAdapterModel.shuffle()
    }

    fun eraseOneTile() {
        tileAdapterModel.eraseOneTile()
    }

    fun addOneTile() {
        tileAdapterModel.addOneTile()
    }

    fun eraseThreeTile() {
        tileAdapterModel.eraseThreeTile()
    }

    fun addThreeTile() {
        tileAdapterModel.addThreeTile()
    }

    inner class TileViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
    	//Tile이 가질 레이아웃을 inflate해서 생성자로 넘겨줌
        LayoutInflater.from(parent.context).inflate(R.layout.tile_item, parent, false)
    ) {
        fun onBind(tile: Tile) {
            itemView.run {
                number_text_view.text = tile.number.toString()
                background_view.setBackgroundColor(tile.color)
            }
        }
    }
}

평범한 어뎁터와 같다. 여기서는 중요한게 없으니 넘어가겠다.

 

아, 여기서 어뎁터는 어뎁터모델을 가진다. 중요한 로직들과 타일들을 어뎁터모델로 넘겨두었다.

 

어뎁터 모델은 아래와 같이 구현한다.

 

class TileAdapterModel(val adpater: RecyclerView.Adapter<RecyclerView.ViewHolder>) {
    companion object {
        const val TILE_SIZE = 25
    }

    private val tiles = mutableListOf<Tile>()

    init {
        tiles.clear()
        (1..TILE_SIZE).forEach {
            tiles.add(Tile(it))
        }
    }

    fun size(): Int = tiles.size

    fun get(position: Int) = tiles[position]

    private fun calcDiff(newTiles: MutableList<Tile>) {
        val tileDiffUtilCallback = TileDiffUtilCallback(tiles, newTiles)
        val diffResult: DiffUtil.DiffResult = DiffUtil.calculateDiff(tileDiffUtilCallback)
        diffResult.dispatchUpdatesTo(adpater)
    }

    private fun setNewTiles(newTiles: MutableList<Tile>) {
        tiles.clear()
        tiles.addAll(newTiles)
    }

    fun shuffle() {
        val newTiles = mutableListOf<Tile>()
        newTiles.addAll(tiles)
        newTiles.shuffle()

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

    fun eraseOneTile() {
        val newTiles = mutableListOf<Tile>()
        if (tiles.size >= 1) {
            val erasedRandomIndex = (Random.nextDouble() * tiles.size).toInt()
            tiles.forEachIndexed { index, tile ->
                if (index != erasedRandomIndex) newTiles.add(tile)
            }
        }

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

    fun addOneTile() {
        val newTiles = mutableListOf<Tile>()
        newTiles.addAll(tiles)
        val insertRandomIndex = (Random.nextDouble() * tiles.size).toInt()
        newTiles.add(insertRandomIndex, Tile(tiles.size + 1))

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

    fun eraseThreeTile() {
        val newTiles = mutableListOf<Tile>()
        newTiles.addAll(tiles)
        if (tiles.size >= 3) {
            repeat(3) {
                val erasedRandomIndex = (Random.nextDouble() * newTiles.size).toInt()
                newTiles.removeAt(erasedRandomIndex)
            }
        }

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

    fun addThreeTile() {
        val newTiles = mutableListOf<Tile>()
        newTiles.addAll(tiles)
        repeat(3) {
            val insertRandomIndex = (Random.nextDouble() * newTiles.size).toInt()
            newTiles.add(insertRandomIndex, Tile(newTiles.size + 1))
        }

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

    inner class TileDiffUtilCallback(
        private var oldTiles: MutableList<Tile>,
        private var newTiles: MutableList<Tile>
    ) : DiffUtil.Callback() {
        override fun getOldListSize(): Int = oldTiles.size
        override fun getNewListSize(): Int = newTiles.size
        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldTiles[oldItemPosition] == newTiles[newItemPosition]
        }

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return areItemsTheSame(oldItemPosition, newItemPosition)
        }
    }
}

여기가 중요하다.

 

쓸모없는 함수가 많아 너무 기니까, 중요한 부분만 축약하겠다.

 

    private val tiles = mutableListOf<Tile>()

    init {
        tiles.clear()
        (1..TILE_SIZE).forEach {
            tiles.add(Tile(it))
        }
    }

타일들의 리스트를 초기화하는 부분이다. TILE_SIZE만큼 tiles에 넣어준다.

 

 

    fun shuffle() {
        val newTiles = mutableListOf<Tile>()
        newTiles.addAll(tiles)
        newTiles.shuffle()

        calcDiff(newTiles)
        setNewTiles(newTiles)
    }

타일들을 셔플하는 함수이다. 

 

우리가 흔히 느끼기에는, 기존의 tiles를 그냥 셔플하면 될것이라고 생각하는데, DiffUtil을 이용할 때는 그렇게 하면 안된다.

DiffUtil에 이전의 데이터셋과 변경 후의 데이터셋을 넘겨줘야 하기 때문이다.

 

그래서 새로운 newTiles를 만들고, tiles를 전체 복사해준 뒤, newTiles를 셔플한다.

 

그후, calcDiff함수로 newTiles를 넘겨준다.

 

setNewTiles는 tiles를 newTiles로 바꾸는 작업을 한다.

 

 

그럼 calcDiff함수를 설명하겠다.

    private fun calcDiff(newTiles: MutableList<Tile>) {
        val tileDiffUtilCallback = TileDiffUtilCallback(tiles, newTiles)
        val diffResult: DiffUtil.DiffResult = DiffUtil.calculateDiff(tileDiffUtilCallback)
        diffResult.dispatchUpdatesTo(adpater)
    }

TileDiffUtilCallBack 이라는 클래스를 만들고, 그 클래스를 인자로 하여 DiffUtil.calculateDiff를 호출한다.

그 후 dispatchUpdateTo를 이용하여 리사이클러뷰 어뎁터에 결과를 넘겨주는 일을 한다.

 

그렇다면 TileDiffUtilCallBack이라는 클래스가 대부분의 일을 한다는 느낌인데, 그럼 그 클래스를 살펴보자.

 

    inner class TileDiffUtilCallback(
        private var oldTiles: MutableList<Tile>,
        private var newTiles: MutableList<Tile>
    ) : DiffUtil.Callback() {
        override fun getOldListSize(): Int = oldTiles.size
        override fun getNewListSize(): Int = newTiles.size
        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldTiles[oldItemPosition] == newTiles[newItemPosition]
        }

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return areItemsTheSame(oldItemPosition, newItemPosition)
        }
    }

먼저 생성자로 oldTile과 newTile을 받는다.

 

그 후 DiffUtil.Callback을 상속받아 몇가지 함수를 구현한다.

 

1. getOldListSize : 말 그대로 변화하기 전의 데이터셋의 사이즈이다

2. getNewListSize : 변화 후의 데이터셋 사이즈.

3. areItemsTheSame : 두 아이템이 같으냐? 라는 뜻이다. 아까 구현한 Tiles.equals를 이용하자.

4. areContentsTheSame : 두 콘텐츠가 같으냐? 라는 뜻이다. areItemTheSame이 true일 때 호출된다.

즉, "아이템이 같아? 그럼 콘텐츠까지 완전히 같아?"라고 물어보는 것이다. 지금과 같은 상황에서는 areItemsTheSame과 같은 의미이므로 areItemsTheSame의 결과 값을 리턴해준다

5. getChangePayload : 여기에 나와있지 않지만, 이 함수 또한 오버라이드할 수 있다. 이 함수는 아이템이 같은데 콘텐츠가 다를 때(즉, areItemsTheSame은 true인데 areContentsTheSame이 false일 때), 변경 내용에 대한 Payload를 가져온다. 기본적으로 null이다. 

 

이 중, 5번 함수는 구현하지 않아도 된다. 1~4번은 abstract이므로 구현해야 한다.

 

보면 매우 간단하다. 단순히 Tiles.equals를 이용하고 있다.

 

그 후 이 클래스를 이용하여 위에 적힌 calcDiff함수에 나와있듯이 하면 된다.

 

 

끝이다.

 

요약하자면, DiffUtil.CallBack을 만들어주고, 거기에 oldList랑 newList를 넘겨주고, dispatchUpdateTo를 호출하면 되는 것이다.

 

 

 

마지막으로 DiffUtil은  Eugene W. Myers's diff algorithm을 이용한다고 한다. 

시간복잡도가 낮지는 않으므로 스레드로 돌리는 것을 추천한다.

상세한건 나같은 멍청이에겐 어렵다. 다음에 기회가 되면 살펴보도록 하고 아이템 추가, 삭제에 대한 움짤을 마저 올리고 글을 끝내겠다.

 

 

1개 아이템 추가 * 4번
1개 아이템 삭제 * 4번
3개 아이템 삭제 3번, 3개 아이템 추가 3번