개발을 파헤치다/Android

[Android] MVVM 아키텍처에서 Live Data와 Data Binding을 활용해서 Recycler View 구현하기

개발자_H 2022. 1. 11. 14:24
반응형

Android MVVM RecyclerView 구현

MVVM패턴은 Model, View, View Model로 구성되는 아키텍처 패턴입니다. 근래 가장 많이 쓰이는 패턴이라고도 생각이 드는데요.
확실히 이 아키텍처 패턴을 사용하면 코드 양이 조금 늘 수는 있지만 유지보수 측면이나 코드 재사 용성면에서는 확실히 장점이 있다고 생각합니다. 개념을 확실히 이해하고 MVVM을 구현하기 위해 사용되는 Live Data와 Data Binding을 적용해야 하는데요.
이번에는 Recycler View를 MVVM 패턴에 맞게 구현하는 방법을 알아봅니다.

MVVM 구조 이해


MVVM 아키텍처에서는 위와 같은 구조로 앱이 실행됩니다. 
Live Data를 사용하면 좋은 점은 View Model에서 View에 보여줄 데이터를 가져왔을 때 View단에 알아서 알려준다는 것입니다.
기존에는 API 호출 등을 통해 Model에서 데이터를 가져오면 View에 있는 콜백 함수를 호출하는 방식으로 UI를 업데이트했었습니다.
Live Data가 데이터가 바뀌면 이벤트를 기다리는(View에서 Observing 하고 있다고 흔히 표현합니다) View의 업데이트 로직을 알아서 실행시켜주기 때문에 매우 편리합니다. 
또, 코드 유지보수 관점에서도 굉장히 깔끔하다고 할 수 있죠. 이 덕분에 ViewModel은 특정 Activity나 Fragment에 묶이지 않고 재사용이 가능합니다. Recycler View를 예로 들어볼까요? 하나의 앱에서 기획에 따라 정말 많은 RecyclerView를 사용할 수 있는데요. 대개의 경우 데이터 흐름은 동일합니다. 호출하는 API가 다를 뿐 데이터 묶음을 가져와서 Adapter에 넣어주고 데이터 삭제나 추가가 이루어질 수 있겠죠. MVVM 아키텍처에서는 Live Data를 활용해서 재사용이 가능한 Recycler ViewModel을 구현할 수 있습니다. 즉, 데이터만 바뀔 뿐 데이터를 받아서 View에 전달해주는 흐름이 같기 때문에 하나의 ViewModel로 여러 개의 Recycler View에 적용할 수 있다는 것이죠. 이렇게 View Model을 잘 구현하면 유지보수성뿐만 아니라 개발 시간도 단축할 수 있습니다.

View에서는 View Model로부터 받은 데이터를 적용해 사용자에게 보여주거나 다이얼로그를 띄워주거나 토스트 메시지를 보여주는 등 UI 로직을 처리하게 됩니다. Data Binding 사용 전에는 Layout의 요소를 불러와(findViewById) 내부 구성요소의 값을 바꿔주는 식으로 구현을 했는데요.
복잡하고 처리할 데이터가 많아지면 많아질수록 View 단 코드가 너저분해지고 가끔은 findViewById 덕에 Null 에러도 나곤 합니다.
Data Binding은 XML에서 데이터를 변수로 받아 처리할 수 있게 하는 기술인데요. 그러니까 View에서는 받은 데이터를 레이아웃 관련 클래스에 변수로 설정만 해주고 나머지 UI 관련 로직을 XML에서 직접 처리할 수 있는 것이죠. 이렇게 되면 코드도 깔끔해지고 유지보수가 편리해집니다. 게다가 Live Data를 통해 값이 바뀔 때마다 알아서 설정값들이 UI에 업데이트되니 개발하기도 편리하죠.

 

반응형

 

재사용 가능한 RecyclerViewModel 구현

먼저 여러 Recycler View에서 사용이 가능한 View Model을 구현해보도록 하겠습니다.
Recycler View마다 필요한 데이터는 다를 것이고 따라서 호출하는 API도 다를 것이기 때문에 내부적으로 API를 나타내는 상수값을 정의하고 생성자에 이 값을 받아서 알맞은 API를 호출할 수 있도록 구현했습니다.

/*
*   재사용 가능한 RecyclerView Model
* */

class RecyclerViewModel(private val mode: Int): ViewModel() {

