안드로이드에서는 사용하는 디자인 패턴들이 여러 가지가 있다.
예전에는 대표적인 디자인 패턴이 MVC (Model - View - Controller)였는데,
요새는 MVVM (Model - View - ViewModel)인 것 같다.
MVP (Model - View - Presenter) 패턴은 잘 못 본것 같은데,
우아한 형제들에서 런칭중인 배달의 민족 앱은 MVP + Repository 디자인 패턴을 사용한다고 한다.
나는 요즘에는 MVVM 패턴으로 개발을 하려고 노력중인데, 내가 제대로 쓰고는 있는건지
MVVM 패턴을 쓴다고 생각하는데 MVC처럼 구현하는 건 아닌지 의문점이 들어서
이번에 디자인 패턴을 정리해보려고 한다.
예시 프로젝트를 들어서 설명을 할 것인데, 프로젝트 앱이 어떤 형태인지 화면을 먼저 보여주고 설명하겠다.
모든 디자인 패턴에 같은 화면, 기능, 로직을 구현한 후 장단점에 대해 알아보겠다.
앱의 로직은 다음과 같다.
- 메인 화면에서 원하는 디자인 패턴의 버튼을 정한다. ( 모든 화면의 기능은 같다. )
- 유저의 정보를 불러온다.
- 유저의 수정할 정보를 입력하고 수정 완료 버튼을 누른다.
- 로딩 창이 나오며 Model을 통해서 DB의 데이터를 수정한다.
- 수정된 데이터를 다시 받아와서 View에 나타낸다.
이제 디자인 패턴을 살펴보겠다.
주의해야 할 점이 몇 가지 있다.
1. 사람마다 같은 디자인 패턴을 써도 다르게 구현할 수 있다.
(물론 MVVM 디자인 패턴을 썼다고 하고 MVC처럼 구현한 것은 잘못 구현한 것이다.)
2. 각각의 디자인 패턴들 중 어느게 좋고 나쁘고는 없다. 자신이 구현해야하는 앱에 맞게 쓰면 된다.
디자인 패턴에서의 MV, Model과 View
디자인 패턴에서 공통적인 부분 MV, Model과 View는 먼저 설명하고 가겠다.
- View : 말 그대로 화면에 나타나는 UI. 사용자가 볼 수 있는 데이터들이다.
안드로이드에서는 Layout 파일이라고 생각하면 된다. - Model : 데이터를 처리관련 로직을 담당하는 곳이다.
Local DB / Server DB에 접근해서 데이터를 가져오거나 수정한다.
모든 디자인 패턴에서 Model은 독립적이다.
즉, 어떠한 곳에도 의존성을 두지 않는다는 뜻이다.
오늘 프로젝트에서 사용할 데이터의 정보와 Model 클래스는 다음와 같다.
Retrofit2 / Room DB 등등의 데이터를 다루는 곳이 이 Model 클래스라고 생각하면 된다.
이 예시 프로젝트에서는 실제 DB 데이터가 아닌 임의의 정해진 데이터를 사용해서 정리하겠다.
View는 따로 설명하지 않겠다. 위에 보여준 화면구성이 View라고 생각하면 된다.
프래그먼트 전환 코드를 줄여주는 클래스
package com.khs.designpatternexampleproject.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()
}
fun changeFragmentInFragment(containerID: Int, parentFragment: Fragment, childFragment: Fragment, addBackStack: Boolean) {
parentFragment
.childFragmentManager
.beginTransaction()
.replace(containerID, childFragment)
.apply {
if(addBackStack) {
addToBackStack(null)
}
}
.commit()
}
}
코드의 중복을 막아주는 클래스를 만들어서 사용했다.
BaseFragment
package com.khs.designpatternexampleproject.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 com.khs.designpatternexampleproject.ui.main.LoadingDialogFragment
abstract class BaseFragment<T: ViewDataBinding>: Fragment() {
lateinit var binding: T
abstract val layoutId: Int
val loadingDialog = LoadingDialogFragment()
abstract fun init()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, layoutId, container, false)
binding.lifecycleOwner = viewLifecycleOwner
init()
return binding.root
}
}
뷰 바인딩을 쉽게 처리해주기위해 BaseFragment를 사용했다.
로딩다이얼로그
package com.khs.designpatternexampleproject.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import com.khs.designpatternexampleproject.databinding.FragmentLoadingDialogBinding
class LoadingDialogFragment: DialogFragment() {
lateinit var binding: FragmentLoadingDialogBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentLoadingDialogBinding.inflate(inflater, container, false)
isCancelable = false
return binding.root
}
}
데이터를 수정했을 때, 로딩 중 창이 나오게 하는 다이얼로그이다.
메인 액티비티 / 메인 프래그먼트
package com.khs.designpatternexampleproject.ui.main
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.khs.designpatternexampleproject.databinding.ActivityMainBinding
import com.khs.designpatternexampleproject.util.FragmentTransitionManager
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setMainFragment()
}
private fun setMainFragment() {
FragmentTransitionManager().changeFragmentOnActivity(this, binding.mainContainer.id, MainFragment(), false)
}
}
메인 액티비티. 따른 로직은 없고, 바로 메인 프래그먼트를 띄운다.
package com.khs.designpatternexampleproject.ui.main
import com.khs.designpatternexampleproject.R
import com.khs.designpatternexampleproject.databinding.FragmentMainBinding
import com.khs.designpatternexampleproject.ui.mvc.MVCFragment
import com.khs.designpatternexampleproject.ui.base.BaseFragment
import com.khs.designpatternexampleproject.ui.mvp.MVPFragment
import com.khs.designpatternexampleproject.ui.mvvm.MVVMFragment
import com.khs.designpatternexampleproject.util.FragmentTransitionManager
/**
* 디자인 패턴(MVC, MVP, MVVM)에서의 MV는 모두 동일합니다.
* Model : 데이터를 저장하는 계층입니다. Local DB / Server DB에서 데이터에 접근하는 모든 로직을 통틀어서 Model이라고 합니다.
* 모든 디자인 패턴에서, Model은 어디에도 의존되지 않는 독립적인 존재입니다.
*
* View : 데이터를 시각화하는 곳입니다. 즉, UI를 뜻합니다.
*/
class MainFragment: BaseFragment<FragmentMainBinding>() {
override val layoutId: Int = R.layout.fragment_main
override fun init() {
setBtnClickListener()
}
private fun setBtnClickListener() {
binding.mvcBtn.setOnClickListener {
FragmentTransitionManager().changeFragmentOnActivity(requireActivity(), R.id.main_container, MVCFragment(), true)
}
binding.mvpBtn.setOnClickListener {
FragmentTransitionManager().changeFragmentOnActivity(requireActivity(), R.id.main_container, MVPFragment(), true)
}
binding.mvvmBtn.setOnClickListener {
FragmentTransitionManager().changeFragmentOnActivity(requireActivity(), R.id.main_container, MVVMFragment(), true)
}
}
}
메인 프래그먼트.
각 디자인 패턴의 버튼을 누르면 각 프래그먼트로 전환해주는 동작이 들어있다.
MVC ( Model - View - Controller )
Model - View - Controller의 줄임말 MVC 디자인 패턴이다.
안드로이드 앱 초창기 개발 시절에 많이 사용했던 디자인 패턴이다. 요새는 많이 사용하지 않는다.
안드로이드에서의 MVC
안드로이드에서는 Activity / Fragment가 View이자 Controller의 기능을 모두 수행하게 된다.
"Activity / Fragment는 View지, Controller가 아니다" 라는 의견도 있다.
나는 View이자 Controller라는 의견에 약간 더 동의하지만,
Controller는 아니다 라는 의견도 동의한다.
실제로 정해진 틀은 View와 Controller는 분리돼 있어야 하는게 맞기 때문이다.
나는 Activity / Fragment의 Layout 파일은 View,
Source Code는 Controller라고 생각한다.
다시 설명하지만 디자인 패턴에는 틀은 있어도 정답은 없다.
이번 글에서는
안드로이드에서는 Activity / Fragment가 View이자 Controller의 기능을 모두 수행하게 된다. |
라는 것에 기반을 두고 MVC 디자인 패턴을 정리하겠다.
Controller는 위 그림에서 보다시피 다음과 같은 작업을 수행한다.
- View에 UI를 갱신시킨다.
- Model을 통해서 데이터를 Update시킨다.
이 때, View는 Model을 통해서 데이터를 가져오고 UI에 나타내게 된다.
MVC패턴에서는 프로젝트 앱의 화면을 구성하기 위해 하나의 Fragment만 있으면 된다.
package com.khs.designpatternexampleproject.ui.mvc
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.khs.designpatternexampleproject.R
import com.khs.designpatternexampleproject.databinding.FragmentUserInfoNoDataBindingBinding
import com.khs.designpatternexampleproject.model.User
import com.khs.designpatternexampleproject.model.UserModel
import com.khs.designpatternexampleproject.ui.base.BaseFragment
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* MVC에서는 Activity / Fragment가 View와 Controller의 기능을 모두 수행한다.
* 따라서 Activity / Fragment에서 모든 기능을 구현할 수 있다.
* (물론 실제로는 View 계층과 Controller는 분리되어져야 맞다.
* 그러나 안드로이드에서는 Activity / Fragment가 View / Controller 역할을 하는 것을 MVC 패턴이라고 부른다고 한다.)
*
* MVC에서 Controller는 View에는 UI갱신, Model에는 데이터 업데이트 역할을 해줍니다.
* View는 Model을 직접 참조할 수도있고, Controller를 통해 간접적으로 참조할 수 있다.
* 그러나 결국에는 Model이 있어야만 View 표시가 가능하므로 View, Model간에 의존성이 생기게 된다.
*
* 장점 :
* 1. 앱에서 기능을 구현하기가 가장 쉽다.
* 2. Activity / Fragment에서 모든 동작을 처리해주면 되므로 개발기간이 줄어든다.
* 3. 기능이 적은 앱이라면, 코드 분석이 용이해진다.
*
* 단점 :
* 1. 하나의 클래스(Activity / Fragment)에 코드의 양이 증가한다. 이는 안드로이드 앱 아키텍처 원칙 '관심사 분리'에 어긋난다.
* 2. 코드가 나중에 스파게티가 될 가능성이 높으므로 유지 및 보수가 어려워진다.
* 3. View는 UI 갱신을 위해서 Model을 참조하게 되므로 View와 Model간의 의존성이 높아진다. 이는 앱 테스트를 어렵게 만든다.
*
* */
class MVCFragment: BaseFragment<FragmentUserInfoNoDataBindingBinding>() {
override val layoutId: Int = R.layout.fragment_user_info_no_data_binding
private val userModel = UserModel()
override fun init() {
setMVCTitle()
setUpBtnListener()
getUserInfo()
setInputText()
}
private fun setUpBtnListener() {
binding.editCompleteBtn.setOnClickListener {
updateUserInfoModel()
}
}
// View -> Controller -> Model : 현재 입력정보를 참조해서 유저 정보 업데이트 (To Server / Local DB)
private fun updateUserInfoModel() {
getCurrentInputUserInfo()?.let {
loadingDialog.show(childFragmentManager, null)
// 1. View -> Controller -> Model : Controller가 View를 참조해서 DB 정보 Update.
userModel.modifyUserInfo(
it.userEmail,
it.userName,
it.userContact,
it.userAddress,
it.userAge
)
setInputText()
Toast.makeText(requireContext(), context?.getString(R.string.edit_complete_msg), Toast.LENGTH_SHORT).show()
// 수정된 유저 정보를 불러와 갱신시키는 작업은 불러오는 것을 보여주기 위해 일부러 Delay를 주었다.
lifecycleScope.launch {
delay(3000L)
loadingDialog.dismiss()
// 2. Model -> View : View가 Model을 참조해서 UI를 Update.
getUserInfo()
}
}
}
// Model -> View : View가 Model을 참조해서 UI를 Update. View와 Model간에 의존성이 생기게 된다.
private fun getUserInfo() {
binding.userNameTv.text = userModel.getUserInfo()?.userName
binding.userAgeTv.text = userModel.getUserInfo()?.userAge
binding.userEmailTv.text = userModel.getUserInfo()?.userEmail
binding.userContactTv.text = userModel.getUserInfo()?.userContact
binding.userAddressTv.text = userModel.getUserInfo()?.userAddress
Toast.makeText(requireContext(), context?.getString(R.string.get_info_complete_msg), Toast.LENGTH_SHORT).show()
}
// Model -> Controller -> View : View EditText Clear. Controller가 Model을 참조해서 View UI를 Update.
private fun setInputText() {
binding.inputUserNameEditText.setText(userModel.getUserInfo()?.userName)
binding.inputUserAgeEditText.setText(userModel.getUserInfo()?.userAge)
binding.inputUserEmailEditText.setText(userModel.getUserInfo()?.userEmail)
binding.inputUserContactEditText.setText(userModel.getUserInfo()?.userContact)
binding.inputUserAddressEditText.setText(userModel.getUserInfo()?.userAddress)
}
// 현재 입력된 유저의 정보를 토대로 Nullable한 User 객체 Return.
// 빈 칸이 존재하는 경우 Null Return.
// View -> Controller : 사용자 이벤트 처리하는 곳.
private fun getCurrentInputUserInfo(): User? {
val userName = binding.inputUserNameEditText.text.toString()
val userAge = binding.inputUserAgeEditText.text.toString()
val userEmail = binding.inputUserEmailEditText.text.toString()
val userContact = binding.inputUserContactEditText.text.toString()
val userAddress = binding.inputUserAddressEditText.text.toString()
if(userName.trim().isEmpty().not() &&
userAge.trim().isEmpty().not() &&
userEmail.trim().isEmpty().not() &&
userContact.trim().isEmpty().not() &&
userAddress.trim().isEmpty().not()) {
return User(userEmail, userName, userContact, userAddress, userAge)
} else {
Toast.makeText(requireContext(), context?.getString(R.string.please_dont_empty), Toast.LENGTH_SHORT).show()
}
return null
}
// Controller -> View : UI 갱신.
private fun setMVCTitle() {
binding.titleTv.text = context?.getString(R.string.start_mvc)
}
}
MVC에서 앱의 로직은 다음과 같이 동작한다.
- 유저의 정보를 불러온다.
-> getUserInfo() 메서드로 Model에 바로 접근해서
TextView에 바로 setText해준다.
Model -> View - 유저의 수정할 정보를 입력하고 수정 완료 버튼을 누른다.
-> updateUserInfoModel() 메서드 호출.
이는 getCurrentInputUserInfo()가 return하는 User?객체를 가지고 작업을
수행하게 된다.
현재 입력한 유저의 정보의 빈 칸이 없는지 체크하고
빈 칸이 없다면 수정할 유저의 데이터에 맞게 새로운 User객체를 생성한다.
그리고 현재 저장된 유저의 정보를 수정한다.
View -> Controller : 수정할 유저의 정보를 Controller가 받음. - 받아온 User객체가 null이 아니라면
updateUserInfoModel() 메서드가 계속 진행된다.
로딩 창이 나오며 Model을 통해서 DB의 데이터를 수정한다.
Controller -> Model : 수정할 유저의 정보를 가지고 현재 유저의 정보를
Model을 통해서 수정. - getUserInfo() 메서드로
수정된 데이터를 다시 받아와서 View에 나타낸다.
Model -> View
정말 별 기능 없지만 모든 로직을 Controller에서 짜니까 코드가 길어졌다.
만일 여기서 비밀번호가 추가되고, 비밀번호 확인 로직이 추가되고, 이메일 인증 로직이
추가된다면 코드가 정말 가독성이 떨어질 것이다.
또한 View가 Model을 바로 참조하게 되므로, View와 Model의 의존성이 생기게 된다.
MVC 패턴 장점
- Controller(Activity / Fragment)에서 모든 로직을 처리하게 하면 되므로 구현하기가 쉽고 개발기간이 줄어든다.
- 만약 기능이 많지 않은 화면이라면 코드 분석이 용이해 진다.
MVC 패턴 단점
- Controller(Activity / Fragment)에 코드의 양이 증가한다.
이는 안드로이드 앱 아키텍처 원칙 '관심사 분리'에 어긋난다.
- 구현한 화면에 기능들이 계속해서 추가된다면, 코드의 가독성이 떨어지고 유지 및 보수가 어려워진다.
- View가 UI 갱신을 위해서 Model을 참조하게 되므로 View와 Model간에 의존성이 생긴다.
이는 앱 테스트를 어렵게 만든다.
View와 Model 간에 의존성을 최대한 낮춘 MVC 패턴이 좋은 MVC 패턴이다.
View와 Model을 완전히 분리할 순 없을까?
MVP ( Model - View - Presenter )
View와 Model을 완전히 분리시킨 것이 바로 이 MVP 패턴이다.
MVP 디자인 패턴에서의 P는 Presenter이다.
Presenter는 위 이미지에서 보듯, Presenter를 가운데에 둬서
View와 Model간에 의존성을 없앴다.
Presenter가 Model을 참조해서 Update가 발생했을 시, View를 업데이트한다.
또한 Event가 발생했을 때 View를 참조해서 Model에 Data Update를 요청한다.
안드로이드에서의 MVP
안드로이드에서 MVP는 인터페이스를 구현하는 형태로 만들어진다.
위 MVC 디자인 패턴으로 만든 Controller를 MVP 패턴으로 만들려면 어떻게 만들어야 할까?
Contract 인터페이스
package com.khs.designpatternexampleproject.ui.mvp.contract
import android.content.Context
import com.khs.designpatternexampleproject.model.User
/**
* Contract는 MVP의 Interface입니다.
* View, Presenter에서 사용하게 될 기능들을 여기에 구현해야 합니다.
*
* View에는 setText()같은 View 처리 관련 메서드들이,
* Presenter에는 Business Logic( View -> Presenter -> Model / Model -> Presenter -> View)같은
* 메서드들이 존재해야 합니다.
*
*/
interface MVPContract {
interface View {
val mContext: Context?
fun setMVPTitle()
fun setUpBtnListener()
fun setInputText(user: User?)
fun getUserInfo(user: User?)
fun getCurrentInputUserInfo(): User?
fun showLoadingDialog()
fun hideLoadingDialog()
fun showToastMessage(msg: String)
}
interface Presenter {
fun initUserInfo()
fun updateUserInfoModel()
}
}
Contract는 MVP에서 사용하는 총괄 Interface다.
Model도 여기에 넣어서 Model도 Interface를 implement해서 사용하도록 하는 코드들도 있었다.
나는 Model은 늘 분리되어야 한다고 생각하므로 따로 넣지 않았다.
View 인터페이스와 Presenter 인터페이스가 있다.
Acvitivity / Fragment 클래스가 View 인터페이스를 implement하게 되고,
Presenter 인터페이스를 implement하는 클래스는 따로 생성해야 한다.
Presenter 구현 클래스
package com.khs.designpatternexampleproject.ui.mvp.presenter
import com.khs.designpatternexampleproject.R
import com.khs.designpatternexampleproject.model.UserModel
import com.khs.designpatternexampleproject.ui.mvp.contract.MVPContract
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MVPPresenter(_userModel: UserModel, _mvpView: MVPContract.View): MVPContract.Presenter {
private val userModel = _userModel
private val mvpView = _mvpView
override fun initUserInfo() {
val user = userModel.getUserInfo()
user?.let { // 받아온 데이터가 NULL이 아닌 경우만 View에 처리해줌
mvpView.getUserInfo(user)
mvpView.setInputText(user)
}
}
override fun updateUserInfoModel() {
mvpView.getCurrentInputUserInfo()?.let {
mvpView.showLoadingDialog()
userModel.modifyUserInfo(
it.userEmail,
it.userName,
it.userContact,
it.userAddress,
it.userAge
)
mvpView.setInputText(it)
mvpView.showToastMessage(mvpView.mContext?.getString(R.string.edit_complete_msg).toString())
CoroutineScope(Dispatchers.Main).launch {
delay(3000L)
mvpView.hideLoadingDialog()
mvpView.getUserInfo(it)
}
}
}
}
Presenter에는 View와 Model을 가지고 있고,
View와 Model 간에 처리해야할 비즈니스 로직을 담아야한다.
이 프로젝트에서 비즈니스 로직은 두 가지이다.
- 맨 처음 User 정보 초기화 : Model -> Presenter -> View
- User 정보 수정 : View -> Presenter -> Model
두 가지 기능에 맞춰서 메서드들을 implement해준다.
Repository패턴을 적용시킬 수도 있다.
일단은 뒤에 나올 MVVM 패턴에 Repository패턴을 적용시켜서 설명하겠다.
Model은 모든 디자인 패턴이 똑같은 Model이다.
맨 위 공통부분에 설명돼있으므로 넘어가겠다.
View 구현 클래스 (Activity / Fragment)
package com.khs.designpatternexampleproject.ui.mvp
import android.content.Context
import android.widget.Toast
import com.khs.designpatternexampleproject.R
import com.khs.designpatternexampleproject.databinding.FragmentUserInfoNoDataBindingBinding
import com.khs.designpatternexampleproject.model.User
import com.khs.designpatternexampleproject.model.UserModel
import com.khs.designpatternexampleproject.ui.base.BaseFragment
import com.khs.designpatternexampleproject.ui.mvp.contract.MVPContract
import com.khs.designpatternexampleproject.ui.mvp.presenter.MVPPresenter
/**
* MVC 패턴에서의 문제점은 View와 Model간의 의존성이 존재한다는 점이다.
* View와 Model 간에 의존성이 존재할 경우 앱 테스트를 어렵게 만든다.
* 또한 Controller에서 View, Model 관련 로직을 처리하므로
* 나중에 유지, 보수가 어려워지고 가독성도 떨어지게 된다.
*
* 이러한 문제를 해결하기 위해 나온게 MVP 패턴이다. MVP 패턴에서의 P는 Presenter이다.
* Presenter에서는 Model에서 데이터를 가져오고 View에서 UI를 갱신한다.
* View와 Model간에 의존성을 없앤 디자인 패턴이다.
*
* 장점 :
* 1. View - Presenter, Presenter - Model 간은 인터페이스(Contract)를 통해 이루어진다.
* 2. Presenter와 View가 1 : 1 관계이다.
* 3. View와 Model은 서로에 대한 참조가 없다. 즉, 의존성이 없다.
*
* 단점 :
* 1. 1 : 1 관계이므로 View와 Presenter 사이에 의존성이 강해진다.
* 2. 1 : 1 관계이므로 View에서 처리해야할 기능이 많아지면 그만큼 Presenter의 코드도 길어진다.
*
*/
class MVPFragment: BaseFragment<FragmentUserInfoNoDataBindingBinding>(), MVPContract.View {
override val layoutId: Int = R.layout.fragment_user_info_no_data_binding
private val mvpPresenter = MVPPresenter(UserModel(), this)
override fun init() {
setMVPTitle()
setUpBtnListener()
mvpPresenter.initUserInfo()
}
override val mContext: Context?
get() = context
override fun setMVPTitle() {
binding.titleTv.text = context?.getString(R.string.start_mvp)
}
override fun setUpBtnListener() {
binding.editCompleteBtn.setOnClickListener {
mvpPresenter.updateUserInfoModel()
}
}
override fun setInputText(user: User?) {
user?.let {
binding.inputUserNameEditText.setText(it.userName)
binding.inputUserAgeEditText.setText(it.userAge)
binding.inputUserEmailEditText.setText(it.userEmail)
binding.inputUserContactEditText.setText(it.userContact)
binding.inputUserAddressEditText.setText(it.userAddress)
}
}
override fun getUserInfo(user: User?) {
user?.let {
binding.userNameTv.text = it.userName
binding.userAgeTv.text = it.userAge
binding.userEmailTv.text = it.userEmail
binding.userContactTv.text = it.userContact
binding.userAddressTv.text = it.userAddress
Toast.makeText(requireContext(), context?.getString(R.string.get_info_complete_msg), Toast.LENGTH_SHORT).show()
}
}
override fun getCurrentInputUserInfo(): User? {
val userName = binding.inputUserNameEditText.text.toString()
val userAge = binding.inputUserAgeEditText.text.toString()
val userEmail = binding.inputUserEmailEditText.text.toString()
val userContact = binding.inputUserContactEditText.text.toString()
val userAddress = binding.inputUserAddressEditText.text.toString()
if(userName.trim().isEmpty().not() &&
userAge.trim().isEmpty().not() &&
userEmail.trim().isEmpty().not() &&
userContact.trim().isEmpty().not() &&
userAddress.trim().isEmpty().not()) {
return User(userEmail, userName, userContact, userAddress, userAge)
} else {
Toast.makeText(requireContext(), context?.getString(R.string.please_dont_empty), Toast.LENGTH_SHORT).show()
}
return null
}
override fun showLoadingDialog() {
loadingDialog.show(childFragmentManager, null)
}
override fun hideLoadingDialog() {
loadingDialog.dismiss()
}
override fun showToastMessage(msg: String) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
(로직은 어렵지 않고, MVC와 유사하거나 거의 같기 때문에 따로 설명하지 않겠다.)
View 구현 클래스에서는 화면에 보여지는 것들(UI)에 관한 로직만 있다.
Presenter 객체를 생성하게 된다.
유저의 정보를 나타내주는 View에서는, Presenter가 Model을 통해 받아온 유저의 정보를 가지고 나타내주게 된다.
즉, View는 Model의 존재를 모르는 것이 된다.
MVC의 프래그먼트 코드와 거의 유사한데, 조금 다른 점이 존재한다.
로딩 창이나, 토스트 메시지를 띄우는 것도 메서드로 구현이 되어있다.
토스트 메시지 같은 것도 특정 비즈니스 로직에서 띄워줘야한다면
View 인터페이스에서 만들고
Presenter에서 View의 구현된 메서드를 받아와서 써야한다.
즉, View와 Model 사이에 의존성은 사라졌지만,
View와 Presenter 사이에 의존성이 생겼다.
또한 View와 Presenter는 1 : 1의 관계이므로 재사용성이 떨어지게 될 것이다.
MVP 패턴 장점
- View, Presenter (Model은 선택사항)가 인터페이스를 통해 구현된다.
따라서 인터페이스를 구현할 때, UI 영역과 비즈니스 영역(데이터 + UI)을
명확하게 구분지을 수 있게된다. - View와 Model이 서로에 대한 존재 자체를 모르게 된다.
즉, MVC 패턴의 문제점이였던 View와 Model간에 강한 의존성 문제가 사라진다.
MVP 패턴 단점
- 1 : 1 관계이므로 View와 Presenter 사이에 의존성이 생기게 된다.
- 1 : 1 관계이므로 처리해야할 비즈니스 로직이 많아지면 그만큼 Presenter의 코드도
길어지게 된다.
View와 Model간의 의존성을 없앴더니,
이제는 View와 Presenter간에 의존성이 생겼다.
View와 Presenter의 의존성을 어떻게 없앨 수 있을까?
MVVM ( Model - View - View Model )
MVP 패턴에서 Presenter는 View를 참조했고, View에서는 Presenter를 참조했다.
1 : 1 관계인 View와 Presenter간에 의존성이 강했다.
Presenter와 View를 분리해서 ViewModel이 등장했다.
MVVM 디자인 패턴에서는 다음과 같은 로직으로 작동된다.
MVVM 디자인 패턴에서는 Data Binding이 전제로 깔린다.
- View는 나머지 디자인 패턴들과 마찬가지로 화면을 구성하는 UI이다.
비즈니스 로직 등을 ViewModel과 Data Binding을 통해서 UI를 보여주거나
특정 이벤트를 처리하게 된다. - Presenter와 달리, ViewModel은 View의 존재를 모른다.
다만, View에서는 ViewModel과 Data Binding이 되어있으므로,
ViewModel은 그저 자기 자신이 할 일을 하지만
View는 ViewModel(Model의 State)을 자동으로 참조하게된다.
안드로이드에서의 MVVM
- View는 ViewModel의 특정 값(LiveData)을 계속해서 Observe하다가
ViewModel의 특정 값의 변화가 생기면 그 때 그 값에 해당하는 UI를 Update한다. - ViewModel은 View의 존재를 모른다.
따라서 View와 ViewModel 사이에 의존성이 없다.
그냥 비즈니스 로직에 따른 Model에 Data를 요청하거나, Data Update를 요청한다.
이렇게 한 후에 자신의 값을 변화시킨다.
Model은 그냥 데이터 관련 작업 요청이 들어왔을 때 작업을 수행한다.
위에서 설명했듯이 Model은 모든 요소들과 분리돼있다고 생각하면 된다.
MVVM 디자인 패턴은 요즘 안드로이드에서 권장하는 아키텍처이다.
데이터 바인딩, LiveData, AAC ViewModel 등 Android JetPack 요소들이 모두
MVVM 디자인 패턴에서 잘 어울리는 요소들이다.
이번에 나온 Android Compose 1.0 코드랩을 진행했었는데,
MVVM 디자인 패턴이 적용된 코드를 봤었다.
이제 안드로이드에서 MVVM 디자인 패턴은 권장이 아닌 필수가 된 것 같다.
이번 MVVM 디자인 패턴 예시에서는 Repository 패턴도 적용해서 설명하겠다.
Repository 디자인 패턴은 안드로이드 권장 디자인 패턴이다.
Repository를 사용하면 다음과 같은 장점이 생긴다.
- ViewModel은 Repository가 넘겨주는 데이터만 받으면 된다.
즉, Local DB / Server DB 어디에서 넘어오는 데이터든 일관성있게 처리할 수가 있게된다. - View는 ViewModel에 종속되며, ViewModel은 Repository에 종속되기 때문에,
하나의 클래스만 고려하면 된다.
MVP 패턴에서 그래서 Presenter를 어떻게 나누며, View와의 의존성을 없애고 ViewModel로 나눌 수 있을까?
아래 단계에서 차근차근 살펴보자.
Model 클래스는 이미 알고 있다.
따라서 바로 Repository 클래스로 넘어가겠다.
Repository
package com.khs.designpatternexampleproject.ui.mvvm.repository
import com.khs.designpatternexampleproject.model.UserModel
/**
* Repository를 쓰면 데이터의 출처에 상관없이
* 상위 계층에서 일관성있는 데이터의 처리가 가능해진다.
*/
class UserRepository {
val userModel = UserModel()
fun getUserInfo() = userModel.getUserInfo()
fun modifyUserInfo(userEmail: String, userName: String, userContact: String, userAddress: String, userAge: String) = userModel.modifyUserInfo(userEmail, userName, userContact, userAddress, userAge)
}
이번 예시 프로젝트에서는 그저 하나의 Model을 가지고 다룰 뿐이라서 Repository가 짧다.
그러나 만일, 대형 프로젝트에서 여러 API를 호출하게 되고 Room DB도 사용하게 되는 경우가 생길 때
그 때 Repository의 강점이 드러나게 된다.
상위 계층(이 프로젝트에서는 ViewModel)에서 일관성있는 데이터의 처리가 가능해진다.
Repository는 확인했다. 이제 ViewModel을 살펴보자.
ViewModel
package com.khs.designpatternexampleproject.ui.mvvm.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.khs.designpatternexampleproject.model.User
import com.khs.designpatternexampleproject.ui.mvvm.repository.UserRepository
/**
* AAC(Android Architecture Components) ViewModel 클래스.
* MVVM 디자인 패턴처럼 사용하기위해 구현된 클래스입니다.
*
* 매개변수로 Repository를 받아서 Repository 내에 있는 기능들을 가져다가 사용합니다.
*
* inputUserInfo : 입력하는 곳에 양방향 데이터를 하기 위한 유저 LiveData입니다.
* getUserInfo() 메서드와 연동되며, 유저의 정보가 들어오게 되면
* copyUserInfoToInputUserInfo() 메서드로 해당 LiveData에 깊은 복사가 이루어진
* User 객체를 postValue합니다.
*
* currentUserInfo : 현재 저장된 유저의 정보를 LiveData 형태로 보관합니다.
* getUserInfo() 메서드와 연동됩니다.
* Layout File에서 DataBinding해서 사용합니다.
* View는 자동으로 이 값이 변하면 이 값에 맞게 UI를 갱신합니다.
*
* modifyComplete : 유저 정보 수정이 완료됐는지를 저장해두는 LiveData입니다.
* modifyUserInfo() 메서드와 연동됩니다.
* View 소스 코드에서 Observer를 생성해서 사용하게 됩니다.
* View는 이 값을 Observe하다가 값이 변화되면 UI를 갱신하는 등의 로직을 수행합니다.
*
*/
class UserViewModel(private val userRepository: UserRepository): ViewModel() {
private val _inputUserInfo = MutableLiveData<User>() // View에서 Binding된 LiveData
val inputUserInfo: LiveData<User>
get() = _inputUserInfo
private val _currentUserInfo = MutableLiveData<User>() // View에서 Binding된 LiveData.
val currentUserInfo: LiveData<User>
get() = _currentUserInfo
private val _modifyComplete = MutableLiveData<Boolean>() // View에서 Observe를 할 LiveData.
val modifyComplete: LiveData<Boolean>
get() = _modifyComplete
fun getUserInfo() {
userRepository.getUserInfo().let {
_currentUserInfo.postValue(it)
copyUserInfoToInputUserInfo(it)
}
}
fun modifyUserInfo(userEmail: String, userName: String, userContact: String, userAddress: String, userAge: String) {
if(userEmail.isNotEmpty() && userName.isNotEmpty() && userContact.isNotEmpty() && userAddress.isNotEmpty() && userAge.isNotEmpty()) {
userRepository.modifyUserInfo(userEmail, userName, userContact, userAddress, userAge).let {
_modifyComplete.postValue(true)
}
} else {
_modifyComplete.postValue(false)
}
}
private fun copyUserInfoToInputUserInfo(user: User) {
// 깊은 복사 사용.
val inputUser = user.copyUser()
_inputUserInfo.postValue(inputUser)
}
}
ViewModel은 AAC ViewModel을 상속받아서 만들었다. MVVM ViewModel과는 전혀 다른 개념이다.
AAC ViewModel은 그냥, Android에 상속된 ViewModel이라고 생각하면된다.
(그렇다고 MVVM ViewModel은 아니다. 그냥 이름만 ViewModel)
대표적인 장점으로는 Activity의 Scope로 AAC ViewModel을 생성하면,
해당 Activity 위에 Fragment들에서 AAC ViewModel을 공유해서 사용할 수 있다.
(새롭게 생성해도 이미 만들어진 AAC ViewModel이 Return된다.)
이 때 Activity는 해당 작업에 대해 아무것도 몰라도 된다.
AAC ViewModel을 사용하는 또 다른 이유는 생명주기 관련이다.
그냥 Class 파일을 ViewModel로 사용할 경우 Activity / Fragment가 사라져도 남아있을 수 있다.
AAC ViewModel로 MVVM ViewModel을 사용하면 이런 문제를 해결할 수 있다.
또한 화면회전 같은 문제에서 아래의 그림처럼 ViewModel이 살아있으므로
데이터가 유지될 수 있다.
AAC ViewModel 설명은 이쯤 하고, 뷰모델 클래스의 로직에 대해 설명하겠다.
View와 ViewModel은 Binding하거나, Observe하는 방식으로 UI를 갱신시킬 수 있다.
- 매개변수로 Repository를 받아서 Data 관련 작업을 Repository를 통해 진행하게 된다.
- inputUserInfo : 입력정보 창에 나타낼 유저의 정보이다.
View에서 Binding해서 사용할 LiveData이다.
해당 User 정보가 바뀌면 User입력 정보의 값(EditText)이 수정된다.
copyUserInfoToInputUserInfo() 메서드와 연동되는데
getUserInfo() 메서드로 유저의 정보를 가져온 후에
유저의 정보와 똑같은 User 객체를 깊은 복사로 만들어서 postValue()해준다.
View와 Binding이 돼 있기 때문에 해당 값이 수정되면
View는 수정된 User 정보로 UI를 갱신한다. - currentUserInfo : 화면의 상단에 나와있는 현재 불러온 유저의 정보이다.
View에서 Binding해서 사용할 LiveData이다.
해당 User 정보가 바뀌면 User의 현재 정보 값(TextView)이 갱신된다.
getUserInfo() 메서드와 연동되는데
Repository로 현재 유저의 정보를 불러온 후에
불러온 유저의 정보를 postValue()해준다.
View와 Binding이 돼 있기 때문에 해당 값이 수정되면
View는 수정된 User 정보로 UI를 갱신한다. - modifyComplete : 수정 완료 버튼을 눌렀을 때 수정 요청이
정상적으로 처리됐는지 여부를 알려주는 LiveData이다.
modifyUserInfo() 메서드와 연동된다.
View에서 Observe해서 사용할 LiveData이다.
해당 값이 바뀌게 되면 수정 완료 여부를 알려준다.
값이 정상적으로 수정되면 true를 postValue()해주고,
수정이 안됐다면 false를 postValue()해준다.
View가 해당 값을 Observe하고있기 때문에 해당 값이 수정되면
수정된 값에 따른 특정한 로직을 수행하게 된다.
ViewModel도 살펴보았다.
그런데 AAC ViewModel에서 매개변수를 받으려면 ViewModelFactory가 필요하다.
이와같은 클래스가 있으면 된다.
이제 View 즉, 프래그먼트 코드를 봐 보자.
View (Activity / Fragment)
package com.khs.designpatternexampleproject.ui.mvvm
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.khs.designpatternexampleproject.R
import com.khs.designpatternexampleproject.databinding.FragmentUserInfoDataBindingBinding
import com.khs.designpatternexampleproject.ui.base.BaseFragment
import com.khs.designpatternexampleproject.ui.mvvm.repository.UserRepository
import com.khs.designpatternexampleproject.ui.mvvm.viewmodel.UserViewModel
import com.khs.designpatternexampleproject.ui.mvvm.viewmodel.UserViewModelFactory
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* MVP에서는 Model과 View의 의존성은 완전히 없앴지만,
* View, Presenter가 1 : 1 관계이기 때문에 의존성이 강해졌다.
*
* MVP의 문제점을 해결하기 위해서 MVVM이라는 패턴이 나왔다.
* Presenter에서 View와 Model을 완전히 분리시켜 ViewModel이 된다.
* MVVM에서는 View와 MVVM ViewModel간의 관계는 n : n일 수 있다.
* 하나의 View에서 여러 개의 MVVM ViewModel을 사용할 수 있고,
* 하나의 MVVM ViewModel은 여러 개의 View에서 사용되어질 수 있다.
*
* AAC ViewModel과 같이 사용할 경우, Activity에 하나의 AAC ViewModel을 생성할 수 있다.
* 즉, 하나의 Activity 위에 여러 개의 Fragment에서 같은 ViewModel을 공유해서 사용할 수 있다.
* 이 프로젝트에서는 Fragment를 Owner로 사용하기 때문에 Fragment가 onDestroy되면 ViewModel도 사라진다.
* (물론 ViewModel이 실제로는 Fragment의 생명주기보다 더 길게 생존될 순 있지만, UI로직만 참조하지 않는다면 문제가 되지 않는다.)
*
* 장점 :
* 1. Activity / Fragment에서 ViewModel이 Model관련 작업을 수행하므로 View와 Model간에 의존성이 사라지게 된다. 때문에 테스트가 용이하다.
* 2. Observable한 값(ex. LiveData)을 사용할 경우 UI가 실시간으로 최신값으로 갱신될 수 있다.
* 3. 데이터 바인딩을 사용하게 되면 UI 관련 소스 코드 로직을 줄일 수 있다.
*
* 단점 :
* 1. 간단한 기능 구현에도 ViewModel, Repository를 사용할 경우 복잡한 구성이 된다.
* 2. ViewModel에 기능 구현이 많아지면, MVC 패턴의 Controller처럼 복잡한 로직을 가질 수 있게된다.
* 3. 데이터 바인딩이 의무화가 된다.
*
*/
class MVVMFragment: BaseFragment<FragmentUserInfoDataBindingBinding>() {
override val layoutId: Int = R.layout.fragment_user_info_data_binding
lateinit var viewModelFactory: UserViewModelFactory
lateinit var userViewModel: UserViewModel
override fun init() {
initViewModel()
setUpObserver()
getUserInfo()
}
private fun initViewModel() {
viewModelFactory = UserViewModelFactory(UserRepository())
//userViewModel = ViewModelProvider(requireActivity(), viewModelFactory).get(UserViewModel::class.java) - EventWrapper 사용 시.
userViewModel = ViewModelProvider(this, viewModelFactory).get(UserViewModel::class.java)
binding.vm = userViewModel // ViewModel을 Layout에 Binding한다.
}
private fun setUpObserver() {
userViewModel.modifyComplete.observe(viewLifecycleOwner) {
if(it) {
//if(it.contentIfNotHandled != null) { - EventWrapper 사용 시.
loadingDialog.show(childFragmentManager, null)
context?.let { mContext ->
Toast.makeText(mContext, mContext.getString(R.string.edit_complete_msg), Toast.LENGTH_SHORT).show()
}
lifecycleScope.launch {
delay(3000L)
loadingDialog.dismiss()
getUserInfo()
}
} else {
context?.let { mContext ->
Toast.makeText(mContext, mContext.getString(R.string.please_dont_empty), Toast.LENGTH_SHORT).show()
}
}
}
}
private fun getUserInfo() {
userViewModel.getUserInfo()
context?.let {
Toast.makeText(it, it.getString(R.string.get_info_complete_msg), Toast.LENGTH_SHORT).show()
}
}
}
프래그먼트 소스 코드.
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="vm"
type="com.khs.designpatternexampleproject.ui.mvvm.viewmodel.UserViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/start_mvvm"
android:textColor="@color/black"
android:textSize="27sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/user_info_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/title_tv">
<TextView
android:id="@+id/cur_user_info_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cur_user_info"
android:textColor="@color/black"
android:textSize="27sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/user_name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/cur_user_info_tv">
<TextView
android:id="@+id/user_name_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/name"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/user_name_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/user_name_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{vm.currentUserInfo.userName}"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/user_name_text_tv"
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/user_age_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/user_name_layout">
<TextView
android:id="@+id/user_age_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/age"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/user_age_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/user_age_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{vm.currentUserInfo.userAge}"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/user_age_text_tv"
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/user_email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/user_age_layout">
<TextView
android:id="@+id/user_email_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/email"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/user_email_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/user_email_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{vm.currentUserInfo.userEmail}"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/user_email_text_tv"
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/user_contact_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/user_email_layout">
<TextView
android:id="@+id/user_contact_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/contact"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/user_contact_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/user_contact_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{vm.currentUserInfo.userContact}"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/user_contact_text_tv"
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/user_address_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/user_contact_layout">
<TextView
android:id="@+id/user_address_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/contact"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/user_address_tv"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/user_address_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{vm.currentUserInfo.userAddress}"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/user_address_text_tv"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/user_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/user_info_layout">
<TextView
android:id="@+id/edit_user_info_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/edit_user_info"
android:textColor="@color/black"
android:textSize="27sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/input_user_name_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/edit_user_info_tv">
<TextView
android:id="@+id/input_user_name_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/name"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_user_name_edit_text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/input_user_name_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="6dp"
android:text="@={vm.inputUserInfo.userName}"
android:textColor="@color/black"
android:textSize="19sp"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/input_user_name_text_tv"
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/input_user_age_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/input_user_name_layout">
<TextView
android:id="@+id/input_user_age_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/age"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_user_age_edit_text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/input_user_age_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="6dp"
android:inputType="number"
android:text="@={vm.inputUserInfo.userAge}"
android:textColor="@color/black"
android:textSize="19sp"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/input_user_age_text_tv"
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/input_user_email_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/input_user_age_layout">
<TextView
android:id="@+id/input_user_email_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/email"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_user_email_edit_text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/input_user_email_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="6dp"
android:text="@={vm.inputUserInfo.userEmail}"
android:textColor="@color/black"
android:textSize="19sp"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/input_user_email_text_tv"
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/input_user_contact_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/input_user_email_layout">
<TextView
android:id="@+id/input_user_contact_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/contact"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_user_contact_edit_text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/input_user_contact_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="6dp"
android:text="@={vm.inputUserInfo.userContact}"
android:textColor="@color/black"
android:textSize="19sp"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/input_user_contact_text_tv"
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/input_user_address_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/input_user_contact_layout">
<TextView
android:id="@+id/input_user_address_text_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/address"
android:textColor="@color/black"
android:textSize="19sp"
app:layout_constraintHorizontal_weight="3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_user_address_edit_text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<EditText
android:id="@+id/input_user_address_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingVertical="6dp"
android:text="@={vm.inputUserInfo.userAddress}"
android:textColor="@color/black"
android:textSize="19sp"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintHorizontal_weight="7"
app:layout_constraintStart_toEndOf="@+id/input_user_address_text_tv"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/edit_complete_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@color/black"
android:text="@string/edit_complete"
android:textSize="18sp"
android:textColor="@color/white"
android:onClick="@{() -> vm.modifyUserInfo(vm.inputUserInfo.userEmail, vm.inputUserInfo.userName, vm.inputUserInfo.userContact, vm.inputUserInfo.userAddress, vm.inputUserInfo.userAge)}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/user_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
프래그먼트 레이아웃 코드
View에서의 코드가 상당히 짧아진 것을 볼 수 있다.
이는 데이터 바인딩을 사용해서 그렇다.
버튼 온클릭 메서드 같은 것이나, View 갱신을 Binding으로 처리하기 때문에
View에서는 할 일이 크게 없다. 로직의 순서를 살펴보자면 다음과 같다.
- ViewModelFactory / ViewModel을 초기화한다.
ViewModel의 Owner는 Fragment로 해준다. 다른 프래그먼트와 이 뷰모델을 공유할 일은 없기 때문이다.
그런 후에 ViewModel을 View와 Binding해준다. (binding.vm = userViewModel) - 수정완료(modifyComplete) LiveData에 대한 Observer를 설정해준다.
Observer의 Owner를 viewLifeCycleOwner로 설정한다.
Observer의 Owner로 this, viewLifeCycleOwner를 설정가능한데 차이는 다음과 같다.
this : onCreate() ~ onDestroy() 동안 LiveData를 관찰한다.
viewLifeCycleOwner : onCreateView() ~ onDestroyView() 동안 LiveData를 관찰한다.
만일 true값이 Observe된 경우, 수정이 잘 됐다는 뜻이다.
로딩 중 다이얼로그를 띄우고 수정완료 토스트 메시지를 띄운다.
3초 후에 로딩 중 다이얼로그를 닫고 수정된 유저의 정보를 불러온다.
flase값이 Observe된 경우, 수정이 안 됐다는 뜻이다.
빈 칸이 존재하는 경우에만 현재 수정이 안되므로 빈 칸 없이 입력하라는 토스트 메시지를 띄운다.
getUserInfo() 메서드는 ViewModel을 통해서 유저의 정보를 갱신하는 메서드이다.
현재 Fragment에서 ViewModel의 currentUserInfo를 Binding하고 있으므로
ViewModel이 Model을 통해서 값을 받아오면 자동으로 해당 유저의 정보로 UI가 갱신된다.
주의할 점은, 뷰바인딩 객체의 lifeCycleOwner를 viewLifeCycleOwner로 설정해줘야 한다.
그래야 바인딩한 클래스 내에 있는 모든 LiveData를 관찰할 수 있다.
this도 가능하긴 하지만, 메모리 누수의 위험이 있다고 한다.
왜냐하면 ViewBinding은 말 그대로 View와 관련한 Binding이다.
View는 onDestroyView()에서 사라진다. 이 때 ViewBinding 역시 사라져야한다.
그러나 바인딩 객체의 lifeCycleOwner를 Fragment로 해버리면
View는 모두 사라졌는데 바인딩 객체는 onDestroy()에서 사라지게 된다.
이는 메모리 누수를 초래하게 된다.
MVVM 패턴 장점
- View->ViewModel->Model 의 관계만 가지게 된다. (Repository는 선택사항)
- Data Binding을 사용하면 View에서의 UI Update 코드가 줄어든다.
- View와 ViewModel이 1 : N 관계를 가질 수 있게된다.
(여러 프래그먼트에서 하나의 ViewModel 공유) - ViewModel에서 UI관련 요소가 없으므로 유닛 테스트가 용이해진다.
MVVM 패턴 단점
- 디자인 패턴의 진입 장벽이 높다.
- 간단한 UI에서도 고려해야할 사항이 많아진다.
- DataBinding이 거의 필수사항이 된다.
- Observe, DataBinding을 고려해야 하므로 만들어 놓은 코드를 다른 사람이 이해하기 어려울 수 있게 된다.
- 표준화된 틀이 존재하지 않는다.
총정리
같은 기능, 화면을 구현한 세 가지 디자인 패턴에 대해 정리해봤다.
어떤 디자인 패턴이 좋다 나쁘다는 없는 것 같다.
다만 구글이 MVVM 패턴을 권장하므로 MVVM 패턴으로 앱을 만드는 것을
지향하도록 해야겠다는 생각이 든다.
예시 프로젝트의 전체 코드는 위에서 확인 가능하다.