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

이번 포스팅에서는 RecyclerView에 ListAdapter를 적용하는 법에 대해 알아보도록 하겠습니다.

# 들어가기

Recyclerview의 데이터가 변하면 Recyclerview Adapter가 제공하는 notifyItem 메소드를 사용해서 ViewHolder 내용을 갱신할 수 있습니다.

1
2
3
4
5
6
notifyItemChanged(int)
notifyItemInserted(int)
notifyItemRemoved(int)
notifyItemRangeChanged(int, int)
notifyItemRangeInserted(int, int)
notifyItemRangeRemoved(int, int)

그런데 데이터가 변경되는 방식을 확인하고 그때마다 이렇게 notify를 일일이 해 주는것은 번거롭기도 하고, 또 사용하기에 따라서는 갱신이 필요없는 ViewHolder를 같이 갱신하는 불필요한 작업이 생길수도 있습니다.

# DiffUtil

DiffUtil은 두 데이터셋을 받아서 그 차이를 계산해주는 클래스입니다. DiffUtil을 사용하면 두 데이터 셋을 비교한 뒤 그중 변한부분만을 파악하여 Recyclerview에 반영할 수 있습니다.

DiffUtil은 Eugene W. Myers의 difference 알고리즘을 이용해서 O(N + D^2)시간 안에 리스트의 비교를 수행하는데 넥서스 5X에서 테스트를 수행한 결과는 다음과 같았다고 합니다. 이때 N은 추가 및 제거된 항목의 갯수이고, D는 스크립트의 길이입니다.

1
2
3
4
5
6
7
100 items and 10 modifications: avg: 0.39 ms, median: 0.35 ms
100 items and 100 modifications: 3.82 ms, median: 3.75 ms
100 items and 100 modifications without moves: 2.09 ms, median: 2.06 ms
1000 items and 50 modifications: avg: 4.67 ms, median: 4.59 ms
1000 items and 50 modifications without moves: avg: 3.59 ms, median: 3.50 ms
1000 items and 200 modifications: 27.07 ms, median: 26.92 ms
1000 items and 200 modifications without moves: 13.54 ms, median: 13.36 ms 

DiffUtil을 사용하기 위해서는 DiffUtil.Callback()을 상속받아 areItemsTheSame으로 비교대상인 두 객체가 동일한지 확인하고, areContentsTheSame으로 두 아이템이 동일한 데이터를 가지는지 확인하면 됩니다.

# AsyncListDiffer

DiffUtil은 아이템 개수가 많을 경우 비교연산에 필요한 시간이 길어질 수 있기 때문에 백그라운드 스레드에서 처리되어야 합니다. AsyncListDiffer는 DiffUtil을 편하게 쓰기 위해서 만들어진 클래스로, DiffUtil에 대해 자체적으로 스레드 처리를 해 줍니다.

코드를 사용하기 위해서는 우선 어댑터 내부로 DiffUtil 콜백을 전달받은 AsyncListDiffer 객체를 만들어 줍니다. 그리고 currentList로 데이터를 참조하고 submitList 로 리스트 데이터를 갱신하면 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Google Developers에서 제공하는 코드
class UserAdapter extends RecyclerView.Adapter<UserViewHolder> {
    private final AsyncListDiffer<User> mDiffer = new AsyncListDiffer(this, DIFF_CALLBACK);
    @Override
    public int getItemCount() {
        return mDiffer.getCurrentList().size();
    }
    public void submitList(List<User> list) {
        mDiffer.submitList(list);
    }
    @Override
    public void onBindViewHolder(UserViewHolder holder, int position) {
        User user = mDiffer.getCurrentList().get(position);
        holder.bindTo(user);
    }
    public static final DiffUtil.ItemCallback<User> DIFF_CALLBACK
            = new DiffUtil.ItemCallback<User>() {
        @Override
        public boolean areItemsTheSame(
                @NonNull User oldUser, @NonNull User newUser) {
            // User properties may have changed if reloaded from the DB, but ID is fixed
            return oldUser.getId() == newUser.getId();
        }
        @Override
        public boolean areContentsTheSame(
                @NonNull User oldUser, @NonNull User newUser) {
            // NOTE: if you use equals, your object must properly override Object#equals()
            // Incorrectly returning false here will result in too many animations.
            return oldUser.equals(newUser);
        }
    }
}

# ListAdapter

