리사클러뷰에 아이템 하나만 선택되게 하는 뷰를 ListAdapter로 구현해보자.

DIffUtil이 바뀐 데이터만 알아서 업데이트해주는 ListAdapter을 사용하면 그냥 리사이클러뷰를 사용했을 때 보다 성능이 개선될 수 있겠다고 생각하고 해당 뷰를 ListAdapter로 수정해보려했다.

//activity_main.xml

<?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=".recyclerview.MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:itemCount="30"
        tools:listitem="@layout/item_book" />

</androidx.constraintlayout.widget.ConstraintLayout>
//item_book.xml

<?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="wrap_content">

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/image_view_item"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:background="@color/teal_200"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toStartOf="@id/text_view_item"
        app:layout_constraintBottom_toBottomOf="parent"
        tools:src="@drawable/ic_launcher_foreground" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/text_view_item"
        android:background="@color/black"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="10dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/image_view_item"
        tools:text="Test" />

</androidx.constraintlayout.widget.ConstraintLayout>
data class Book(
    val id: Int,
    val name: String,
    val image: Drawable,
    var isSelected : Boolean=false,
)

submitList가 실행되지 않음

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val dummyList by lazy { getDummyBookList() }
    private lateinit var adapter: RecyclerAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)
       // binding.recyclerView.setHasFixedSize(true)
        adapter = RecyclerAdapter()

        fun onClickListener(book: Book) {

            ***dummyList.forEach {
                if (book.id == it.id) {
                    it.isSelected = it.isSelected.not() // 선택된 아이템의 isSelected를 변경한다.
                } else {
                    it.isSelected = false //선택 되지 않은 아이템은 isSelected을 false로 바꾼다.
                }
            }***

            ***adapter.submitList(dummyList) //dummyList를 다시 submitList한다.***
						Log.d("LOGGING", "submitList done")
        }

        adapter.also {
            it.setOnClickListener(::onClickListener)
       //     it.setHasStableIds(true)
            it.submitList(dummyList)
        }
        binding.recyclerView.adapter = adapter

    }

    private fun getDummyBookList(): MutableList<Book> {
        val list = mutableListOf<Book>()
        for (i in 0..30) {
            val book = Book(
                i,
                "android${i + 1}",
                image = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)!!
            )
            list.add(book)
        }
        return list
    }
}
class RecyclerAdapter :
    androidx.recyclerview.widget.ListAdapter<Book, RecyclerAdapter.BookViewHolder>(DIFF_UTIL) {

    private lateinit var clickListener: (Book) -> Unit

    fun setOnClickListener(listener: (Book) -> Unit) {
        clickListener = listener
    }

//    override fun getItemId(position: Int): Long {
//        return position.toLong()
//    }

    inner class BookViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root), MyViewHolder {
        init {
            itemView.setOnClickListener {
                clickListener(currentList[bindingAdapterPosition])
            }
        }

        override fun bind() {
            val item = currentList[bindingAdapterPosition]

            if (item.isSelected) binding.textViewItem.setBackgroundColor(Color.YELLOW)
            else binding.textViewItem.setBackgroundColor(Color.BLACK)
            binding.imageViewItem.setImageDrawable(item.image)
            binding.textViewItem.text = item.name
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = ItemBookBinding.inflate(inflater, parent, false)
        return BookViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
        (holder as? MyViewHolder)?.bind()
    }

    companion object {
        val DIFF_UTIL = object : DiffUtil.ItemCallback<Book>() {
            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d("LOGGING", "areItemsTheSame : ${oldItem.id} ${oldItem === newItem}")
                return oldItem === newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d(
                    "LOGGING",
                    "areContentsTheSame : ${oldItem.id}, ${oldItem==newItem}"
                )
                return oldItem == newItem
            }
        }
    }
}

interface MyViewHolder {
    fun bind()
}

처음에 짠 코드는 단순히 ‘아이템이 클릭 될 때 마다 원래 있던 리스트를 순회하면서 요소의 isSelected만 바꿔준 후 그 리스트를 다시 어댑터에 submitList’ 하는 것 이였다. 하지만 이렇게 하면 submitList가 되지 않는다. 로그를 보면 DIFF_UTIL 의 areItemsTheSame과 areContentsTheSame는 타지 않는 다는 것을 알 수 있다.

스크린샷 2023-01-16 오후 2.23.54.png

그 이유는 AsyncListDiffer.java 의 submitList를 보면 알 수 있는데, newList와 oldList의 주소값이 같으면 더 이상 진행하지 않고 그냥 return 시켜버린다. 지금 코드는, dummyList 내부 아이템의 필드만 변경한것이지 dummyList자체는 변경된 것이 없고 똑같은 주소값을 참조하기 때문에 submitList가 그냥 return 되면서 제대로 작동이 안하는 것이다.

Screen_Recording_20230116_141724_My Application.mp4

스크린샷 2023-01-16 오후 2.05.31.png

