[Android] ViewModel, LiveData, Repository 구조

Delight Yoon·2022년 11월 6일
0

Android

목록 보기
7/17
post-custom-banner

개요

MVVM 패턴에 사용되는 ViewModel, LiveData, Repository 구조에 대해서 알아보고 정리해보는 시간을 가지려고 한다.

ViewModel


ViewModel 공식문서 를 참조하여 작성하였다.

ViewModel 클래스는 LifeCycle을 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계되었다.
그래서 UI LayerStates Holder 역할을 할 수 있는 것이다. 이유는 아래와 같다.

ViewModel LifeCycle


Activity 의 경우, 최초 생성되고 화면이 rotate 되거나 finish 를 호출받으면 여러 LifeCycle Callback 이 일어나는 반면에, ViewModel 은 최초에 생성된 이 후에도 계속해서 같은 상태를 유지하는 것을 확인할 수 있다.

따라서, ViewModelStates holder 의 역할을 할 수 있는 것이다.

  • Activity 가 어느 상태이던 간에 언제든지 ViewModel에서 데이터를 요청해서 사용할 수 있다.

  • Activity가 종료될 때, ViewModel 또한 onCleared()를 호출하며, 데이터를 메모리상에서 해제하는 작업을 할 수 있다.

LiveData


ViewModel 에서 데이터를 관리할 때에는 보통 LiveData라는 클래스와 함께 사용하게 된다.
(요즘은 Flow를 사용하는 것 같다.. )

class MyViewModel : ViewModel() {
	private val users: MutableLiveData<List<User>> by lazy {
    	MutableLiveData<List<User>>.also {
        	loadUsers()
        }
    }
    
    fun getUsers() : LiveData<List<User>> {
    	return users
	}
    
    private fun loadUsers() {
    	// Do an asynchronous operation to fetch users.
        // Data Layer - Repository
    }
}

loadUsers()

  • private 접근 지정자가 있으므로, ViewModel 안에서만 호출이 가능하다.

  • Data LayerRepository 에서 프로퍼티인 users의 데이터를 받아온다.

  • getUsers() 에 의해서 lazy 프로퍼티인 user가 호출되면,
    loadUsers() 로 데이터를 fetch 받아와 담겨지며, lazy 하게 최초 한 번만 생성된다.
    lazy 는 기록된 값을 불러오는재사용 성질이 있다. ( 초기화 구문은 한 번만 실행된 후, 기록된 값을 불러옴 )

getUsers()

  • Mutable 타입의 LiveDataImmutable 타입의 LiveData로 반환해준다.

  • public 타입으로 외부에서 호출이 가능하기 때문에, Immutable 타입의 LiveData 를 반환해주는 것이다.
    (원본 데이터의 수정을 막기 위해서)

ViewModel 호출


그렇다면, ViewModel 에서 관리하게 되는 이러한 데이터를 Activity 또는 Fragment 에서는 어떻게 참조할까 ?

import androidx.activity.viewModels

class MyActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        // Create a ViewModel the first time the system calls an activity's onCreate() method.
        // Re-created activities receive the same DiceRollViewModel instance created by the first activity.

        // Use the 'by viewModels()' Kotlin property delegate
        // from the activity-ktx artifact
        val viewModel: MyViewModel by viewModels()
        viewModel.getUsers().observe(this, Observer<List<User>> { users ->
        	// update UI
        }
    }
}

by viewModels()를 호출하기 위해서는 build.gradledependenciesktx artifact 라이브러리를 추가해주어야 한다.

by viewModels()를 호출해서 MyViewModel 객체를 생성한다.

ViewModel 에서 받아온 LiveDataobserve를 사용해서 받아온 데이터의 값이 바뀌었을 때, UI를 업데이트 하는 동작이 실행된다.

이유는 다음과 같다.

LiveData의 사용


  1. LiveData 의 인스턴스를 생성한다.
    이 작업은 일반적으로 ViewModel 클래스 내에서 이루어진다.

  2. onChanged() 메서드를 정의하는 Observer 객체를 만듭니다. 이 메서드는 LiveData 객체가 보유한 데이터가 변경되었을때, 실행되는 작업을 제어합니다. 일반적으로 Activity 또는 Fragment 에서 Observer 객체를 만듭니다.

  3. observe() 메서드를 사용하여 LiveData 객체에 Observer 객체를 연결합니다.
    Observer 객체는LiveData 객체를 구독하여 변경사항에 관한 알림을 받습니다.
    일반적으로 Activity 또는 Fragment와 같은 UI 컨트롤러Observer 객체를 연결합니다.

    observe() 메서드는 첫 번째 인자로 LifecycleOwner , 두 번째 인자로 Observer 객체를 전달받는다.

    observe( LifecycleOwner 객체, Observer 객체 ) 



