[Android] RecyclerView sample 코드 이해하기

neoneoneo·2024년 4월 19일
0

android

목록 보기
15/16

본 글에는 공개되어있는 RecyclerView git 코드를 보면서 이해하고 공부한 내용을 정리한다.


아키텍쳐

data 패키지

Flower

  • 데이터 클래스
  • 객체의 멤버(이름, 사진 등) 구성

Flowers.kt

  • Flower 객체들로 이루어진 리스트를 반환하는 함수 정의
  • 실제적으로 더미 데이터를 넣은 곳

DataSource

  • 데이터를 싱글톤으로 관리될 수 있게 처리(synchronized)
  • 데이터 상의 추가, 삭제, 불러오기 등에 대한 행위에 대한 함수 정의

flowerList 패키지

FlowersListActivity.kt

  • 헤더 및 recyclerView Adapter 연결
  • ViewModel 연결
  • 각 item 눌렀을 때 처리 행위를 함수로 정의
  • 아이템 추가할 때의 행위를 처리하기 위한 함수 정의
    • 받아온 데이터를 뷰모델에 추가

FlowersAdapter.kt

  • recyclerView를 위한 어댑터로 더미 데이터를 각 뷰에 바인딩함
  • ViewHolder를 정의하여 recyclerView에 보여줄 데이터를 처리

FlowersListViewModel.k

  • FlowersListViewModel
    • 더미 데이터 리스트 확보(flowersLiveData)
    • 추가로 받을 데이터에 대한 정보를 받아와 데이터 소스에 add를 요청 하는 행위에 대한 함수 정의
  • FlowersListViewModelFactory
    • 뷰모델 생성하여 데이터 넘김

HeaderAdapter

  • 상단 헤더에 데이터 개수를 관리하기 위한 어댑터
  • ViewHolder를 정의하여 처리

flowerDetail 패키지

FlowerDetailActivity

  • viewModel 연결
  • 메인 화면에서 던져준 데이터를 받아와서 화면에 출력
  • 해당 데이터 삭제 행위 함수로 정의

FlowerDetailViewModel.kt

  • FlowerDetailViewModel
    • 더미 데이터 리스트 확보(flowersLiveData)
    • 추가로 받을 데이터에 대한 정보를 받아와 데이터 소스에 add를 요청 하는 행위에 대한 함수 정의
  • FlowerDetailViewModelFactory
    • 뷰모델 생성하여 데이터 넘김

공부한 내용

ViewModel

ViewModelProvider

  • 뷰모델 객체를 제공하는 유틸리티 클래스
  • 액티비티나 프래그먼트의 라이프사이클 범위에서 뷰모델을 생성하고 관리하는 역할

ViewModelProvider.Factory

  • 뷰모델을 생성하기 위한 팩토리 인터페이스
  • ViewModelProvider가 팩토리를 사용하여 뷰모델 객체를 생성한다.
  • 사실 팩토리가 없어도 뷰모델 자체는 생성할 수 있다. 그러나 사용자 정의 ViewModel을 생성하거나 생성자 인자로 뭔가를 더 받아오고 싶을 때 팩토리를 정의하여 사용한다.
package com.example.recyclersample_practice.flowerList

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.recyclersample_practice.data.DataSource
import com.example.recyclersample_practice.data.Flower
import kotlin.random.Random

class FlowersListViewModel(val dataSource: DataSource) : ViewModel() {

    val flowerLiveData = dataSource.getFlowerList() //TODO: livaData

    // name, description이 제대로 들어오면 새로운 flower를 만들어 데이터소스에 추가함
    fun insertFlower(flowerName: String?, flowerDescription: String?) {
        if (flowerName == null || flowerDescription == null) {
            return
        }

        val image = dataSource.getRandomFlowerImageAsset()
        val newFlower = Flower(
            Random.nextLong(),
            flowerName,
            image,
            flowerDescription
        )

        dataSource.addFlower(newFlower)
    }
}

class FlowerListViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(FlowersListViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST") 
            return FlowersListViewModel(
                dataSource = DataSource.getDataSource(context.resources)
            ) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

이런식으로 뷰모델과 팩토리 클래스를 작성할 수 있다.

팩토리에서는 FlowersListViewModel의 생성자 인자로 dataSource를 받아와 처리하게끔 커스텀된 것을 볼 수 있다.

팩토리 클래스가 좀 이해가 안돼서 더 살펴봤는데, 여전히 이해는 못 했다.

  • override fun <T : ViewModel> create(modelClass: Class<T>): T
    • 이 부분을 보면 T의 타입은 modelClass 매개변수에 따라 결정된다.
    • 그리고 T는 ViewModel의 클래스 또는 하위 클래스의 인스턴스일 것임을 표시했다. 앱 내에 사용되는 ViewModel이 여러개 일 수 있기 때문이다.
  • if (modelClass.isAssignableFrom(FlowersListViewModel::class.java))
    • 이 부분을 보면 modelClass에 FlowersListViewModel를 할당할 수 있는지 검사를 한다.
    • 할당 할 수 있다면 FlowersListViewModel을 반환할 것이고 이때 T 타입으로 캐스팅하여 반환을 해주는 것이다.
  • return되는 부분을 보면 FlowersListViewModel(...) 생성자를 호출하고 객체를 T 타입으로 캐스팅하여 반환한다.

결론적으로 이 팩토리는,
create 함수가 매개변수로 modelCalss를 받아오고,
요청된 ViewModel 타입이 FlowersListViewModel일 경우에만 객체를 생성하여 반환한다.
이때 T 타입을 사용하는 이유는 ViewModel의 하위 클래스들을 받아와서 처리를 해줘야하므로 보다 유연한 처리를 위해 사용하는 것이다.

LiveData

LiveData의 선언

private val flowersLiveData = MutableLiveData(initialFlowerList)

DataSource에 선언된 LiveData는 변경이 될 수 있으므로 MutableLiveData를 사용한다.

val flowerLiveData = dataSource.getFlowerList()

ViewModel에 선언된 LiveData는 그저 소스에서 가져온 데이터일 뿐이다.

value와 postValue의 차이

둘 다 LiveData의 메서드이며, 데이터를 읽고 업데이트하는 데에 사용된다.

  1. .value
  • LiveDat 객체의 현재 값을 동기적으로 가져올 때 사용
  • 읽기 전용이며 수정 불가
  1. .postValue()
  • 비동기적으로 LiveDat 객체의 값을 업데이트할 때 사용
  • 내부적으로 백그라운드 스레드에서 LiveData의 값을 변경하고 변경된 값은 메인 스레드에서 Observer를 통해 수신되어 UI를 업데이트하는 데에 사용
//새로운 꽃을 liveData와 post value를 추가함
    fun addFlower(flower: Flower) {
        val currentList = flowersLiveData.value // 이 부분
        if (currentList == null) {
            flowersLiveData.postValue(listOf(flower))
        } else {
            val updatedList = currentList.toMutableList()
            updatedList.add(0, flower)
            flowersLiveData.postValue(updatedList) // 이 부분
        }
    }

이 코드를 보면 currentLis에 LiveData를 읽어오고,
flowersLiveData.postValue(updatedList)에서 데이터를 변경하고 있다.

LiveData의 observe

데이터를 관리하는 클래스와 ViewModel에서 LiveData가 선언되고 뭔가 처리를 하는 데 사용할 수 있게끔 작성이 되었으면, 이후에는 사용할 액티비티나 프래그먼트에서 .observe 하여 사용한다.

        flowersListViewModel.flowerLiveData.observe(this, {
            it?.let {
                flowerAdapter.submitList(it as MutableList<Flower>)
                headerAdapter.updateFlowerCount(it.size)
            }
        })

이 코드는 flowerLiveData를 observe하여 데이터의 변화를 감지한다.

observe는 이렇게 생겼는데,

  • LifecycleOwner owner
    • 실행되고 있는 액티비티나 프래그먼트
  • Observer<? super T> observer
    • 람다식으로 무언가 처리에 대한 코드 조각

이렇게 매개변수를 받고 있다.

다시 코드를 살펴보면,

  • 현재 실행되고 있는 액티비티(this)와 관련된 LiveData인 flowerLiveData를 observe가 구독한다.
  • it?.let {...}
    • it은 LiveDat의 변경된 값이다. it이 null이 아닐 때 let 이하를 수행한다. 즉, 변경된 사항이 있으면 let 이하를 처리한다.
  • flowerAdapter.submitList(it as MutableList<Flower>)
    • flowerAdapter에 변경된 꽃 목록(it)을 제출하여 뷰를 업데이트한다.
    • submitList은 새로운 목록을 기존 목록과 비교하여 업데이트 해주는 ListAdapter의 메소드이다.
  • headerAdapter.updateFlowerCount(it.size)
    • header에 보여지는 view에도 변경된 꽃 목록(it)의 size를 보내 업데이트 한다.
    • updateFlowerCount는 headerAdapter에 정의된 사용자 정의 함수이다.

ListAdapter

'RecyclerView의 데이터를 관리를 효율적으로 도와주는 클래스' 라고 한다.

특히, DiffUtil라는 유틸리티 클래스를 사용하여 새로운 목록과 이전 목록을 비교하여 변경된 부분만 업데이트 할 수 있다.

사용법

class FlowersAdapter(private val onClick: (Flower) -> Unit) :
    ListAdapter<Flower, FlowersAdapter.FlowerViewHolder>(FlowerDiffCallback) {

이런식으로 adapter 클래스를 작성할 때 ListAdapter를 상속 받는다.

ListAdapter는 <> 기호를 사용하여 정의할 때 타입을 파라미터화 할 수 있다고 하는데,

코드에서는,
첫 번째 인자로 Flower라는 데이터 클래스를
두 번째 인자로 FlowersAdapter.FlowerViewHolder 뷰홀더 클래스를 넣어줬다.

(FlowerDiffCallback) 이 부분은 ListAdapter의 생성자에 전달되는 매개변수로 사용되는데, 코드는 아래와 같다.

object FlowerDiffCallback : DiffUtil.ItemCallback<Flower>() { 
    override fun areItemsTheSame(oldItem: Flower, newItem: Flower): Boolean {
        return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: Flower, newItem: Flower): Boolean {
        return oldItem.id == newItem.id
    }
}

이 코드는 DiffUtil.ItemCallback<Flower> 인터페이스를 구현한 객체이다.

이 객체는 ListAdapter가 데이터 변경을 비교하고 업데이트 하는 데에 사용된다.

딸려온 areItemsTheSame, areContentsTheSame 함수를 override하여 사용하면 된다.

ViewHolder

FlowerAdapter의 ViewHolder 안에는 이런 코드가 있다.

    class FlowerViewHolder(
        binding: FlowerItemBinding, onClick: (Flower) -> Unit
    ) : RecyclerView.ViewHolder(binding.root) {

        private var flowerTextView = binding.flowerText
        private var flowerImageView = binding.flowerImage
        private var currentFlower: Flower? = null

        init {
            binding.root.setOnClickListener {
                currentFlower?.let {
                    onClick(it)
                }
            }
        }
//이하 생략

이 부분을 보면서 init 블록의 역할이 궁금했다. 왜 저기에서 init을 해줘야 하지?

init은 클래스의 초기화를 실행하는 블록이다.
즉, 클래스 인스턴스가 생성될 때 init 안에 있는 내용으로 초기화가 된다.

위 코드에서는 init 안에서 클릭 이벤트 처리를 등록하고 이벤트 핸들러에 대한 설정을 한다.

이렇게 init 블록을 사용하면 클래스 초기화 과정을 명시적으로 정의할 수 있다고 한다. 특히 코드 가독성에 좋고 유지 보수할 때에도 init 블록을 따로 보면서 수정 작업을 할 수도 있겠다.

ConcatAdapter

위에서 다뤘던 flowerAdapter와 headerAdapter는 액티비티에서 아래와 같이 사용된다.

        val headerAdapter = HeaderAdapter()
        val flowerAdapter = FlowersAdapter { flower -> adapterOnClick(flower) }
        val concatAdapter = ConcatAdapter(headerAdapter, flowerAdapter)
        val recyclerView: RecyclerView = binding.recyclerView
        recyclerView.adapter = concatAdapter

ConcatAdapter는 여러 개의 어댑터를 결합하여 하나의 큰 어댑터로 만드는 역할을 한다.

먼저 표시할 아이템을 매개변수 자리에 넣어주고, 보여줄 view의 adapter로 연결해주면 된다.

startActivityForResult

//FAB 버튼 클릭 시 꽃 추가
    private fun fabOnClick() {
        val intent = Intent(this, AddFlowerActivity::class.java)
        startActivityForResult(intent, newFlowerActivityRequestCode)
    }

이런식으로 인텐트와 코드를 함께 넘긴다.

그 전에는 startActivity만 사용해봐서 좀 낯설었는데, 역할은 비슷한데 +@된 기능이 있다.

startActivityForResult는 intent를 보낼 액비비티와 상호작용이 필요할 때 사용한다.

즉, 호출된 액티비티가 시작된 후 작업을 완료한 다음 결과를 호출한 액비티비로 돌려보내는 것이다.

호출된 액비비티에서는 intent로 데이터를 받아 처리를 하는데,

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        binding = ActivityAddFlowerBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.doneButton.setOnClickListener {
            addFlower()
        }

        addFlowerName = binding.addFlowerName
        addFlowerDescription = binding.addFlowerDescription
    }

private fun addFlower() {
        val resultIntent = Intent()

        if (addFlowerName.text.isNullOrEmpty() || addFlowerDescription.text.isNullOrEmpty()) {
            setResult(Activity.RESULT_CANCELED, resultIntent)
        } else {
            val name = addFlowerName.text.toString()
            val description = addFlowerDescription.text.toString()
            resultIntent.putExtra(FLOWER_NAME, name)
            resultIntent.putExtra(FLOWER_DESCRIPTION, description)
            setResult(Activity.RESULT_OK, resultIntent)
        }
        finish()
    }

위 코드를 보면 addFlower() 안에서 setResult() 메서드를 사용하여 데이터를 호출한 액티비티로 반환한다.

onActivityResult

위 코드를 통해 데이터를 다른 액티비티로 넘기고, 받아온 결과를 처리하기 위해 사용하는 함수이다.

이때 requestCode, resultCode를 사용하여 처리할 결과를 확인한다.

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        //꽃 정보를 뷰모델에 전달
        if (requestCode == newFlowerActivityRequestCode && resultCode == Activity.RESULT_OK) {

            data?.let {
                val flowerName = it.getStringExtra(FLOWER_NAME)
                val flowerDescription = it.getStringExtra(FLOWER_DESCRIPTION)

                flowersListViewModel.insertFlower(flowerName, flowerDescription)
            }
        }
    }

코드를 보면,
resultCode == Activity.RESULT_OK로 결과가 정상적으로 반환되었으면,
받아온 data에 대한 let 이하 처리를 한다.

여기서 let 안에서는 ViewModel로 데이터를 보내 insertFlower 처리를 요청한다.

    fun insertFlower(flowerName: String?, flowerDescription: String?) {
        if (flowerName == null || flowerDescription == null) {
            return
        }

        val image = dataSource.getRandomFlowerImageAsset()
        val newFlower = Flower(
            Random.nextLong(),
            flowerName,
            image,
            flowerDescription
        )

        dataSource.addFlower(newFlower)
    }

insertFlower에서는 DataSource로 요청할 데이터를 다듬는다.

보면 마지막 줄에 addFlower(...) 처리를 dataSource에 요청하는데,

  fun addFlower(flower: Flower) fun addFlower(flower: Flower) `{
        val currentList = flowersLiveData.value
        if (currentList == null) {
            flowersLiveData.postValue(listOf(flower))
        } else {
            val updatedList = currentList.toMutableList()
            updatedList.add(0, flower)
            flowersLiveData.postValue(updatedList)
        }
    }

DataSource에서는 데이터를 받아와 실제적으로 DataSource 클래스에서 관리하는 데이터에 변화를 준다.

싱글톤

멤버 참조 문법

companion object {
    private var INSTANCE: DataSource? = null

    fun getDataSource(resources: Resources): DataSource {
        return synchronized(DataSource::class) {
            val newInstance = INSTANCE ?: DataSource(resources)
            INSTANCE = newInstance
            newInstance
        }
    }
}
  • synchronized(DataSource::class) 이부분에 멤버 참조 문법이 사용되었다.
    • DataSource::class는 DataSource 클래스의 KClass 인스턴스를 가리킨다.
    • 컴파일러에게 DataSource 클래스의 KClass를 제공하는 것이다.
      • 'KClass는 Kotlin에서 클래스를 나타내는 런타임 타입 정보를 제공하는 클래스'라고 한다.

멤버 참조 문법을 써서 컴파일러가 안전하게 DataSource의 클래스를 처리할 수 있도록 한 것이다. 물론 코드를 더 간결하게 만드는 것도 있다.

만약 멤버 참조 문법을 쓰지 않는다면?

synchronized("DataSourceLock") 이런식으로 임의의 문자열을 넣을 수도 있다.

흠.. 뭔가 synchronized() 안에 문자열을 넣든 멤버 참조로 KClass를 넣든 상관은 없는 것 같다.

요점은 인스턴스를 한 번만 생성해두고 사용한다는 부분인 것 같다.

애너테이션

@DrawableRes

data class Flower (
    val id: Long,
    val name: String,
    @DrawableRes // TODO: 이 애너테이션의 역할
    val image: Int?,
    val description: String
)
  • @DrawableRes는 리소스 타입을 지정해주는 역할을 한다. 즉, imaga에 대한 리소스는 Drawable 타입이어야 한다.
  • 리소스의 ID는 R.drawable.*과 같이 정의되는데, 이 애너테이션을 사용하여 image가 정확한 리소스 타입을 가리키고 있음을 명시적으로 나타내어 컴파일러에게 알려주는 것이다.
  • 애너테이션은 반드시 써야 하는 것은 아니지만 코드의 가독성을 높여주고, 만약 저 image 필드에 다른 타입의 리소스를 지정할 경우 경고를 줄 수 있게 되어 안정성을 높일 수 있게 된다.

@Suppress

class FlowerListViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(FlowersListViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST") // TODO: suppress 애너테이션의 역할
            return FlowersListViewModel(
                dataSource = DataSource.getDataSource(context.resources)
            ) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}
  • @Suppress는 컴파일러에게 특정 경고를 무시하라고 지시하는 것이다.
  • 위 코드에서는 "UNCHECKED_CAST" 라는 타입 캐스팅과 관련된 경고를 무시하라고 한다.
    • as T 로 리턴하는 뷰모델을 제네릭 타입으로 캐스팅하고 있다.
      • 컴파일러는 T가 어떤 타입인지 알 수 없으며 런타임 시에 알 수 있게 된다. 그렇기에 컴파일러는 경고를 표시하는 것이다.

[TIL-240419]

0개의 댓글