Model View Presenter 패턴은 MVC(Model View Controller) 패턴을 기반으로 하는 아키텍처 패턴으로 관심사의 분리를 높이고 단위 테스트를 용이하게 합니다.
요약: MVC 패턴에서 View와 Model의 의존성을 없애고 단위 테스트가 어려웠던 문제점을 해결하기 위해 등장하게된 패턴이라고 할 수 있다.
앱에 사용되는 데이터를 관리 담당하는 역할을 합니다. 흔히 '비즈니스 로직'이리고 부르는 부분입니다. 모델에는 network API, 데이터 캐싱, 데이터베이스 등이 포함되고 Repository pattern을 사용하는 경우 Repository도 포함됩니다.
사용자 인터페이스 영역이며, Activity, Fragment 등이 포함되고 데이터를 표시하는 역할만 합니다. 오직 Presenter를 통해서 데이터를 요청하고 전달 받기때문에 Presenter에 의존적입니다.
View와 Model 사이 중개자 역할을 담당합니다. View에서 사용자 이벤트를 전달 받아 Model에 데이터를 요청하고 전달받은 데이터를 View에 그대로 전달 합니다. ( MVVM의 ViewModel과 비교했을때 ViewModel은 View를 알지 못하고 View에 대한 참조를 가지고 있지 않다. 하지만 Presenter는 View와 Model 모두 참조 하고있다. )
구글의 예제 참고
Contract interface 생성
interface MainContract {
interface View : BaseView<Presenter> {
fun showProgress(isShow: Boolean)
fun setData(str: String)
}
interface Presenter : BasePresenter
}
interface BasePresenter {
fun start()
}
interface BaseView<T> {
var presenter: T
}
Presenter class 생성
Presenter class에서는 Contract의 Presenter interface를 구현하고, Model(여기서는 repository) 과 View를 생성자 매개변수로 받습니다.
class MainPresenter(
val mainRepository: MainRepository,
val mainContractView: MainContract.View
) : MainContract.Presenter {
init {
mainContractView.presenter = this
}
override fun start() {
mainContractView.showProgress(false)
val data = mainRepository.getData()
mainContractView.setData(data)
mainContractView.showProgress(true)
}
}
Model class 생성
Model은 일반적으로 server 또는 local database에서 데이터를 가져오지만 예제이기에 간단하게 작성하였습니다. 여기선 Repository를 Model이라고 했지만 Repository pattern에 따르면 Repository는 여러개의 datasource(local, remote, database) 에서 필요한 데이터를 선택해 가져오는 Presenter와 Model 사이의 중개자 역할입니다.
또 한가지 object 클래스로 작성했는데 Repository는 singleton 이어야 합니다.
object MainRepository {
fun getData() = "Hello World"
}
class MainActivity : AppCompatActivity(), MainContract.View {
private val textView: TextView by lazy {
findViewById(R.id.textView)
}
private val button: Button by lazy {
findViewById(R.id.button)
}
override fun showProgress(isShow: Boolean) {
textView.isVisible = isShow
}
override fun setData(str: String) {
textView.text = str
}
override lateinit var presenter: MainContract.Presenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
MainPresenter(MainRepository, this)
button.setOnClickListener {
presenter.start()
}
}
}
MVP 패턴은 단위테스트를 더 쉽게 작성할 수 있다는 점인데 어떻게 하는지 위에서 작성한 Presenter의 테스트를 작성해 보겠습니다.
Mock 객체를 만들기 위한 라이브러리로 dependencies에 추가해 줍니다.
testImplementation "org.mockito:mockito-core:3.5.13"
class MainPresenterTest {
@Mock private lateinit var mainRepository: MainRepository
@Mock private lateinit var mainContractView: MainContract.View
private lateinit var mainPresenter: MainPresenter
@Before
fun setupPresenter() {
MockitoAnnotations.openMocks(this)
mainPresenter = MainPresenter(mainRepository, mainContractView)
}
@Test fun createPresenter_setsThePresenterToView() {
mainPresenter = MainPresenter(mainRepository, mainContractView)
verify(mainContractView).presenter = mainPresenter
}
@Test
fun start() {
mainPresenter.start()
verify(mainContractView).showProgress(false)
verify(mainRepository).getData()
verify(mainContractView).setData(mainRepository.getData())
verify(mainContractView).showProgress(true)
}
}
@Mock 어노테이션은 가짜 객체를 생성합니다.
@Before @Test가 실행되기전 실행되어 객체를 초기화할때 사용됩니다.
@Test 테스트 케이스로 실행될 수 있음을 나타냅니다.
@Before가 붙은 setupPresenter() 에서는 @Mock 어노테이션을 사용한 멤버변수들을 생성하고 presenter 객체를 생성합니다.
@Test의 createPresenter_setsThePresenterToView() 에서는 MainPresenter 클래스를 생성했을때 View에 Presenter가 주입되는지 검증하는 테스트 케이스입니다.
@Test의 start() 에서는 Presenter의 start()를 호출하고 Presenter의 start()에 구현되어있는 동작들이 각각 제대로 호출되고 있는지 검증하는 테스트 케이스 입니다.
이렇게 Presenter의 Unit Test를 알아보았습니다.
끝으로 MVP 구현시 안드로이드의 Lifecycle에 영향을 받을 수 밖에 없는데요. 조사하고 제가 이해한 내용으로 설명드리면, 만약 View에서 Presenter를 호출하고 destroy된다면 Presenter는 Model에서 가져온 데이터를 View에 전달해야하는데 데이터를 받을 View가 없다면 실행하면 안되겠죠? 그래서 Presenter에 attach() detach() 같은 함수를 정의하고 Activity Lifecycle에 맞춰 View객체를 넣거나 null로 해제시켜 줍니다. 그리고 Presenter에서는 View의 함수를 호출하기전에 View객체를 null 체크를 해주는 방법을 사용하는거 같습니다.
그리고 화면 회전이 발생했을때 Activity가 재생성되어 보여지고 있던 데이터를 잃어버리기 때문에 caching을 해야합니다. 그래서 Repository를 singleton으로 구성해야 객체가 그대로 남아있어 화면 회전에도 대응할 수 있습니다.