그렇다면 새로운 리스트를 submitList 하면 제대로 작동하나

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val dummyList by lazy { getDummyBookList() }
    private lateinit var adapter: RecyclerAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)
      //  binding.recyclerView.setHasFixedSize(true)
        adapter = RecyclerAdapter()

        fun onClickListener(book: Book) {

            dummyList.forEach {
                if (book.id == it.id) {
                    it.isSelected = it.isSelected.not() // 선택된 아이템의 isSelected를 변경한다.
                } else {
                    it.isSelected = false //선택 되지 않은 아이템은 isSelected을 false로 바꾼다.
                }
            }

            ***adapter.submitList(dummyList.toList())*** //dummyList의 새로운 인스턴스를 만들어서 submitList한다.
            Log.d("LOGGING", "submitList done")
        }

        adapter.also {
            it.setOnClickListener(::onClickListener)
          //  it.setHasStableIds(true)
            it.submitList(dummyList)
        }
        binding.recyclerView.adapter = adapter

    }

    private fun getDummyBookList(): MutableList<Book> {
        val list = mutableListOf<Book>()
        for (i in 0..30) {
            val book = Book(
                i,
                "android${i + 1}",
                image = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)!!
            )
            list.add(book)
        }
        return list
    }
}
class RecyclerAdapter :
    androidx.recyclerview.widget.ListAdapter<Book, RecyclerAdapter.BookViewHolder>(DIFF_UTIL) {

    private lateinit var clickListener: (Book) -> Unit

    fun setOnClickListener(listener: (Book) -> Unit) {
        clickListener = listener
    }

//    override fun getItemId(position: Int): Long {
//        return position.toLong()
//    }

    inner class BookViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root), MyViewHolder {
        init {
            itemView.setOnClickListener {
                clickListener(currentList[bindingAdapterPosition])
            }
        }

        override fun bind() {
            val item = currentList[bindingAdapterPosition]

            if (item.isSelected) binding.textViewItem.setBackgroundColor(Color.YELLOW)
            else binding.textViewItem.setBackgroundColor(Color.BLACK)
            binding.imageViewItem.setImageDrawable(item.image)
            binding.textViewItem.text = item.name
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = ItemBookBinding.inflate(inflater, parent, false)
        return BookViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
        (holder as? MyViewHolder)?.bind()
    }

    companion object {
        val DIFF_UTIL = object : DiffUtil.ItemCallback<Book>() {
            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d("LOGGING", "areItemsTheSame : ${oldItem.id} ${oldItem === newItem}")
                return oldItem === newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d(
                    "LOGGING",
                    "areContentsTheSame : ${oldItem.id}, ${oldItem == newItem}"
                )
                return oldItem == newItem
            }
        }
    }
}

interface MyViewHolder {
    fun bind()
}

그렇다면 dummyList.toList()로 dummyList의 새로운 인스턴스를 만들어서 submitList 하면 제대로 작동할까? submitList는 작동한다. 하지만 우리가 기대한 것은 areItemsTheSame에서 true가 리턴되고 areContentsTheSame에서 (isSelected가 변경되었기 때문에) false가 리턴되서 뷰가 업데이트 되는 것인데, areContentsTheSame에서도 항상 true가 나와서 뷰가 업데이트 되지는 않는다.

실제로 이 코드에서 0번째 아이템을 클릭 후 로그를 살펴보면 areItemsTheSame와 areContentsTheSame 모두 true가 리턴됨을 알 수 있다. submitList done areItemsTheSame : 0 true areContentsTheSame : 0, true

areContentsTheSame에서 true가 리턴되는 이유는, dummyList.toList()가 담고있는 요소와 dummyList가 담고있는 요소는 같은 주소값을 참조하는데, dummyList.toList() 요소의 isSelected를 바꾸면서 dummyList의 요소의 isSelected도 같이 바뀌어버렸기 때문이다. 그렇기 때문에 areContentsTheSame에서 oldItem의 isSelected와 newItem의 isSelected는 항상 같은 값이 나오고 뷰가 업데이트 되지 않는다.

