우아한테크코스

[안드로이드] MVP 패턴을 MVVM 패턴으로 리팩터링하기

베르_최성훈 2023. 7. 9. 19:55

MVP 패턴으로 작성한 단순한 화면을 MVVM 패턴으로 바꿔보자!

 

+ 버튼을 누르면 1씩 증가하고 - 버튼을 누르면 1씩 감소하는 단순한 앱을 만들어보자.

 

먼저 xml 파일을 작성해서 화면을 그렸다.

 

 

 

MVP 패턴

Counter 작성

먼저 도메인 Model 부터 만들어 보자

 

count 를 프로퍼티로 가지며 add 나 sub 를 호출할 경우 현재 count 를 return 한다.

class Counter {
    private var count = 0

    fun add(): Int {
        return ++count
    }

    fun sub(): Int {
        return --count
    }
}

 

MainContract 작성

MVP 패턴ViewPresenter 의 1대1 계약과 비슷하다. 그러니 Contract interface 를 다음과 같이 작성한다.

 

interface MainContract {

    interface Presenter {
        fun minusCount()
        fun plusCount()
    }

    interface View {
        fun showCount(count: Int)
    }
}

 

MainPresenter 작성

MainContract.Presenter를 구현하는 MainPresenter 를 작성한다.

class MainPresenter(
    private val view: MainContract.View,
) : MainContract.Presenter {

    private val counter = Counter()

    override fun minusCount() {
        view.showCount(counter.sub())
    }

    override fun plusCount() {
        view.showCount(counter.add())
    }
}

 

MainActivity 

MainContract.View를 구현하는 MainActivity 를 작성한다.

아래 코드는 view 참조만을 위해서 dataBinding 을 사용했다.

class MainActivity : AppCompatActivity(), MainContract.View {

    private val presenter: MainContract.Presenter by lazy { MainPresenter(this) }

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        initView()
    }

    override fun showCount(count: Int) {
        binding.tvCount.text = count.toString()
    }

    private fun initView() {
        initBtnMinus()
        initBtnPlus()
    }

    private fun initBtnMinus() {
        binding.btnMinus.setOnClickListener {
            presenter.minusCount()
        }
    }

    private fun initBtnPlus() {
        binding.btnPlus.setOnClickListener {
            presenter.plusCount()
        }
    }
}

MVP 실행 결과

 

MVVM 패턴

  • 뷰를 개발자 책임이 아닌 디자이너 책임으로 이동
    뷰를 제로 코드에 가까워지도록 바꾸고자 한다.
    비즈니스 로직 및 뷰의 상태를 가지고 있던 View 로 부터 해당 요소를 분리

  • 고수준에서 저수준으로 단방향 의존관계를 유지한다.
    바라보는 방향은 다음과 같다.
View -> ViewModel -> Model
View 는 ViewModel 을 알지만 Model 을 모른다.
VIewModel 은 Model 을 알지만 View 를 모른다.
Model 은 View와 ViewModel 을 알지 못한다.

ViewModel 은 View 를 분리, 모델이 뷰와 독립적으로 발전할 수 있도록 한다.

 

MVVM 에 대한 내용은 아래 글을 참고했습니다.

더 자세한 내용이 궁금하다면 

https://prolog.techcourse.co.kr/studylogs/3759

 

 

Presenter 를 ViewModel 로 바꾸기

다음 순서로 바꾸고자 한다. 

1. Presenter 의 View 참조를 끊고 View 에서 Presenter 의 data 를 옵저빙하도록 한다.

2. 데이터바인딩의 강력함을 활용해 Presenter 의 속성값이 변경되면 자동으로 새 값이 뷰에 전파되도록 만든다.

3. 구성 변경에 대응하기

 

1. PresenterView 참조를 끊고 View 에서 Presenter 의 data 를 옵저빙

Presenter 가 MainContract.Presenter 를 더 이상 구현하지 않도록 하고 View 를 더 이상 모르도록 수정한다.

class MainViewModel {

    private val counter = Counter()

    private val _count = MutableLiveData<Int>(0)
    val count: LiveData<Int>
        get() = _count

    fun plusCount() {
        _count.value = counter.add()
    }

    fun minusCount() {
        _count.value = counter.sub()
    }
}

 

옵저빙을 위해 LiveData 를 사용하고 외부에서 참조 시 값을 직접 변환하지 못하도록 커스텀 게터를 활용해서 방어적 복사를 적용했다.

 

View 에서 plusCount(), minusCount() 를 호출하면 Counter 인스턴스의 상태가 업데이트 되고 반환값으로 count 의 value 를 업데이트 한다.

 

이젠 더 이상 Presenter 라고 부를 수 없으므로 MainViewModel 로 변경하겠다.

 

2. 속성 값이 변경되면 자동으로 새 값이 View에 전파

DataBinding 사용

 

View 에서 ViewModel 인스턴스를 생성하고 ViewModel 의 메서드로 이벤트 처리 요청을 보낸다.

data 를 옵저빙해서 값이 자동으로 업데이트 된다.

databinding 을 활용하면 Activity 코드가 확실히 간단해진다.

MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        setUpViewModel()
    }

    private fun setUpViewModel() {
        binding.lifecycleOwner = this
        binding.viewModel = MainViewModel()
    }
}

 

activity_main.xml

<layout...>
    <data>
        <variable
            name="viewModel"
            type="com.example.myapplication.MainViewModel" />
    </data>
    
    <androidx.constraintlayout.widget.ConstraintLayout...>
        <TextView
            android:id="@+id/tvCount"
            android:text="@{viewModel.count.toString()}"
	        ... />

        <Button
            android:id="@+id/btnMinus"
            android:onClick="@{_ -> viewModel.minusCount()}"
            ... />

        <Button
            android:id="@+id/btnPlus"
            android:onClick="@{_->viewModel.plusCount()}"
			... />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

MVVM 실행 결과

MVVM  패턴을 적용해도 정상 동작하는 것을 확인할 수 있다.

 

3. 구성 변경에 대응하기

앞의 과정을 거쳤다면 ViewModel 이라고 부를 수 있다. 하지만 구성 변경에 대응하고 싶을 것이다.

다크 모드, 글자 크기 수정, 화면 전환 등의 구성 변경이 일어나면 화면을 다시 그릴 필요가 생기고 따라서 액티비티 인스턴스는 Destroy 되었다가 다시 Create 된다.

 

jetpack AAC ViewModel 을 사용해 UI 상태를 보존할 수 있는데 ViewModel 인스턴스가 제거되지 않도록 하는 방법이다. 

isFinishing 의 여부에 따라서 Activity.isFinishing 이 true 라면 메모리에서 제거하고 false 면 메모리에 남아 있는다.

 

먼저 MainViewModel 이 androidx.lifecycle.viewmodel 을 상속하도록 만든다.

 

class MainViewModel : ViewModel() {
 ...
}

 

그 다음 ViewModelProvider 를 사용해서 원하는 ViewModel 과 연결한다.

ViewModelProvider 는 해당 ViewModel 인스턴스가 메모리에 남아있으면 그것을 반환하고 없으면 새로 생성하는 역할을 한다.

class MainActivity : AppCompatActivity() {
    ...	
    
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        setUpViewModel()
    }

    private fun setUpViewModel() {
        ...
        val provider: ViewModelProvider = ViewModelProvider(this)
        binding.viewModel = provider[MainViewModel::class.java]
    }
}

 

이제 구성 변경이 일어나도 데이터를 저장하고 있는 것을 확인할 수 있다!

 

AAC ViewModel 사용