AAC ViewModel은 Android Architecture Components ViewModel로
Android Jetpack의 구성요소이다.
예전에 다른 글에서 AAC ViewModel을 사용했었는데,
명확히 정리하지 않고 사용한 것 같아서 한 번 정리하고 사용하려고 한다.
AAC ViewModel이란?
MVVM에서의 ViewModel은 View가 ViewModel에서의 값을 Observe하여 Update하고,
ViewModel은 Model을 Update하는 역할을 담당한다.
AAC ViewModel은 안드로이드에서 자체적으로 만든 ViewModel로,
MVVM에서의 ViewModel과는 전혀 다른 개념이다.
- 안드로이드 생명주기를 고려해서 만들어진 ViewModel이다.
Activity/Fragment당 하나의 ViewModel만 생성 가능하다.
메모리 누수, 화면 회전과 같은 상황에서도 Data를 저장할 수 있다.
Activity Scope ViewModel의 경우에는 화면 회전에서도 살아있는 것을 알 수 있다.(Fragment Scope의 경우에는 화면 회전에서 자유롭지 못한 것 같다. 물론 Manifest에서 ConfigureChange 속성을 사용하면 될 것 같다.) - UI 관련 요소를 저장하고 관리한다.
따라서 Activity Scope의 ViewModel을 하나 만들어 놓으면
해당 Activity 위에 존재하는 Fragment들은 데이터를 쉽게 공유할 수 있게된다. - ViewModel이 로더 역할을 대체할 수 있다.
원래는 로더를 사용해서 데이터를 로드하고, 로더 매니저가 콜백을 통해서
UI Controller에게 알려주면 UI Controller가 그 때 반응해서 UI를 업데이트 시켰다.
ViewModel을 사용하면 UI Controller는 ViewModel(LiveData)을 Observe하다가
값이 업데이트되면 그 때 UI를 업데이트하면 된다.
즉, UI 컨트롤러는 데이터 로드 작업에서 분리되므로 클래스 간 의존성이 사라지게 된다. - 코루틴을 공식적으로 지원한다.
자세한 정보는 아래를 참조하면 된다.
AAC ViewModel을 사용한다 해서 MVVM 패턴이 되는 것은 아니다.
(위에서 말했듯이 MVVM 개념에서의 ViewModel과 AAC 개념에서의 ViewModel은 상관이 없는 용어이다.)
그러나 AAC ViewModel을 MVVM 패턴으로 구현할 수 있기 때문에(LiveData, Flow 등 사용)
AAC ViewModel로 MVVM 패턴을 구현하겠다.
이렇게 하면, 생명주기를 고려한 MVVM 패턴을 만들 수 있게 된다.
안드로이드 앱 아키텍처 가이드
안드로이드 앱에는 여러 앱 구성요소가 포함된다.
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를 통해 구현했다.
결과화면 & 앱 로직
코드를 살펴보기에 앞서서 결과화면과 로직 먼저 살펴보고 가자.
Server DB에 접근하는 Model은 없으므로 Room DB를 통한 Cache 저장은 하지 않도록 하겠다.
로직
- 앱 실행 시, Model을 통해 Data를 가져온다.
- [유저 정보 수정] 버튼을 누르면 유저의 정보를 수정할 수 있는 입력창들이 나오게 된다.
- - 1. 뒤로가기 버튼을 누르면 유저 정보 수정이 취소된다.
이 때 데이터는 다시 불러오지 않으며 입력했던 정보는 사라져야 한다.
- 2. [수정 완료] 버튼을 누르면 유저의 정보가 수정이 되고 Local DB에 저장이 된다.
이 때 잘못된 형식의 데이터를 입력하면 유저 정보 수정이 실패한다. - 앱을 종료했다가 다시 켜도 Data가 저장이 된다.
결과화면
맨 처음 앱을 설치하고 유저 정보를 수정했을 때 모습.
잘못된 포맷이 없으므로 수정이 된다.
잘못된 값을 넣었을 때에는 UPDATE가 되지 않게 된다.
또한 입력한 값을 업데이트 하지 않고 뒤로가기를 눌렀을 때에는 UPDATE가 취소가 되며
다시 편집 버튼을 들어갔을 때엔 현재 유저의 정보로 초기화를 해준다.
이 때 뒤로가기를 눌렀을 때 DB에 접근하는 것이 아니라
다시 현재 유저의 정보를 나타내야 한다.
('유저 정보를 불러왔습니다.' 메시지가 나오지 않는다.)
App 수준의 build.gradle
소스 코드
activity_main.xml - 메인 액티비티 레이아웃
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
</androidx.constraintlayout.widget.ConstraintLayout>
액티비티 위에 프래그먼트 두 개를 교체하면서 구현할 예정이다.
메인액티비티 레이아웃 파일에 컨테이너로 사용할 제약 레이아웃을 하나 만들어준다.
MainActivity - 메인 액티비티 소스코드
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.activity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.R
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.databinding.ActivityMainBinding
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.fragment.UserInfoFragment
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util.FragmentTransitionManager
class MainActivity : AppCompatActivity() {
lateinit var viewBinding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
setUpUserInfoFragment()
}
private fun setUpUserInfoFragment() {
FragmentTransitionManager()
.changeFragmentOnActivity(
this,
viewBinding.mainContainer.id,
UserInfoFragment(),
false)
}
}
메인 액티비티 소스코드.
ViewBinding만 사용했고
따로 만들어놓은 FragmentTransitionManager 클래스를 통해서 프래그먼트를 전환한다.
FragmentTransitionManager - 프래그먼트 전환을 도와주는 클래스
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
/**
* 프래그먼트의 전환을 도화주는 클래스입니다.
*/
class FragmentTransitionManager {
fun changeFragmentOnActivity(activity: FragmentActivity, containerID: Int, fragment: Fragment, addBackStack: Boolean) {
activity
.supportFragmentManager
.beginTransaction()
.replace(containerID, fragment)
.apply {
if(addBackStack) {
addToBackStack(null)
}
}
.commit()
}
//프래그먼트를 전환할 시 ChildFragmentManager를 사용할 때 (ex. 뷰 페이저에서 프래그먼트 전환)
fun changeFragmentInFragment(containerID: Int, parentFragment: Fragment, childFragment: Fragment, addBackStack: Boolean) {
parentFragment
.childFragmentManager
.beginTransaction()
.replace(containerID, childFragment)
.apply {
if(addBackStack) {
addToBackStack(null)
}
}
.commit()
}
}
프래그먼트 전환을 도와주는 클래스다.
매개변수로 받은 정보를 토대로 Fragment를 전환해준다.
BaseFragment - ViewBinding 객체 초기화를 쉽게하기 위해 만든 프래그먼트
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
abstract class BaseFragment<T: ViewDataBinding>: Fragment() {
lateinit var viewDataBinding: T
abstract var layoutId: Int
abstract val viewModel: ViewModel
// lateinit var를 사용해서 by lazy를 사용하지 않고 초기화해서 사용할 수도 있다.
abstract fun init()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewDataBinding = DataBindingUtil.inflate(inflater, layoutId, container, false)
viewDataBinding.lifecycleOwner = viewLifecycleOwner
init()
return viewDataBinding.root
}
}
ViewBinding 객체의 초기화 코드를 줄이기 위해 만든 BaseFragment.
유저정보 수정 프래그먼트와
유저정보를 나타내는 프래그먼트에서 이 추상 클래스를 상속받을 예정이다.
이제 데이터와 관련된 부분들에 대해 입력을 할 것이다.
데이터는 SharedPreferences를 사용해서 저장할 것이다.
MySharedPreferences
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.common
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.R
/**
* SharedPreferences는 간단한 정보 값을 XML기반의 파일형태로 저장해서 사용하는 것을 의미한다.
*/
class MySharedPreferences(context: Context) {
/*
* 첫번째 : XML파일이름
* 두번째 : 접근 권한
* MODE_PRIVATE : 이 앱에서만 접근이 가능하다.
* MODE_WORLD_READABLE : 모든 앱에서 읽기 가능
* MODE_WORLD_WRITEABLE : 모든 앱에서 쓰기 가능
* MODE_WORLD_PROCESS : 모든 앱에서 읽기와 쓰기 가능
*/
private val mySharedPreferences: SharedPreferences =
context.getSharedPreferences(context.getString(R.string.app_name), Context.MODE_PRIVATE)
/* GET */
fun getString(key: String, defaultValue: String?): String? {
return mySharedPreferences.getString(key, defaultValue)
}
fun getInt(key: String, defaultValue: Int): Int {
return mySharedPreferences.getInt(key, defaultValue)
}
/* SET
* commit()은 동기, apply()는 비동기이므로 apply()가 더 빠른 속도로 처리가 가능하다.
*/
fun setString(key: String, value: String?) {
mySharedPreferences.edit().putString(key, value).apply()
}
fun setInt(key: String, value: Int) {
mySharedPreferences.edit().putInt(key, value).apply()
}
}
MyApplication - 전역 어플리케이션 클래스
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.common
import android.app.Application
/**
* 이 곳에 SharedPreferences를 선언하는 이유는
* Context 참조 및 모든 곳에서 사용할 수 있게하기 위함이다.
*/
class MyApplication: Application() {
companion object {
lateinit var mySharedPreferences: MySharedPreferences
}
override fun onCreate() {
super.onCreate()
mySharedPreferences = MySharedPreferences(applicationContext)
}
}
MyData - Data 담을 클래스
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data
data class MyData (var userIdx: Int,
var userName: String,
var userEmail: String)
MyData 클래스의 틀을 사용해서 데이터를 불러오고 저장할 것이다.
MyModel - 데이터를 불러오고 저장하는 로직을 담을 클래스
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.common.MyApplication.Companion.mySharedPreferences
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data.MyData
class MyModel {
fun getUserInfo(): MyData {
return MyData(
mySharedPreferences.getInt("userIdx", -1),
mySharedPreferences.getString("userName", "등록된 이름이 없습니다.").toString(),
mySharedPreferences.getString("userEmail", "등록된 이메일이 없습니다.").toString(),
)
}
fun setUserInfo(userInfo: MyData) {
userInfo.let {
mySharedPreferences.setInt("userIdx", it.userIdx)
mySharedPreferences.setString("userName", it.userName)
mySharedPreferences.setString("userEmail", it.userEmail)
}
}
}
Model에서는 View의 요소를 사용할 수 없으므로 context 호출이 불가능하다.
따라서 전역 어플리케이션 클래스에서 생성해놓은 MySharedPreferences 객체를 통해서 데이터에 접근한다.
MyRepository - 아키텍처 가이드에 따른 Model에 접근하는 클래스
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.MyModel
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data.MyData
/**
* Repository를 사용할 경우 Model의 종류에 상관없이
* ViewModel에 데이터를 넘겨줄 때 일관된 형태로 넘겨줄 수 있다.
*/
class MyRepository {
private val model = MyModel()
fun getUserInfo() = model.getUserInfo()
fun setUserInfo(user: MyData) = model.setUserInfo(user)
}
접근하는 모델이 하나이므로 Repository는 없어도 되지만
아키텍처 권장 가이드에 따라 구현해 놓았다.
UserInfoViewModel - AAC ViewModel
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의 수명주기에 맞게 사용할 수 있는 데이터 클래스이다.
* 따라서 메모리 누수로부터 자유롭고 화면 회전과 같은 상황에서도 데이터를 보관할 수 있다.
*/
class UserInfoViewModel(private val myRepository: MyRepository): ViewModel() {
//Binding목적. true PostValue 시, 수정 성공. false PostValue 시, 수정 실패
//수정 화면에서는 userIdx를 가져올 목적으로 Observe한다. 이유는 userIdx가 Int형인데 양방향 바인딩은 String만 가능하기 때문.
private val _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 시, 수정 실패
private val _editComplete = MutableLiveData<EventWrapper<Boolean>>()
val editComplete: LiveData<EventWrapper<Boolean>>
get() = _editComplete
//이미 데이터를 가져왔는지 여부를 Check하는 메서드. 수정하지 않는 이상 DB에 한 번만 접근하기 위해서 만든 메서드.
fun checkAlreadyGetUserInfo(): Boolean {
// 현재 저장중인 LiveData의 값이 null이라면 가져오지 않았다는 뜻. null이 아니라면 이미 가져왔다는 뜻.
return currentUserInfo.value?.peekContent() != null
}
//User의 정보를 가져온다.
fun getUserInfo() {
//CoroutineScope(비동기)로 데이터를 가져온다.
CoroutineScope(Dispatchers.IO).launch {
val userData = myRepository.getUserInfo()
_currentUserInfo.postValue(EventWrapper(userData))
}
}
// Input, 즉 입력하는 유저의 정보를 현재 ViewModel에서 저장중인 데이터로 깊은복사 후 넣어준다.
fun setInputUserInfo() {
// 현재 유저 정보 데이터를 깊은 복사한 후에 inputUserInfo에 넣어준다.
// 깊은 복사를 하지 않을 경우, currentUserInfo의 객체의 변수의 주소들과 같은 변수의 주소들을 참조하게 된다.
val inputUser = currentUserInfo.value?.peekContent()?.copy()
inputUserInfo.postValue(inputUser)
}
// 유저의 정보를 Update
fun setUserInfo(_userIdx: String, _userName: String, _userEmail: String) {
val userIdx: Int
try {
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해준다.
메서드에 대한 설명은 변수의 설명에 나와있고 주석으로도 달아놨으므로 생략하겠다.
EventWrapper가 보이는데 이건 무엇일까?
EventWrapper
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util
/**
* EventWrapper
* https://github.com/nirmaljeffrey/SingleLiveEvent-EventWrapper-LiveData
* 옵저버가 Detach -> Attach 됐을 때 Observe하는 경우를 방지하기 위해 만들었다.
*
* SingleLiveEvent도 있지만 여러 개의 Observer 사용이 불가능해서 EventWrapper가 나은 판단이라 생각했다.
*
*/
class EventWrapper<T>(content: T) {
private val mContent: T // 현재 들어온 값
private var 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.
fun peekContent(): T {
return mContent
}
// 이전에 처리된 값인지 Return.
fun hasBeenObserved(): Boolean {
return hasBeenObserved
}
}
EventWrapper라는 클래스가 있는데, LiveData를 Observe할 때
같은 값에 대해 두 번 Observe되는 현상을 방지하기 위해 사용하게 된다.
위에서 다루었던 내용이다.
ViewModelFactory
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository.MyRepository
class UserInfoViewModelFactory(private val myRepository: MyRepository): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.getConstructor(MyRepository::class.java).newInstance(myRepository)
}
}
AAC ViewModel에 매개변수를 넘기기 위해서는 ViewModelFactory가 필요하다.
매개변수가 여러개일 경우에는 { , } 쉼표를 사용해서 getConstructor와 newInstance에 정의해 주면 된다.
이제 모든 구성요소를 살펴보았다.
먼저 유저의 정보를 나타내는 UserInfoFragment부터 살펴보자.
fragment_user_info.xml - 레이아웃
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/LightCyan">
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/user_info"
android:textColor="@color/black"
android:textSize="36sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/idx_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/text_view">
<TextView
android:id="@+id/idx_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_idx"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/idx_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{' ' + String.valueOf(viewModel.currentUserInfo.peekContent().userIdx)}"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toEndOf="@id/idx_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/idx_layout">
<TextView
android:id="@+id/name_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_name"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/name_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{' ' + viewModel.currentUserInfo.peekContent().userName}"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toEndOf="@id/name_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/name_layout">
<TextView
android:id="@+id/email_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_email"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/email_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{' ' + viewModel.currentUserInfo.peekContent().userEmail}"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toEndOf="@id/email_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/edit_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="64dp"
android:padding="16dp"
android:text="@string/edit_user_info_btn"
android:textColor="@color/MintCream"
android:background="@color/black"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/email_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
ViewModel을 Binding해서 UI를 나타내게 된다.
뷰모델에서 현재 유저의 정보를 나타내는 currentUserInfo LiveData를 Binding해서
UI에 보여준다.
유저 정보 수정 버튼을 누르면 유저 정보 수정 프래그먼트를 띄우도록 만들었다.
UserInfoFragment - 소스 코드
package com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.fragment
import android.util.Log
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.R
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.databinding.FragmentUserInfoBinding
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.repository.MyRepository
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.ui.base.BaseFragment
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.util.FragmentTransitionManager
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModel
import com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModelFactory
class UserInfoFragment: BaseFragment<FragmentUserInfoBinding>() {
override var layoutId = R.layout.fragment_user_info
//만일 Activity 범위 내에서 ViewModel을 초기화하려면 다음과 같이 작성해주면 된다.
//ViewModelStoreOwner를 Acvitiy로 설정해 주는 것이다.
override val viewModel: UserInfoViewModel by lazy {
ViewModelProvider(requireActivity(), UserInfoViewModelFactory(MyRepository())).get(UserInfoViewModel::class.java)
}
/* by viewModels를 사용해서 ViewModel을 초기화하면 ViewModel의 생명주기는 Fragment의 생명주기를 따르게 된다. (이는 by lazy 기반이다.)
by lazy를 사용하면 초기화가 해당 객체가 최초로 사용되기 전까지 미뤄진다.
override val viewModel: UserInfoViewModel by viewModels {
UserInfoViewModelFactory(MyRepository())
}*/
override fun init() {
bindViewModel()
getUserInfo()
setUpBtnListener()
}
//ViewModel 초기화 되는 곳.
private fun bindViewModel() {
viewDataBinding.viewModel = viewModel
Log.d("UserInfoFragment", "ViewModel Address : $viewModel")
}
private fun getUserInfo() {
// 만일 이미 데이터를 가져온 상황이라면 가져오지 않는다.
if(viewModel.checkAlreadyGetUserInfo().not()) {
viewModel.getUserInfo()
Toast.makeText(context, getString(R.string.get_user_info_toast), Toast.LENGTH_SHORT).show()
}
}
private fun setUpBtnListener() {
viewDataBinding.editBtn.setOnClickListener {
FragmentTransitionManager()
.changeFragmentOnActivity(
requireActivity(),
R.id.main_container,
EditFragment(),
true
)
}
}
}
위에서 설명한 BaseFragment를 상속받았다.
ViewModel의 초기화를 by lazy를 사용해서 구현했다.
최초로 사용되기 전까지 초기화가 되지 않은 상태로 있게된다.
원래 by viewModels를 이용하려 했으나 이는 ViewModel의 ViewModelStoreOwner가 'this'가 된다.
오늘은 여러 프래그먼트에서 ViewModel을 공유하는 프로젝트이므로 해당 방법으로 초기화하지 않았다.
bindViewModel() 메서드로 레이아웃에 뷰모델을 바인딩해준다.
getUserInfo() 메서드로 유저의 정보를 불러온다.
뷰모델에서 checkAlreadyGetUserInfo()메서드는 현재 유저 정보의 값이 null이라면 false를 return한다.
DB에 프래그먼트가 전환될 때마다 접근하면 코스트가 심할 수 있으므로 이처럼 만들어준다.
setUpBtnListener() 메서드로 유저 정보 수정 버튼을 누르면
유저 정보 수정 프래그먼트로 전환되도록 만들어준다.
fragment_edit.xml - 레이아웃
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="com.khs.aacviewmodelandrecommandedarchitectureexampleproject.model.data.MyData"/>
<variable
name="viewModel"
type="com.khs.aacviewmodelandrecommandedarchitectureexampleproject.viewmodel.UserInfoViewModel" />
<variable
name="userIdx"
type="java.lang.String" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/LightCyan">
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/edit_user_info_title"
android:textColor="@color/black"
android:textSize="36sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/idx_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/text_view">
<TextView
android:id="@+id/idx_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_idx"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/idx_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="6dp"
android:inputType="number"
android:text="@={userIdx}"
android:textColor="@color/black"
android:textSize="21sp"
android:background="@drawable/shape_edit_text"
app:layout_constraintStart_toEndOf="@id/idx_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/idx_layout">
<TextView
android:id="@+id/name_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_name"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/name_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="6dp"
android:text="@={viewModel.inputUserInfo.userName}"
android:textColor="@color/black"
android:textSize="21sp"
android:background="@drawable/shape_edit_text"
app:layout_constraintStart_toEndOf="@id/name_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toBottomOf="@id/name_layout">
<TextView
android:id="@+id/email_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_email"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/email_value_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="6dp"
android:inputType="textEmailAddress"
android:text="@={viewModel.inputUserInfo.userEmail}"
android:textColor="@color/black"
android:textSize="21sp"
android:background="@drawable/shape_edit_text"
app:layout_constraintStart_toEndOf="@id/email_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/edit_complete_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="64dp"
android:padding="16dp"
android:text="@string/edit_user_info_complete_btn"
android:textColor="@color/MintCream"
android:background="@color/black"
android:onClick="@{() -> viewModel.setUserInfo(userIdx, viewModel.inputUserInfo.userName, viewModel.inputUserInfo.userEmail)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/email_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
데이터 바인딩을 통해서 구현했다.
ViewModel에서 OnClick Method의 로직을 만들어서 데이터 바인딩으로 바로 onClick 속성으로 지정해줬다.
입력하는 EditText들에는 ViewModel에 InputUserInfo의 값들을 양방향 데이터로 처리했다.
이 때 userIdx는 Int형(정수형) 변수로 양방향 데이터가 불가능해서
따로 userIdx라는 String형 data를 layout에서 지정해줘서 양방향 데이터 바인딩이 가능하도록 했다.
EditFragment - 소스 코드
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
class EditFragment: BaseFragment<FragmentEditBinding>() {
override var layoutId = R.layout.fragment_edit
override val viewModel: UserInfoViewModel by lazy {
ViewModelProvider(requireActivity(), UserInfoViewModelFactory(MyRepository())).get(UserInfoViewModel::class.java)
}
override fun init() {
bindViewModel()
initInputData()
setUpObserver()
}
// 만일 입력하고 취소했어도 Update되는 것을 방지하기 위함.
private fun initInputData() {
viewModel.setInputUserInfo()
}
// ViewModel 초기화 되는 곳. 그러나 MainActivity Scope의 UserInfoViewModel이 이미 존재하므로 해당 ViewModel을 가져오게 된다.
private fun bindViewModel() {
viewDataBinding.viewModel = viewModel
Log.d("EditFragment", "ViewModel Address : $viewModel")
}
private fun setUpObserver() {
//유저 정보 가져온 후에 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()
} else if(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 되는 경우 )
예시 프로젝트는 위에서 확인이 가능하다.