그렇다면 리스트의 요소도 새로운 인스턴스로 만들면 어떨까? - deep copy

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val dummyList by lazy { getDummyBookList() }
    private lateinit var adapter: RecyclerAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)
        //    binding.recyclerView.setHasFixedSize(true)
        adapter = RecyclerAdapter()

        fun onClickListener(book: Book) {

           ***//dummyList를 deepCopy 해야한다. 
            //선택된 아이템(it.id == book.id)이나 이전에 선택된 아이템(it.isSelected) 만 deepCopy를 하고
            //나머지는 그냥 원래 인스턴스를 반환하면
            //바뀌어야할 아이템만 areItemsTheSame을 타기때문에 
            //모두 deepCopy를 하는것보다 성능개선 가능 
            val dummyDeepCopyList =
                dummyList.map { if (it.isSelected || it.id == book.id) it.copy() else it }
            dummyDeepCopyList.forEach {
                if (book.id == it.id) {
                    it.isSelected = it.isSelected.not() // 선택된 아이템의 isSelected를 변경한다.
                } else {
                    it.isSelected = false //선택 되지 않은 아이템은 isSelected을 false로 바꾼다.
                }
            }

            adapter.submitList(dummyDeepCopyList) //deepCopy 리스트를 submitList한다.***
            Log.d("LOGGING", "submitList done")
        }

        adapter.also {
            it.setOnClickListener(::onClickListener)
            //    it.setHasStableIds(true)
            it.submitList(dummyList)
        }
        binding.recyclerView.adapter = adapter

    }

    private fun getDummyBookList(): MutableList<Book> {
        val list = mutableListOf<Book>()
        for (i in 0..30) {
            val book = Book(
                i,
                "android${i + 1}",
                image = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)!!
            )
            list.add(book)
        }
        return list
    }
}
class RecyclerAdapter :
    androidx.recyclerview.widget.ListAdapter<Book, RecyclerAdapter.BookViewHolder>(DIFF_UTIL) {

    private lateinit var clickListener: (Book) -> Unit

    fun setOnClickListener(listener: (Book) -> Unit) {
        clickListener = listener
    }

//    override fun getItemId(position: Int): Long {
//        return position.toLong()
//    }

    inner class BookViewHolder(private val binding: ItemBookBinding) :
        RecyclerView.ViewHolder(binding.root), MyViewHolder {
        init {
            itemView.setOnClickListener {
                clickListener(currentList[bindingAdapterPosition])
            }
        }

        override fun bind() {
            val item = currentList[bindingAdapterPosition]

            if (item.isSelected) binding.textViewItem.setBackgroundColor(Color.YELLOW)
            else binding.textViewItem.setBackgroundColor(Color.BLACK)
            binding.imageViewItem.setImageDrawable(item.image)
            binding.textViewItem.text = item.name
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = ItemBookBinding.inflate(inflater, parent, false)
        return BookViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
        (holder as? MyViewHolder)?.bind()
    }

    companion object {
        val DIFF_UTIL = object : DiffUtil.ItemCallback<Book>() {
            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d("LOGGING", "areItemsTheSame : ${oldItem.id} ${oldItem === newItem}")
                return oldItem === newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                Log.d(
                    "LOGGING",
                    "areContentsTheSame : ${oldItem.id}, ${oldItem == newItem}"
                )
                return oldItem == newItem
            }
        }
    }
}

interface MyViewHolder {
    fun bind()
}

그렇다면 dummyList를 deepCopy하여 요소들이 다른 주소값을 참조하도록 하면 어떨까?

이러면 우리가 원하는 결과가 나온다. 특히 dummyList를 모두 deepCopy 하는 것 보다 업데이트 되야할 요소만 deepCopy 하고 나머지는 원래 인스턴스를 반환하게하면, 업데이트 되야 할 요소만 areItemsTheSame에서 false를 반환해 업데이트 되기 때문에 모두 deepCopy후 모두 areItemsTheSame에서 false를 반환하는 것 보다 성능 개선이 된다.

Screen_Recording_20230116_160151_My Application.mp4

성능개선 - 최종코드

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val dummyList by lazy { getDummyBookList() }
    private lateinit var adapter: RecyclerAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)
        ***binding.recyclerView.setHasFixedSize(true) ⭐️⭐️⭐️***
        adapter = RecyclerAdapter()

        fun onClickListener(book: Book) {

           //dummyList를 deepCopy 해야한다. 
            //선택된 아이템(it.id == book.id)이나 이전에 선택된 아이템(it.isSelected) 만 deepCopy를 하고
            //나머지는 그냥 원래 인스턴스를 반환하면
            //바뀌어야할 아이템만 areItemsTheSame을 타기때문에 
            //모두 deepCopy를 하는것보다 성능개선 가능 
            val dummyDeepCopyList =
                dummyList.map { if (it.isSelected || it.id == book.id) it.copy() else it }
            dummyDeepCopyList.forEach {
                if (book.id == it.id) {
                    it.isSelected = it.isSelected.not() // 선택된 아이템의 isSelected를 변경한다.
                } else {
                    it.isSelected = false //선택 되지 않은 아이템은 isSelected을 false로 바꾼다.
                }
            }

            adapter.submitList(dummyDeepCopyList) //deepCopy 리스트를 submitList한다.
            Log.d("LOGGING", "submitList done")
        }

        adapter.also {
            it.setOnClickListener(::onClickListener)
            ***it.setHasStableIds(true) ⭐️⭐️⭐️***
            it.submitList(dummyList)
        }
        binding.recyclerView.adapter = adapter

    }

    private fun getDummyBookList(): MutableList<Book> {
        val list = mutableListOf<Book>()
        for (i in 0..30) {
            val book = Book(
                i,
                "android${i + 1}",
                image = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)!!
            )
            list.add(book)
        }
        return list
    }
}