Model, View, Presenter로 구성되는 아키텍처 패턴입니다.
기존 MVC 패턴이 뷰와 모델간에 강한 의존성을 가지고 있는 단점을 해결하기 위해 고안되었습니다.
Model과 View는 Presenter를 통해 동작하게 됩니다.
각 구성요소의 역할은 다음과 같습니다.
각 요소가 분리되어 테스트코드를 작성하기 쉬운 형태가 됩니다.
결합도가 낮아져 추후 새로운 기능 추가 및 변경을 할 때 유리합니다.
앱의 규모가 작을 때는 오히려 불필요한 인터페이스가 추가되어, 오히려 효율이 나쁠 수 있습니다.
또 반대로, 앱의 규모가 커지고 복잡해 질수록 View와 Presenter간의 의존성이 커지며 Presenter에 모든 로직이 집중될 수 있습니다.
MVP패턴을 구현하는 여러가지 방법이 있지만, 이번에는 구글에서 권장하는 방법인 Contract를 정의하는 방법으로 구현해보도록 하겠습니다.
예시 코드는 네이버의 영화검색 API를 이용해서 만든 검색 및 리뷰 앱입니다.
interface SearchContract {
interface SearchPresenter {
fun initPresenter(view: View, dataSource: MovieDataSource, db: MovieDB)
suspend fun onClickSearch(searchString: String)
suspend fun onClickMovie(movie: Movie)
}
interface View {
fun submitSearchList(list: List<Movie>)
fun controlVisibility(isEmpty: Boolean)
fun showReviewDialog(movie: Movie, review: ReviewEntity?, insertReview: suspend (ReviewEntity)->Unit)
}
}
영화 검색을 위한 Search화면의 Contact 입니다.
Contract에는 Presenter와 View의 인터페이스를 작성합니다.
Presenter에는 이벤트를 전달받는 함수를 작성하고, View에는 데이터를 전달받는 함수를 작성합니다.
제 생각이지만, 이런 식으로 Contract를 먼저 작성해두고 개발을 시작한다면, 각 기능에 대해 더욱 명확하게 코드를 작성할 수 있어 좋을 것 같습니다.
이렇게 작성된 Presenter와 View의 인터페이스는 각각 PresenterImpl과 Activity/Fragment에서 구현하게 됩니다.
따라서 추후에 테스트코드를 작성할 때, View의 객체를 Mocking하여 테스트코드를 보다 쉽게 작성할 수 있게 됩니다.
class SearchPresenterImpl: SearchContract.SearchPresenter {
private var dataSource: MovieDataSource? = null
private var db: MovieDB? = null
private var view: SearchContract.View? = null
override fun initPresenter(view: SearchContract.View, dataSource: MovieDataSource, db: MovieDB) {
this.dataSource = dataSource
this.view = view
this.db = db
}
private suspend fun searchMovie(searchString: String): MovieResult? {
return dataSource?.getMovies(searchString)
}
private fun getReview(movie: Movie): ReviewEntity? {
return movie.title?.let { db?.reviewDAO()?.getRate(it) }
}
private fun insertReview(reviewEntity: ReviewEntity) {
db?.reviewDAO()?.insert(reviewEntity)
}
override suspend fun onClickSearch(searchString: String) {
val list: List<Movie>
withContext(Dispatchers.IO) {
val searchResult = searchMovie(searchString)
list = searchResult?.items?.filter { (!it.image.isNullOrEmpty() && it.userRating != "0.00") } ?: emptyList()
}
view?.run {
submitSearchList(list)
controlVisibility(list.isEmpty())
}
}
override suspend fun onClickMovie(movie: Movie) {
val review: ReviewEntity?
withContext(Dispatchers.IO) {
review = getReview(movie)
}
view?.showReviewDialog(movie, review) { reviewEntity: ReviewEntity ->
withContext(Dispatchers.IO) {
insertReview(reviewEntity)
}
}
}
}
먼저 Presenter에서 작성해둔 메소드들을 override하여 구현합니다.
Model에서 데이터를 받고, 처리한 뒤 View에게 데이터를 넘겨 업데이트 합니다.
이때, Presenter는 인터페이스를 통해 View에게 데이터만을 전달할 뿐, 실질적인 업데이트는 View가 직접 수행합니다.
class SearchFragment : Fragment(), SearchContract.View {
private lateinit var dataBinding: FragmentSearchBinding
private val movieDataSource by lazy { MovieDataSource() }
private val adapter by lazy { SearchAdapter(onClickMovie, onClickDetail) }
private val searchPresenter: SearchContract.SearchPresenter by lazy { SearchPresenterImpl() }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
dataBinding = DataBindingUtil.inflate(layoutInflater, R.layout.fragment_search, container, false)
dataBinding.lifecycleOwner = this
return dataBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchPresenter.initPresenter(this, movieDataSource, App.db)
dataBinding.rvSearch.adapter = this.adapter
dataBinding.btnSearch.setOnClickListener {
lifecycleScope.launch {
searchPresenter.onClickSearch(dataBinding.etSearch.text.toString())
}
}
}
private val onClickMovie: Function1<Int,Unit> = { pos: Int ->
val movie = adapter.currentList[pos]
lifecycleScope.launch {
searchPresenter.onClickMovie(movie)
}
}
private val onClickDetail: Function1<String,Unit> = { link: String ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)))
}
override fun submitSearchList(list: List<Movie>) {
adapter.submitList( list )
}
override fun controlVisibility(isEmpty: Boolean) {
if (!isEmpty) {
dataBinding.rvSearch.visibility = View.VISIBLE
dataBinding.tvEmpty.visibility = View.INVISIBLE
} else {
dataBinding.rvSearch.visibility = View.INVISIBLE
dataBinding.tvEmpty.visibility = View.VISIBLE
}
}
override fun showReviewDialog(movie: Movie, review: ReviewEntity?, insertReview: suspend (ReviewEntity)->Unit) {
ReviewDialog.displayReviewDialog(parentFragmentManager, this@SearchFragment, movie.title ?: "Review", movie.image, review ) { reviewEntity: ReviewEntity ->
lifecycleScope.launch(Dispatchers.IO) {
insertReview.invoke(reviewEntity)
withContext(Dispatchers.Main) {
Toast.makeText(requireContext(), "리뷰가 등록되었습니다.", Toast.LENGTH_SHORT).show()
}
}
}
}
}
View에서 작성한 메소드들을 override하고 이벤트를 받는 부분에서 Presenter의 인터페이스를 통해 이벤트를 전달합니다.
이렇게 MVP를 간단히 구현해봤는데, 기존의 MVC보다 클래스가 많아지고 구조는 다소 복잡해졌지만 각 코드들의 관심사가 보다 잘 분리되어 나중에 테스트코드를 작성할 때 수월할 것 같습니다.
이 부분에 대해서는 추후에 테스트코드에 관련한 글을 작성할 때 더 자세히 다뤄보도록 하겠습니다.
감사합니다.
사용된 예제 코드는 제 github에 올려두었습니다.
https://github.com/Jong-han/Android_architect_study/tree/mvp