MVVM 패턴에 사용되는 ViewModel, LiveData, Repository 구조에 대해서 알아보고 정리해보는 시간을 가지려고 한다.
ViewModel 공식문서 를 참조하여 작성하였다.
ViewModel
클래스는 LifeCycle을 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계되었다.
그래서 UI Layer
의 States Holder
역할을 할 수 있는 것이다. 이유는 아래와 같다.
Activity
의 경우, 최초 생성되고 화면이 rotate
되거나 finish
를 호출받으면 여러 LifeCycle Callback
이 일어나는 반면에, ViewModel
은 최초에 생성된 이 후에도 계속해서 같은 상태를 유지하는 것을 확인할 수 있다.
따라서, ViewModel
은 States holder
의 역할을 할 수 있는 것이다.
Activity
가 어느 상태이던 간에 언제든지 ViewModel
에서 데이터를 요청해서 사용할 수 있다.
Activity
가 종료될 때, ViewModel
또한 onCleared()
를 호출하며, 데이터를 메모리상에서 해제하는 작업을 할 수 있다.
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 Layer
의 Repository
에서 프로퍼티인 users
의 데이터를 받아온다.
getUsers()
에 의해서 lazy
프로퍼티인 user
가 호출되면,
loadUsers()
로 데이터를 fetch
받아와 담겨지며, lazy
하게 최초 한 번만 생성된다.
lazy
는 기록된 값을 불러오는재사용
성질이 있다. ( 초기화 구문은 한 번만 실행된 후, 기록된 값을 불러옴 )
getUsers()
Mutable
타입의 LiveData
를 Immutable
타입의 LiveData
로 반환해준다.
public
타입으로 외부에서 호출이 가능하기 때문에, Immutable
타입의 LiveData
를 반환해주는 것이다.
(원본 데이터의 수정을 막기 위해서)
그렇다면, 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.gradle
의dependencies
에ktx artifact
라이브러리를 추가해주어야 한다.
by viewModels()
를 호출해서 MyViewModel
객체를 생성한다.
ViewModel
에서 받아온 LiveData
를 observe
를 사용해서 받아온 데이터의 값이 바뀌었을 때, UI를 업데이트
하는 동작이 실행된다.
이유는 다음과 같다.
LiveData
의 인스턴스를 생성한다.
이 작업은 일반적으로 ViewModel
클래스 내에서 이루어진다.
onChanged()
메서드를 정의하는 Observer
객체를 만듭니다. 이 메서드는 LiveData
객체가 보유한 데이터가 변경되었을때, 실행되는 작업을 제어합니다. 일반적으로 Activity
또는 Fragment
에서 Observer
객체를 만듭니다.
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" 를 해주자.
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
으로 선언해주었다.
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 생성 방법.
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 Layer
의 HomeRepository
를 구현해보자.
Repository
는 DataSource
로부터 데이터를 받는다.
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
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 을 사용해주는것이 좋다.
import com.youngsun.shoppi.app.model.HomeData
// Home 화면에서 보여질 데이터 관리.
class HomeRepository( private val assetDataSource : HomeDataSource ) {
fun getHomeData() : HomeData? {
return assetDataSource.getHomeData()
}
}