MVVM에서의 ViewModel은 View가 ViewModel에서의 값을 Observe하여 Update하고,
ViewModel은 Model을 Update하는 역할을 담당한다.
AAC ViewModel은 안드로이드에서 자체적으로 만든 ViewModel로,
MVVM에서의 ViewModel과는 전혀 다른 개념이다.
안드로이드 생명주기를 고려해서 만들어진 ViewModel이다. Activity/Fragment당 하나의 ViewModel만 생성 가능하다. 메모리 누수, 화면 회전과 같은 상황에서도 Data를 저장할 수 있다. 화면 회전 상황에서 AAC ViewModel과 Activity의 생명주기. Activity Scope ViewModel의 경우에는 화면 회전에서도 살아있는 것을 알 수 있다.(Fragment Scope의 경우에는 화면 회전에서 자유롭지 못한 것 같다. 물론 Manifest에서 ConfigureChange 속성을 사용하면 될 것 같다.)
UI 관련 요소를 저장하고 관리한다. 따라서 Activity Scope의 ViewModel을 하나 만들어 놓으면 해당 Activity 위에 존재하는 Fragment들은 데이터를 쉽게 공유할 수 있게된다.
ViewModel이 로더 역할을 대체할 수 있다. 로더를 사용한 데이터 로드 & UI 업데이트 로직
원래는 로더를 사용해서 데이터를 로드하고, 로더 매니저가 콜백을 통해서 UI Controller에게 알려주면 UI Controller가 그 때 반응해서 UI를 업데이트 시켰다. ViewModel을 사용한 데이터 로드 & UI 업데이트 로직
ViewModel을 사용하면 UI Controller는 ViewModel(LiveData)을 Observe하다가 값이 업데이트되면 그 때 UI를 업데이트하면 된다. 즉, UI 컨트롤러는 데이터 로드 작업에서 분리되므로 클래스 간 의존성이 사라지게 된다.
안드로이드 앱에는 여러 앱 구성요소가 포함된다. 1. 액티비티 : 사용자와 상호작용하기 위한 진입점. (Activity) 2. 서비스 : 백그라운드에서 앱을 계속 실행하기 위한 다목적 진입점. (Service) 3. 콘텐츠 제공자 : 파일 시스템, SQLite, 데이터베이스, 웹이나 앱이 액세스할 수 있는 저장 가능한 앱 데이터의 공유 가능한 형태들을 관리한다. (ContentProvider) 4. 브로드캐스트 수신자 : 시스템이 사용자 플로우 밖에서 이벤트를 앱에 전달하도록 지원하는 구성 요소. 앱이 시스템 전체의 브로드캐스트 알림에 응답할 수 있도록 해준다. 가 대표가 된다. (BroadcastReceiver)
개발자는 앱 매니페스트에서 앱 구성요소의 대부분을 선언하고 안드로이드 OS에서는 매니페스트를 확인해서 기기의 사용자 환경에 앱을 통합하는 방법을 결정한다.
휴대폰에서는 앱 실행 중 전화가 오거나, 램 공간이 부족해서 OS에서 새로운 앱 실행을 위해 일부 앱을 강제 종료 시킬 수 있다. 이러한 환경 조건을 고려해 볼 때 앱 구성요소는 개별적이고 비순차적으로 실행될 수 있고 운영체제나 사용자가 언제든지 앱 구성요소를 제거할 수 있게 되는데, 이런 경우들은 직접 제어할 수 없기 때문에 1. 앱 구성요소에 데이터나 상태를 저장해서는 안되며, 2. 앱 구성요소가 서로 종속되어서는 안된다.
일반 아키텍처 원칙 1. 관심사 분리 : 액티비티 / 프래그먼트에 모든 코드를 작성해서는 안된다. UI 기반의 클래스는 UI & 운영체제 상호작용을 처리하는 로직만 포함해야 한다. 이러한 클래스들을 최대한 가볍게 유지해서 안드로이드 생명 주기와 관련된 문제들을 피해야 한다.
2. 모델에서 UI 도출 : 모델이란 Local DB(Room) / Server DB를 거쳐서 데이터를 가져오는 모든 로직을 포함한다.
모델은 View와 독립된 존재이므로 앱의 수명 주기 문제에 영향을 받지 않는다.
잘 정의된 모델에서 UI를 도출해야 테스트가 쉽고 일관성을 유지할 수 있게된다.
(네트워크가 끊어져도 앱이 죽지 않고, 메모리 확보를 위해 앱이 강제종료되어도 데이터가 살아있는 앱)
앱 아키텍처 가이드 이미지
각 구성요소가 아래 계층의 구성요소에만 종속된다.
Activity / Fragment -> ViewModel
ViewModel -> Repository,
Repository는 유일하게 여러 다른 클래스에 종속되는데, Local DB(Room) / Server DB(Retrofit)에 종속된다.
이는 ViewModel에 어디에서 가져온 데이터든지 일관성있는 데이터를 제공해주기 위함이다.
위와 같이 설계할 경우 네트워크 연결과 관계없이, 얼마나 오랜만에 앱을 켰든지간에
앱에서는 Room DB에 저장해놓았던 데이터를 통해 UI를 미리 표시해준다.
그리고 이 데이터가 오래된 경우에는 Repository를 통해 데이터를 Update하게된다.
위 앱 아키텍처 가이드 문서를 보면 Room DB를 사용한 예시 프로젝트가 나와있지만,
나는 다르게 만들어서 적용해보았다.
Room DB나 Retrofit2 대신에 SharedPreferences를 통해 구현했다.
- 1. 뒤로가기 버튼을 누르면 유저 정보 수정이 취소된다. 이 때 데이터는 다시 불러오지 않으며 입력했던 정보는 사라져야 한다. - 2. [수정 완료] 버튼을 누르면 유저의 정보가 수정이 되고 Local DB에 저장이 된다. 이 때 잘못된 형식의 데이터를 입력하면 유저 정보 수정이 실패한다.
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.common
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.R
/**
* SharedPreferences는 간단한 정보 값을 XML기반의 파일형태로 저장해서 사용하는 것을 의미한다.
*/classMySharedPreferences(context: Context) {
/*
* 첫번째 : XML파일이름
* 두번째 : 접근 권한
* MODE_PRIVATE : 이 앱에서만 접근이 가능하다.
* MODE_WORLD_READABLE : 모든 앱에서 읽기 가능
* MODE_WORLD_WRITEABLE : 모든 앱에서 쓰기 가능
* MODE_WORLD_PROCESS : 모든 앱에서 읽기와 쓰기 가능
*/privateval mySharedPreferences: SharedPreferences =
context.getSharedPreferences(context.getString(R.string.app_name), Context.MODE_PRIVATE)
/* GET */fungetString(key: String, defaultValue: String?): String? {
return mySharedPreferences.getString(key, defaultValue)
}
fungetInt(key: String, defaultValue: Int): Int {
return mySharedPreferences.getInt(key, defaultValue)
}
/* SET
* commit()은 동기, apply()는 비동기이므로 apply()가 더 빠른 속도로 처리가 가능하다.
*/funsetString(key: String, value: String?) {
mySharedPreferences.edit().putString(key, value).apply()
}
funsetInt(key: String, value: Int) {
mySharedPreferences.edit().putInt(key, value).apply()
}
}
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.common
import android.app.Application
/**
* 이 곳에 SharedPreferences를 선언하는 이유는
* Context 참조 및 모든 곳에서 사용할 수 있게하기 위함이다.
*/classMyApplication: Application() {
companionobject {
lateinitvar mySharedPreferences: MySharedPreferences
}
overridefunonCreate() {
super.onCreate()
mySharedPreferences = MySharedPreferences(applicationContext)
}
}
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.MyModel
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data.MyData
/**
* Repository를 사용할 경우 Model의 종류에 상관없이
* ViewModel에 데이터를 넘겨줄 때 일관된 형태로 넘겨줄 수 있다.
*/classMyRepository{
privateval model = MyModel()
fungetUserInfo() = model.getUserInfo()
funsetUserInfo(user: MyData) = model.setUserInfo(user)
}
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data.MyData
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository.MyRepository
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util.EventWrapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.lang.Exception
import java.lang.NumberFormatException
/**
* AAC ViewModel Class.
* UI와 관련된 데이터를 저장하는 클래스이다.
* Android의 수명주기에 맞게 사용할 수 있는 데이터 클래스이다.
* 따라서 메모리 누수로부터 자유롭고 화면 회전과 같은 상황에서도 데이터를 보관할 수 있다.
*/classUserInfoViewModel(privateval myRepository: MyRepository): ViewModel() {
//Binding목적. true PostValue 시, 수정 성공. false PostValue 시, 수정 실패//수정 화면에서는 userIdx를 가져올 목적으로 Observe한다. 이유는 userIdx가 Int형인데 양방향 바인딩은 String만 가능하기 때문.privateval _currentUserInfo = MutableLiveData<EventWrapper<MyData>>()
val currentUserInfo: LiveData<EventWrapper<MyData>>
get() = _currentUserInfo
//Binding목적. 정보 수정하는 곳에서의 유저 정보.//양방향 바인딩에서는 수정을 하지 않아도 수정사항이 apply가 돼버리므로 따로 LiveData를 만들어준다.val inputUserInfo by lazy { MutableLiveData<MyData>() }
//Observe목적. true PostValue 시, 수정 성공. false PostValue 시, 수정 실패privateval _editComplete = MutableLiveData<EventWrapper<Boolean>>()
val editComplete: LiveData<EventWrapper<Boolean>>
get() = _editComplete
//이미 데이터를 가져왔는지 여부를 Check하는 메서드. 수정하지 않는 이상 DB에 한 번만 접근하기 위해서 만든 메서드.funcheckAlreadyGetUserInfo(): Boolean {
// 현재 저장중인 LiveData의 값이 null이라면 가져오지 않았다는 뜻. null이 아니라면 이미 가져왔다는 뜻.return currentUserInfo.value?.peekContent() != null
}
//User의 정보를 가져온다.fungetUserInfo() {
//CoroutineScope(비동기)로 데이터를 가져온다.
CoroutineScope(Dispatchers.IO).launch {
val userData = myRepository.getUserInfo()
_currentUserInfo.postValue(EventWrapper(userData))
}
}
// Input, 즉 입력하는 유저의 정보를 현재 ViewModel에서 저장중인 데이터로 깊은복사 후 넣어준다.funsetInputUserInfo() {
// 현재 유저 정보 데이터를 깊은 복사한 후에 inputUserInfo에 넣어준다.// 깊은 복사를 하지 않을 경우, currentUserInfo의 객체의 변수의 주소들과 같은 변수의 주소들을 참조하게 된다.val inputUser = currentUserInfo.value?.peekContent()?.copy()
inputUserInfo.postValue(inputUser)
}
// 유저의 정보를 UpdatefunsetUserInfo(_userIdx: String, _userName: String, _userEmail: String) {
val userIdx: Inttry {
userIdx = Integer.valueOf(_userIdx) // UserIdx 입력 포맷 확인.if(_userName.isEmpty() || _userEmail.isEmpty()) {
// 유저이름, 유저이메일 비었는지 확인.
_editComplete.postValue(EventWrapper(false))
} else {
//정상 입력이라면, CoroutineScope(비동기)로 입력받은 데이터 저장.
CoroutineScope(Dispatchers.IO).launch {
val inputData = MyData(userIdx, _userName, _userEmail)
myRepository.setUserInfo(inputData)
_editComplete.postValue(EventWrapper(true))
getUserInfo()
}
}
} catch (e: NumberFormatException) {
// UserIdx 입력한 값이 이상한 값이라면, 여기에서 실패하게 된다.
e.printStackTrace()
_editComplete.postValue(EventWrapper(false))
} catch (e: Exception) {
e.printStackTrace()
_editComplete.postValue(EventWrapper(false))
}
}
}
각 변수는 다음과 같다.
currentUserInfo : 불러온 유저의 정보를 저장하고 있는 LiveData 유저 정보 레이아웃 파일에서 Binding해서 정보를 나타내준다. getUserInfo() 메서드로 Repository를 통해 Model에 접근해서 가져온 유저의 정보를 currentUSerInfo에 postValue한다.
inputUserInfo : 입력한 유저의 정보를 저장하고 있는 LiveData 정보 수정 레이아웃 파일에서 EditText와 양방향 Binding해서 입력받은 값에따라 값이 바뀐다. 정보 수정 프래그먼트에 진입 시, setInputUserInfo() 메서드로 현재 유저 정보를 깊은 복사한 객체를 inputUserInfo에 postValue해준다.
editComplete : 유저의 정보가 수정됐는지 여부를 Observe할 목적인 LiveData. setUserInfo() 메서드를 통해 입력한 유저의 정보가 올바른 포맷인지 확인하고 Repository를 통해 Model에 접근해서 유저의 정보를 Update하게 된다. 이상한 포맷일 경우 false를 postValue하고 올바르게 입력한 경우 true를 postValue해준다.
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util
/**
* EventWrapper
* https://github.com/nirmaljeffrey/SingleLiveEvent-EventWrapper-LiveData
* 옵저버가 Detach -> Attach 됐을 때 Observe하는 경우를 방지하기 위해 만들었다.
*
* SingleLiveEvent도 있지만 여러 개의 Observer 사용이 불가능해서 EventWrapper가 나은 판단이라 생각했다.
*
*/classEventWrapper<T>(content: T) {
privateval mContent: T // 현재 들어온 값privatevar hasBeenObserved = false// 예전에 다루어진 Content인가?init {
requireNotNull("NULL Values in EveneWrapper are Not Allowed.")
mContent = content
}
//이전에 Observe한 값은 처리하지 않는 변수val contentIfNotHandled: T?
get() = if(hasBeenObserved) {
null
} else {
hasBeenObserved = true
mContent
}
// Observe 여부 상관없이 가장 최신 Data return.funpeekContent(): T {
return mContent
}
// 이전에 처리된 값인지 Return.funhasBeenObserved(): Boolean {
return hasBeenObserved
}
}
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.fragment
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.R
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.databinding.FragmentEditBinding
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository.MyRepository
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.base.BaseFragment
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModel
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModelFactory
classEditFragment: BaseFragment<FragmentEditBinding>() {
overridevar layoutId = R.layout.fragment_edit
overrideval viewModel: UserInfoViewModel by lazy {
ViewModelProvider(requireActivity(), UserInfoViewModelFactory(MyRepository())).get(UserInfoViewModel::class.java)
}
overridefuninit() {
bindViewModel()
initInputData()
setUpObserver()
}
// 만일 입력하고 취소했어도 Update되는 것을 방지하기 위함.privatefuninitInputData() {
viewModel.setInputUserInfo()
}
// ViewModel 초기화 되는 곳. 그러나 MainActivity Scope의 UserInfoViewModel이 이미 존재하므로 해당 ViewModel을 가져오게 된다.privatefunbindViewModel() {
viewDataBinding.viewModel = viewModel
Log.d("EditFragment", "ViewModel Address : $viewModel")
}
privatefunsetUpObserver() {
//유저 정보 가져온 후에 userIdx를 String으로 변경한 값을 UI에 표시.
viewModel.currentUserInfo.observe(viewLifecycleOwner) {
viewDataBinding.userIdx = it.peekContent().userIdx.toString()
}
//유저 정보 수정 완료 시, UserInfoFragment로 전환.
viewModel.editComplete.observe(viewLifecycleOwner) {
val value = it.contentIfNotHandled // 가장 최근의 값을 기록해둔다. 이제 contentIfNotHandled는 NULL이 된다.if(value == true) {
Toast.makeText(context, getString(R.string.edit_user_info_complete_toast), Toast.LENGTH_SHORT).show()
requireActivity().supportFragmentManager.popBackStack()
} elseif(value == false){
Toast.makeText(context, getString(R.string.edit_user_info_fail_toast), Toast.LENGTH_SHORT).show()
}
}
}
}
UserInfoFragment와 비슷한 양상이다.
위에서 설명한 BaseFragment를 상속받았다.
UserInfoFragment에서 이미 MainActivity의 Scope로 생성했으므로 해당 ViewModel이 Return된다.
이렇게 되면 ViewModel을 공유해서 사용할 수 있게된다.
bindViewModel() 메서드로 레이아웃에 뷰모델을 바인딩해준다.
initInputData() 메서드로 현재 저장중인 유저의 정보를 currentUserInfo LiveData를 통해서
가져온 후에 ViewModel의 inputUserInfo에 postValue해준다.
Model에 접근을 최소화하기 위함이다.
setUpObserver()에는 두 개의 Observer가 존재한다.
currentUserInfo에 대한 observe로 userIdx를 String 형태로 가져온 후에 레이아웃에서 선언한 userIdx에 넣어준다.
editComplete에 대한 observe로 true일 경우 정보 수정이 완료됐으므로 유저 정보 수정 프래그먼트를 종료시켜준다. false일 경우 정보 수정이 실패됐다는 토스트 메시지를 띄워준다.
여기서 만일 EventWrapper가 없다면 정보 수정이 완료된 후에 다시 EditFragment에 진입하면 true가 계속 observe되어서 수정이 완료됐다고 나오게 된다. Activity Scope의 범위에서 ViewModel을 공유할 경우 이런 경우를 조심해야 한다.
Activity Scope에서 ViewModel을 통해 Data를 공유하게 되면
Bundle로 데이터를 넘겨주고 넘겨받는 경우를 줄일 수 있게 되는 장점도 있는 것 같다.
단점은 observe에서 처리해야할 경우가 생기게 된다. ( 두 번 observe 되는 경우 )