프로젝트 적용 코드


위 과정들을 통해서 shoppi-android 프로젝트에 코드들을 적용해보았다.

먼저, 우리가 ViewModel 을 연결할 UI 컨트롤러가 Fragment 이므로, 다음과 같이 gradle 을 추가해주자.

implementation("androidx.fragment:fragment-ktx:1.5.4")	

"Sync Now" 를 해주자.

HomeViewModel

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.youngsun.shoppi.app.model.Banner
import com.youngsun.shoppi.app.model.Title
import com.youngsun.shoppi.app.repository.HomeRepository

// ViewModel - LifeCycle 을 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계.
// ViewModel 역할 -> StateHolder, 데이터 저장, 관리
class HomeViewModel( private val homeRepository: HomeRepository )  : ViewModel() {

    private val _title = MutableLiveData<Title>()
    val title : LiveData<Title> = _title

    private val _topBanners = MutableLiveData<List<Banner>>()
    val topBanners : LiveData<List<Banner>> = _topBanners

    init {
        loadHomeData()	// ViewModel 생성 시, 데이터를 _title에 load 해서, 외부에서 참조가능한 title에 전달.
    }

    private fun loadHomeData() {
        // TODO . Data Layer - Repository 에 요청
        val homeData = homeRepository.getHomeData()
        homeData?.let { homeData ->
            _title.value = homeData.title
            _topBanners.value = homeData.topBanners
        }
    }
}

홈 화면에서 받을 데이터를
외부에서 참조하지 못하는 경우, 변수명을 _ 언더 바로 시작하는 네이밍 컨벤션이 존재한다. ( _title )

그리고, 위 예제에서 살펴봤던 외부에서 참조하는 public 타입의 get프로퍼티명() 으로 Immutable 타입의 LiveData를 반환하는 메서드 대신, title 이라는 프로퍼티를 public으로 선언해주었다.

ViewModelFactory

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.youngsun.shoppi.app.AssetLoader
import com.youngsun.shoppi.app.repository.HomeAssetDataSource
import com.youngsun.shoppi.app.repository.HomeRepository
import com.youngsun.shoppi.app.ui.home.HomeViewModel

// 여러 ViewModel 이 추가되면서 ViewModel 을 여러 개 생성할 수 있으므로, common 패키지로 이동.
class ViewModelFactory(private val context: Context ): ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(HomeViewModel::class.java)) {	// HomeViewModel 타입인지 검사.
            val repository = HomeRepository(HomeAssetDataSource(AssetLoader(context)))
            return HomeViewModel(repository) as T
        } else {
            throw IllegalArgumentException("Failed to create ViewModel : ${modelClass.name}")
        }
    }
}

ViewModelFactory - ViewModel 생성 방법.

HomeFragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.youngsun.shoppi.app.GlideApp
import com.youngsun.shoppi.app.R
import com.youngsun.shoppi.app.ui.common.ViewModelFactory


class HomeFragment : Fragment() {