ListAdapter는 AsyncListDiffer를 더 쓰기 편하도록 랩핑한 클래스로 Recyclerview 어댑터를 만들때 ListAdapter를 상속하도록 하면 됩니다. 초기화할때 DiffUtil 콜백 객체를 받도록 하면 나머지는 AsyncListDiffer와 같이 currentList로 현재 데이터를 불러올 수 있고, submitList로 데이터를 갱신할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Google Developers에서 제공하는 코드
class UserAdapter extends ListAdapter<User, UserViewHolder> {
    public UserAdapter() {
        super(User.DIFF_CALLBACK);
    }
    @Override
    public void onBindViewHolder(UserViewHolder holder, int position) {
        holder.bindTo(getItem(position));
    }
    public static final DiffUtil.ItemCallback<User> DIFF_CALLBACK =
            new DiffUtil.ItemCallback<User>() {
        @Override
        public boolean areItemsTheSame(
                @NonNull User oldUser, @NonNull User newUser) {
            // User properties may have changed if reloaded from the DB, but ID is fixed
            return oldUser.getId() == newUser.getId();
        }
        @Override
        public boolean areContentsTheSame(
                @NonNull User oldUser, @NonNull User newUser) {
            // NOTE: if you use equals, your object must properly override Object#equals()
            // Incorrectly returning false here will result in too many animations.
            return oldUser.equals(newUser);
        }
    }
}

다시말해 Recyclerview 어댑터를 ListAdapter로 구현하면 데이터가 어떻게 바뀌든간에 submitList로 전체 리스트를 넘겨주기만 하면 어댑터가 알아서 백그라운드 스레드를 사용해 리스트 차이를 계산하여 화면을 갱신시켜주게 됩니다.

# 앱 작성

여기서는 Data class 이해하고 RecyclerView에서 사용하기에서 작성했던 프로젝트를 고치면서 ListAdapter를 구현해 보도록 하겠습니다.

# Monster.kt

우선 Recyclerview에 표시할 데이터를 Data class 형태로 준비합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
data class Monster(
    val name: String,
    val race: Race,
    val level: Int,
    val stats: List<Int>,
    val encount: Boolean
)

enum class Race {
    Zombie, Human, Goblin, Dragon
}

# view_itemlist.xml

Recyclerview의 ViewHolder 레이아웃을 만들어줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/vhLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?attr/selectableItemBackground"
    android:baselineAligned="false"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="10dp">

    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="9"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tv_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="3dp"
            android:text="Monster name"
            android:textSize="24sp" />

        <TextView
            android:id="@+id/tv_race"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Race"
            android:textSize="18sp" />

        <TextView
            android:id="@+id/tv_level"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Level"
            android:textSize="18sp" />

        <TextView
            android:id="@+id/tv_stats"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Stats"
            android:textSize="18sp" />

        <TextView
            android:id="@+id/tv_encount"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Encount"
            android:textSize="18sp" />
    </LinearLayout>

    <ImageView
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:src="@drawable/ic_baseline_reorder_24" />

</LinearLayout>

# MyViewHolder.kt

Recyclerview의 ViewHolder 클래스를 만들어줍니다. View Binding을 사용하도록 세팅하고 나중에 투명도를 바꾸는데 사용하도록 setAlpha 함수를 만들어줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class MyViewHolder(private val binding: ViewItemlistBinding) :

    RecyclerView.ViewHolder(binding.root) {
    fun bind(data: Monster) {
        with(binding) {
            tvName.text = "Name: ${data.name}"
            tvRace.text = "Race: ${data.race}"
            tvLevel.text = "Level: ${data.level}"
            tvStats.text = "HP: ${data.stats[0]} / MP: ${data.stats[1]} / Exp: ${data.stats[2]}"
            tvEncount.text = "Encounted: ${data.encount}"

            vhLayout.setOnClickListener {
                Snackbar.make(it, "Item $layoutPosition touched!", Snackbar.LENGTH_SHORT).show()
            }
        }
    }

    fun setAlpha(alpha: Float) {
        with(binding) {
            tvName.alpha = alpha
            tvRace.alpha = alpha
            tvLevel.alpha = alpha
            tvStats.alpha = alpha
            tvEncount.alpha = alpha
        }
    }
}

# MyDiffCallback.kt

코틀린에서는 Equality를 다음과 같이 두가지 방법으로 판정할 수 있습니다.

  • Structural equality (==- a check for equals())

  • Referential equality (===- two references point to the same object)

areItemsTheSame에서 비교대상인 두 객체가 Referential equality를 갖는지 판정하는데에는 ===를 이용하였습니다. 그리고 areContentsTheSame 에서 두 객체의 아이템이 Structural equality를 갖는지 판정하는데에는 equals를 사용했습니다. Monster데이터클래스이기 때문에 데이터클래스의 정의에 따라 ==을 equals 대신 사용할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyDiffCallback : DiffUtil.ItemCallback<Monster>() {

    override fun areItemsTheSame(oldItem: Monster, newItem: Monster): Boolean {
        return oldItem === newItem
    }

    override fun areContentsTheSame(oldItem: Monster, newItem: Monster): Boolean {
        return oldItem == newItem
    }
}

# MyListAdapter.kt