    // 데이터 로드 실패시 에러 이벤트
    private val _navigateToLoadFail = SingleLiveEvent<Any>()
    val navigateToLoadFail : LiveData<Any>
        get() = _navigateToLoadFail

    // Live Data로 리스트를 관리하는 경우 리스트 내부 아이템의 추가, 삭제와 같은
    // 변경사항이 일어나도  Observer에 등록된 콜백이 호출되지 않음
    // 이를 위해 Live Data와 데이터  List를 따로 관리
    private lateinit var list : List<Any?>

    private val _dataList = MutableLiveData<List<Any?>>()
    val dataList: LiveData<List<Any?>> = _dataList

    // View에서 알맞은  API 호출을 위해 상수값을 정의
    companion object{
        const val requestMainEventBannerList = 0
        const val requestFeaturedProductList = 1
        const val requestProductList = 2
        const val requestEventBannerList = 3
        const val requestProductWishlist = 4
    }

    // 초기화 할 때 요청에 맞는 API를 호출해서 데이터 리스트를 가져온다
    init{
        when(mode){
            // 메인용 이벤트 배너 리스트 API
            requestMainEventBannerList -> {
                requestMainBannerApi()
            }

            // Md's Pick 상품 리스트 API
            requestFeaturedProductList  ->  {
                requestFeaturedProductApi()
            }


            // 인기상품 리스트 API
            requestProductList  ->  {
                requestProductApi()
            }

            // 이벤트탭 목록 API
            requestEventBannerList -> {
                requestEventBannerApi()
            }

            // 찜한 상품 목록 API
            requestProductWishlist -> {
                requestProductWishlistApi()
            }
        }
    }

    // 찜한 상품 목록 API
    private fun requestProductWishlistApi(page: Int? = 1){
        val storeRepository = ServiceLocator.provideStoreRepository()
        storeRepository.requestProductWishlist(
            page= page,
            callback = object: BaseNetworkInterface.SimpleListDataResponseCallback{
                override fun onSuccess(list: List<Any?>) {
                    // 데이터 리스트 업데이트 후에 Live Data에도 반영해주면
                    // value값이 변경되기 때문에 Observer의 콜백이 호출됩니다
                    this@RecyclerViewModel.list = list
                    _dataList.value = this@RecyclerViewModel.list
                }

                override fun onFailure(statusCode: Int?) {
                    _navigateToLoadFail.call()
                }

            }
        )
    }


    // 메인용 이벤트 배너 리스트 API
    private fun requestMainBannerApi(page : Int? = 1){
        val eventRepository = ServiceLocator.provideEventRepository()
        eventRepository.requestMainEventList(
            page = page,
            callback = object:BaseNetworkInterface.SimpleListDataResponseCallback{
                override fun onSuccess(list: List<Any?>) {
                    this@RecyclerViewModel.list = list
                    _dataList.value = this@RecyclerViewModel.list
                }

                override fun onFailure(statusCode: Int?) {
                    // 데이터 로드 실패시 에러 이벤트를 호출한다
                    _navigateToLoadFail.call()
                }
            })
    }

    // Md's Pick 상품 리스트 API
    private fun requestFeaturedProductApi(page: Int? = 1){
        val storeRepository = ServiceLocator.provideStoreRepository()
        storeRepository.requestProductList(
            page = page,
            featured = true,
            callback = object:BaseNetworkInterface.SimpleListDataResponseCallback{
                override fun onSuccess(list: List<Any?>) {
                    this@RecyclerViewModel.list = list
                    _dataList.value = this@RecyclerViewModel.list
                }

                override fun onFailure(statusCode: Int?) {
                    _navigateToLoadFail.call()
                }

            }
        )
    }

    // 인기상품 리스트 API
    private fun requestProductApi(page: Int? = 1){
        val storeRepository = ServiceLocator.provideStoreRepository()
        storeRepository.requestProductList(
            page = page,
            perPage=4,
            callback = object:BaseNetworkInterface.SimpleListDataResponseCallback{
                override fun onSuccess(list: List<Any?>) {
                    this@RecyclerViewModel.list = list
                    _dataList.value = this@RecyclerViewModel.list
                }

                override fun onFailure(statusCode: Int?) {
                    _navigateToLoadFail.call()
                }

            }
        )
    }