    private val viewModel: HomeViewModel by viewModels { ViewModelFactory(requireContext()) }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
    ): View? {


        return inflater.inflate(R.layout.fragment_home, container, false)
        // attachToRoot 값을 false 로 두는 이유 -> Activity 가 생성되기 이전에 Fragment 의 생성을 늦추기 위함.
    }

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

        val toolbarTitle = view.findViewById<TextView>(R.id.toolbar_home_title)
        val toolbarIcon = view.findViewById<ImageView>(R.id.toolbar_home_icon)
        val viewPager = view.findViewById<ViewPager2>(R.id.viewpager_home_banner)
        val viewPagerIndicator = view.findViewById<TabLayout>(R.id.viewpager_home_banner_indicator)

        // ( LifeCycleOwner, 옵저버 객체 )
        viewModel.title.observe(viewLifecycleOwner) { title ->
            // Observer - onChanged()  (Single Abstract Method -> Convert Lambda)

            toolbarTitle.text = title.text
            // Glide 로 아이콘 이미지 적용.
            GlideApp.with(this)
                .load(title.iconUrl)
                .into(toolbarIcon)
        }

        // ViewPager 의 어댑터는 당연시 초기화되어야 하고,
        viewPager.adapter = HomeBannerAdapter().apply {
            viewModel.topBanners.observe(viewLifecycleOwner) { banners ->
                submitList(banners) // 값이 바뀔때마다 어댑터에 전달되는 데이터를 바뀐 값으로 수정해주자.
            }
        }

        val pageWidth = resources.getDimension(R.dimen.viewpager_item_width)
        val pageMargin = resources.getDimension(R.dimen.viewpager_item_margin)
        val screenWidth = resources.displayMetrics.widthPixels

        val offset = screenWidth - pageWidth - pageMargin

        viewPager.offscreenPageLimit = 3
        // viewPager에서 페이지가 이동이 될 때, pageTransformer 적용하기.
        viewPager.setPageTransformer { page, position ->
            // Single Abstract Method (SAM) -> lambda
            page.translationX = position * -offset
        }

        // 탭 레이아웃 인디케이터
        TabLayoutMediator(viewPagerIndicator, viewPager) { tab, position ->

        }.attach()

    }

}

HomeFragment 에서 HomeViewModel 을 참조해서 데이터를 가져올 것이다.

Fragment 에서는 viewLifecycleOwner를 통해 LifecycleOwner 를 참조할 수 있다.
현재의 Lifecycle 상태를 알 수 있는 객체를 의미한다.


그리고 홈 화면에서 다루는 데이터를 담고 있는 Data LayerHomeRepository 를 구현해보자.

HomeDataSource

RepositoryDataSource로부터 데이터를 받는다.

import com.youngsun.shoppi.app.model.HomeData

interface HomeDataSource {
    // 원본 데이터를 요청 받는다.
    fun getHomeData() : HomeData?   // getJsonString 값이 null 일 수 있음 (nullable)
}

HomeDataSource가 인터페이스가 되어야 하는 이유 ?

여러 유형의 Data가 될 수 있는데, 예를 들면, 파일이 될 수도 있고, 네트워크 통신의 결과가 될 수도 있고, 로컬DB에 저장한 데이터가 될 수도 있다.

여러 유형의 DataSource에게 공통적으로 요청하는 것은 원본 데이터 이다.

이 요청들을 각각 데이터의 유형마다 Interface의 메서드로 만들고, 각 DataSource를 다루는 클래스를 만들어 Override 한다면, 여러 유형의 데이터에 대해서도 처리를 할 수 있다.

ex)
class HomeAssetDataSource + override getHomeData() : HomeData ,
class HomeFileDataSource + override getHomeFile() : HomeFileData ,
class HomeNetworkDataSource + override getHomeNetwork() : HomeNetworkData

HomeAssetDataSource

import com.google.gson.Gson
import com.youngsun.shoppi.app.AssetLoader
import com.youngsun.shoppi.app.model.HomeData

class HomeAssetDataSource(private val assetLoader: AssetLoader) : HomeDataSource {

    // Gson 객체 또한 다른 함수에서도 사용 될 수 있음.
    private val gson = Gson()

    override fun getHomeData(): HomeData? {
        // AssetLoader 는 여러 곳에서 호출 될 가능성이 높음. 따라서 매번 객체를 생성해서 사용하는 것 보다는 생성자로 받아서 재사용.
        // val assetLoader = AssetLoader()

        return assetLoader.getJsonString("home.json")?.let { homeJsonString ->
            gson.fromJson(homeJsonString, HomeData::class.java)
        }

        // Gson 객체 또한 다른 함수에서도 사용 될 수 있음.
        // val gson = Gson()
    }
}

home.json Asset 파일을 처리하는 HomeAssetDataSource 클래스이다.
nullable 값을 반환하는 getJsonString() 이므로, let scope function 을 사용해주는것이 좋다.

HomeRepository

import com.youngsun.shoppi.app.model.HomeData

// Home 화면에서 보여질 데이터 관리.
class HomeRepository( private val assetDataSource : HomeDataSource )  {

    fun getHomeData() : HomeData? {
        return assetDataSource.getHomeData()
    }
}

📌 참조


profile
Yoon's Dev Blog
post-custom-banner

0개의 댓글