ListAdapter를 상속받는 Recyclerview Adapter를 작성합니다. onCreateViewHolder에서 뷰바인딩을 사용하도록 설정합니다. 데이터의 개수는 ListAdapter가 관리하므로 기존의 Recyclerview 어댑터에서 정의했던 getItemCount는 오버라이드하지 않아도 됩니다.

그리고 아이템을 이동했을때 실행할 moveItem, 지울때 실행할 removeItem 메소드를 작성해줍니다. 이 때 ListAdapter에서는 데이터를 직접 조작할 수 없고 비교만 할수 있기 때문에 변형된 데이터셋은 새로운 객체로 만들어 기존의 데이터셋과 비교도록 submitList를 수행하게 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class MyListAdapter : ListAdapter<Monster, RecyclerView.ViewHolder>(MyDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val viewHolder = MyViewHolder(
            ViewItemlistBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
        return viewHolder
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (holder is MyViewHolder) {
            val monster = getItem(position) as Monster
            holder.bind(monster)
        }
    }

    fun moveItem(fromPosition: Int, toPosition: Int) {
        val newList = currentList.toMutableList()
        // val item = newList.removeAt(fromPosition)
        // newList.add(toPosition, item)
        Collections.swap(newList, fromPosition, toPosition)
        submitList(newList)
    }

    fun removeItem(position: Int) {
        val newList = currentList.toMutableList()
        newList.removeAt(position)
        submitList(newList)
    }
}

# MyItemTouchHelperCallback.kt

기존 프로젝트에서 터치 콜백을 받기 위해 MainActivity에서 사용했던 ItemTouchHelper.SimpleCallback 클래스는 코드의 가독성을 위해 개별 클래스로 분리시키겠습니다.

그리고 홀딩중인 ViewHolder는 투명하게 변화시켜 더 인지하기 쉽게 만들겠습니다. 그럴려면 onSelectedChanged에서 투명도를 50%로 낮추었다가 clearView에서 다시 100%로 되돌리도록 하면 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class MyItemTouchHelperCallback(private val recyclerView: RecyclerView) :
    ItemTouchHelper.SimpleCallback(
        ItemTouchHelper.UP or ItemTouchHelper.DOWN,
        ItemTouchHelper.LEFT
    ) {
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        (recyclerView.adapter as MyListAdapter).moveItem(
            viewHolder.absoluteAdapterPosition,
            target.absoluteAdapterPosition
        )
        return true
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        (recyclerView.adapter as MyListAdapter).removeItem(viewHolder.layoutPosition)
    }

    // 홀딩중인 ViewHolder 투명도를 변경
    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
        super.onSelectedChanged(viewHolder, actionState)
        when (actionState) {
            ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.ACTION_STATE_SWIPE -> {
                (viewHolder as MyViewHolder).setAlpha(0.5f)
            }
        }
    }

    // 홀딩중인 ViewHolder 투명도를 되돌림
    override fun clearView(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ) {
        super.clearView(recyclerView, viewHolder)
        (viewHolder as MyViewHolder).setAlpha(1.0f)
    }
}

# activity_main.xml

Recyclerview와 floating action button을 배치해줍니다. DiffUtil 처리를 테스트하기 위해 플로팅버튼을 누르면 데이터가 랜덤하게 뒤섞이는 기능이 수행되도록 하겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@layout/view_itemlist" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="20dp"
        android:src="@drawable/ic_baseline_add_24"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

# MainActivity.kt

데이터셋에 초기값을 넣어주고, 어댑터 인스턴스를 생성하여 Recyclerview와 연동시킵니다. 그리고 터치헬퍼를 달아 스와이프와 드래그를 했을때 데이터가 잘 갱신되는지 확인합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }
    private val myListAdapter: MyListAdapter by lazy {
        MyListAdapter()
    }

    private val dataSet = arrayListOf<Monster>().apply {
        add(Monster("타일런트", Race.Zombie, 10, listOf(100, 10, 50), false))
        add(Monster("조커", Race.Human, 23, listOf(200, 20, 100), false))
        add(Monster("그렘린", Race.Goblin, 2, listOf(10, 1, 5), true))
        add(Monster("리오레우스", Race.Dragon, 2500, listOf(10000, 1000, 50000), false))
        add(Monster("사우론", Race.Human, 100, listOf(1000, 200, 1000), false))
        add(Monster("리바이어던", Race.Dragon, 50, listOf(2000, 250, 10000), true))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)


        binding.recyclerview.apply {
            layoutManager =
                LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
            addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
            adapter = myListAdapter
        }
        myListAdapter.submitList(dataSet)

        binding.fab.setOnClickListener {
            myListAdapter.submitList(dataSet.shuffled())
        }

        val itemTouchHelper = ItemTouchHelper(MyItemTouchHelperCallback(binding.recyclerview))
        itemTouchHelper.attachToRecyclerView(binding.recyclerview)
    }
}

이렇게해서 ListAdapter를 RecyclerView에 적용하는 법에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy