Android MVP 패턴

Jong_Han·2022년 1월 24일
0

Android

목록 보기
7/9

MVP?

Model, View, Presenter로 구성되는 아키텍처 패턴입니다.

기존 MVC 패턴이 뷰와 모델간에 강한 의존성을 가지고 있는 단점을 해결하기 위해 고안되었습니다.

Model과 View는 Presenter를 통해 동작하게 됩니다.

각 구성요소의 역할은 다음과 같습니다.

장점

각 요소가 분리되어 테스트코드를 작성하기 쉬운 형태가 됩니다.

결합도가 낮아져 추후 새로운 기능 추가 및 변경을 할 때 유리합니다.

단점

앱의 규모가 작을 때는 오히려 불필요한 인터페이스가 추가되어, 오히려 효율이 나쁠 수 있습니다.

또 반대로, 앱의 규모가 커지고 복잡해 질수록 View와 Presenter간의 의존성이 커지며 Presenter에 모든 로직이 집중될 수 있습니다.

구현

MVP패턴을 구현하는 여러가지 방법이 있지만, 이번에는 구글에서 권장하는 방법인 Contract를 정의하는 방법으로 구현해보도록 하겠습니다.

예시 코드는 네이버의 영화검색 API를 이용해서 만든 검색 및 리뷰 앱입니다.

Contract

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하여 테스트코드를 보다 쉽게 작성할 수 있게 됩니다.

Presenter

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가 직접 수행합니다.

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

profile
안드로이드 개.....발자?

0개의 댓글