    // 이벤트탭 이벤트 목록 불러오기 API
    private fun requestEventBannerApi(page: Int? = 1){
        val eventRepository = ServiceLocator.provideEventRepository()
        eventRepository.requestEventList(
            page = page,
            callback = object:BaseNetworkInterface.SimpleListDataResponseCallback{
                override fun onSuccess(list: List<Any?>) {
                    this@RecyclerViewModel.list = list
                    _dataList.value = this@RecyclerViewModel.list
                }

                override fun onFailure(statusCode: Int?) {
                    _navigateToLoadFail.call()
                }

            }
        )
    }
}

주의할 점은 Live Data 사용 시 리스트 내부 아이템 변동에도 Observer의 콜백 함수가 호출되지 않는다는 점인데요.
이렇게 되면 UI 업데이트가 안됩니다. 이것을 해결하기 위해 데이터 리스트를 따로 관리하고 데이터 업데이트 시 내부 데이터 리스트와 Live Data의 value를 모두 변경시켜줍니다. 이렇게 구현하면 새로운 Recycler View에 대응하기 위해 API 상수값 추가와 API 호출 로직만 추가하면 되고 얼마든지 재사용이 가능합니다.


Layout 구현 및 Data Binding 적용

이제 Recycler View의 아이템 레이아웃을 구현할 차례입니다.
Data Binding을 사용하기 위해서는 먼저 App 레벨의 build.gradle에 아래와 같이 설정이 되어있어야 합니다.

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
       ...
    }

    buildTypes {
        ...
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }

    // Data Binding 설정 추가
    dataBinding{
        enabled = true
    }

}

그리고 Layout 구현 시 가장 상위에 있는 레이아웃을 <layout>으로 설정해줘야 합니다.
이렇게 설정해야 Data Binding에서 레이아웃에 매핑이 되는 Class를 생성하게 됩니다. 이 클래스가 있어야 Layout에서 사용할 데이터를 전달해줄 수 있습니다.

<?xml version="1.0" encoding="utf-8"?>

    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="event"
            type="com.leoncorp.jejuand.model.event.Event"
            />
    </data>
<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/event_banner"
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        android:scaleType="fitXY"
        app:imageUrl="@{event.thumbnailUrl}"/>

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<layout> 태그 밑에 <data> 태그가 사용된 것을 확인할 수 있는데요. 여기에 Layout에서 사용할 데이터 변수들을 선언합니다.
variable 태그를 생성하고 사용할 이름과 타입을 설정해주면 됩니다.
레이아웃에 값을 적용할 때에는 아래와 같이 적용하면 됩니다. 

@{class.attribute}

위 Layout에서는 Event 클래스의 썸네일 URL 값을 ImageView의 imageUrl 필드에 설정하고 있습니다.

Binding Adapter 구현

Binding Adapter는 View의 특정 필드 값들이 변동됐을 때 원하는 메서드가 호출되도록 매핑해주는 역할을 합니다.
예를 들어 앞에서 ImageView의 url 값이 변동되었다면 이미지 라이브러리를 호출해서 이미지를 알아서 적용하도록 할 수 있습니다.

 // Layout에서 이미지 URL값이 변동되었을 떄 호출되는 Binding Adapter
    @BindingAdapter("app:imageUrl")
    @JvmStatic
    fun loadImage(targetView: ImageView, url: String?){
        url?.let {
            Glide.with(targetView.context)
                .load(url)
                .fitCenter()
                .into(targetView)
        }

    }

위의 Binding Adapter는 app:imageUrl 필드에 변동이 있을 때 loadImage 함수를 실행합니다.
로직에서는 Glide를 활용해 이미지를 불러와서 ImageView에 적용합니다.

RecyclerView Adapter 구현

이제 Adapter를 구현합니다. 여기에서 중은 레이아웃 생성 시 Data Binding을 사용할 수 있도록 Binding class를 사용해야 한다는 점입니다.

class EventBannerAdapter(val context: Context):
    SliderViewAdapter<EventBannerAdapter.EventBannerViewHolder>() {

    private var eventList = emptyList<Event>()

    // View Holder 클래스
    inner class EventBannerViewHolder(val binding: EventBannerItemBinding):
        SliderViewAdapter.ViewHolder(binding.root){
        fun onBind(data: Event){
            // Binding class에 레이아웃에서 사용할 데이터를 설정합니다
            binding.event = data
        }
    }

    override fun getCount(): Int {
        return eventList.size
    }

    // Data Binding에서 XML 레이아웃에 매핑되는  Binding Class를 생성해줍니다
    // 해당 class로 layout을 생성해서  View Holder에 넘겨줍니다
    override fun onCreateViewHolder(parent: ViewGroup?): EventBannerViewHolder {
        val binding = EventBannerItemBinding.inflate(
            LayoutInflater.from(context), parent, false
        )
        return EventBannerViewHolder(binding)
    }

    override fun onBindViewHolder(viewHolder: EventBannerViewHolder?, position: Int) {
        val eventData = eventList[position]
        viewHolder?.onBind(eventData)
    }

    // Live Data를 세팅하는 메서드
    fun setDataList(dataList: List<Event>){
        // 보여줄 데이터 목록이 업데이트 된다
        this.eventList = dataList
        notifyDataSetChanged()
    }
}

눈여겨봐야 할 부분은 onCreateViewHolder와 ViewHolder 내부 onBind 함수인데요.
먼저 RecyclerView를 통해 보여줄 레이아웃을 생성할 때 기존과는 다르게 Binding Class를 사용해서 만들어줘야 합니다.
그리고 ViewHolder에 binding class를 매개변수로 넘겨줘야 하는데요 이유는 레이아웃에서 사용할 데이터를 설정해주기 위해서입니다.
원래 ViewHolder의 onBind 메서드에서는 View에 데이터를 매핑하는 코드가 들어가는데요. Data Binding을 사용하면 데이터 값만 설정해주고 실제 로직은 XML에서 처리하게 됩니다.


Activity 및 Fragment 구현

이제 View단을 구현할 차례입니다. 아래 예시에서는 Fragment에서 RecyclerView를 초기화하고 데이터를 어떻게 업데이트하는지 나타나 있습니다.

class EventFragment: Fragment(), LifecycleObserver {

    lateinit var binding: FragmentEventBinding
    lateinit var viewModel : RecyclerViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // RecyclerViewModel 초기화하고 데이터 로드
        viewModel = RecyclerViewModel(RecyclerViewModel.requestEventBannerList)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_event, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initEventList()

        // 데이터 로드 실패 이벤트를 observing한다
        viewModel.navigateToLoadFail.observe(viewLifecycleOwner, Observer {
            showErrorMessage()
        })

    }


    private fun initEventList(){
        // 이벤트 탭 리스트 초기화
        val eventListView: RecyclerView = binding.mainEventTapRecyclerView
        val eventAdapter = EventListAdapter(requireContext())
        eventListView.adapter = eventAdapter
        eventListView.layoutManager = LinearLayoutManager(requireContext())

        // Live Data를 observing해서 이벤트 리스트를 업데이트한다
        viewModel.dataList.observe(viewLifecycleOwner, Observer {
            // View Model에서 전달해준 데이터의 타입을 알 수 없으므로
            // filterIsInstance 메서드롤 통해 확인해준다
            val dataList = it.filterIsInstance<Event>()
            // Recycler View Adapter에 데이터 목록을 업데이트한다
            eventAdapter.setDataList(dataList)
        })
    }

    // 데이터 로드 실패시 메시지를 보여준다
    private fun showErrorMessage(){
        Toast.makeText(
            requireContext(),
            AppUtils.getString(R.string.dialog_error_msg),
            Toast.LENGTH_SHORT
        ).show()
    }

}

Recycler View 초기화는 initEventList에서 이루어지는데요. Adapter나 layout Manager 초기화는 기존과 다르지 않습니다.
데이터를 업데이트하기 위해서는 ViewModel의 데이터 변수(Live Data)를 observing 해야 하는데요. 이렇게 설정해야 값이 바뀔 때 콜백 함수 로직이 실행되면서 Recycler View 목록이 업데이트됩니다.

주의할 점은 ViewModel로부터 가져온 데이터를 Adapter에 적용할 때인데요. ViewModel의 재사용성을 위해 데이터 리스트 타입을 Any로 설정했었습니다. 하지만 Kotlin에서는 런타임에 Generic 파라미터를 확인할 수 있는 방법이 없습니다. 따라서 List <Any>를 다른 타입의 아이템을 가진 List로 변환하려면 위와 같이 f ilterIsInstance 메서드를 통해 타입 필터링을 해주어야 합니다. 이렇게 List 데이터 타입을 변환한 뒤 Adapter에 설정해주면 Recycler View에 목록이 정상적으로 나타납니다.

반응형