관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] MVP 디자인패턴 정리 및 예제 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] MVP 디자인패턴 정리 및 예제

막무가내막내 2019. 8. 23. 13:01
728x90

 

[2021-04-13 업데이트]

 

프로젝트에 MVC 아키텍처만 사용하다가 최근 간단한 공부용 프로젝트를 통해 MVP 아키텍처를 적용해보고있다. 확실히 기존 MVC 구조보다 코드가 정리되는 느낌이 들었다. MVP구조로 짜는 연습을 많이하고 MVVM도 나중에 공부해볼 예정이다.

 

이번에 MVP 아키텍처 공부한 것을 간략하게 정리하는 포스팅을 하려고한다.

 

출처: http://blog.dramancompany.com/2016/08/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0/

 

먼저 MVC와 MVP에 대한 구조도 그림이다. 그림을 보면 알 수 있듯이 둘은 C와 P만 바뀌고 MVC구조에서는 모델과 뷰와 이어져있지만 MVP는 프레젠터를 통해서 모델과 뷰가 소통이 되야한다.

 

안드로이드에서 MVC구조는 사실상 액티비티나 프래그먼트에 컨트롤러와 뷰에 관한 코드를 전부 집어넣어서 MVC패턴이라 하기도 애매하고 코드가 복잡해진다는 단점이 있다.(즉 웹에서는 MVC가 통용되는 반면 안드로이드에서의 적용은 힘들고 불가능에 가깝다.) 그에 반면, MVP는 Presenter를 만들어 모델과 뷰를 분리해주고 Presenter를 통해 모델과 뷰를 소통하게 해주므로 코드가 더 깔끔해지고 유지보수 하기 좋게 해주는 효과가 생긴다.

 

MVP구조에 대해 좀 더 정리하자면,

M은 MODEL로서 앱 사용되는 데이터와 그 데이터를 처리하는 부분이다. (난 아직 Repository를 구현하지 않았지만 Repository 관련 소스들이 들어가 있을 거다.)

V는 VIEW로서 말 그대로 사용자들에게 보여지는 UI라고 할 수 있다. 뷰의 터치 이벤트, UI갱신만 담당한다.

P는 Presenter로서(증여자) View에서 요청한 정보로 Model을 가공하여 View에 전달해 주는 부분이다. View와 Model을 붙여주는 접착제같은 역할을 한다. Presenter와 View가  1대1  관계가 된다.

 

MVP구조의 장점은 그림에서 MVC구조와 비교하면 알 수 있듯이 뷰와 모델의 의존성이 사라진다는 것이다.

단점은 View와 Model 사이의 의존성은 해결되었지만, View와 Presenter 사이의 의존성이 높아졌기 때문에 어플리케이션이 복잡해 질 수록 View와 Presenter 사이의 의존성이 강해지는 단점이 있습니다. (장점이 단점이 되는 것 같습니다. ㅎㅎ, 하지만 MVC패턴보다는 보기 확실히 좋습니다. )

 


MVP를 적용한 코드를 살펴보겠씁니다. 

 

[2021-01-22 예제 업데이트] 

이전에 쓴 예제가 첫 MVP 공부했을 때 예제고 제가 봐도 이해하기 힘든 것 같아 다른 코드로 수정했습니다. 다만 예전 코드는 Java인데 바꾼 코드는 Kotlin입니다..

 

 

 

액티비티 (View) : 외부 API나 로컬디비와 통신하는건 Pesenter가 해주고 뷰는 오로지 뷰의 클릭이벤트라든가 뷰를 그리는 것 같은 뷰의 역할만 오로지 전담한다. Presenter와 연결되어 있다. 

package com.mtjin.androidarchitecturestudy.ui.search

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.androidarchitecturestudy.R
import com.mtjin.androidarchitecturestudy.data.search.Movie
import com.mtjin.androidarchitecturestudy.utils.MyApplication


class MovieSearchActivity : AppCompatActivity(), MovieSearchContract.View {

    private lateinit var presenter: MovieSearchContract.Presenter
    private lateinit var etInput: EditText
    private lateinit var btnSearch: Button
    private lateinit var rvMovies: RecyclerView
    private lateinit var pbLoading: ProgressBar
    private lateinit var movieAdapter: MovieAdapter
    private lateinit var myApplication: MyApplication
    private lateinit var query: String
    private lateinit var scrollListener: EndlessRecyclerViewScrollListener

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movie_search)

        initView()
        initAdapter()
        initListener()
        presenter = MovieSearchPresenter(this, myApplication.movieRepository)
    }

    private fun initView() {
        myApplication = application as MyApplication
        etInput = findViewById(R.id.et_input)
        btnSearch = findViewById(R.id.btn_search)
        rvMovies = findViewById(R.id.rv_movies)
        pbLoading = findViewById(R.id.pb_loading)
    }

    private fun initAdapter() {
        movieAdapter = MovieAdapter()
        val linearLayoutManager = LinearLayoutManager(this)
        rvMovies.layoutManager = linearLayoutManager
        scrollListener = object : EndlessRecyclerViewScrollListener(linearLayoutManager) {
            override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
                presenter.requestPagingMovie(query, totalItemsCount + 1)
            }
        }
        rvMovies.addOnScrollListener(scrollListener)
        rvMovies.adapter = movieAdapter
    }

    private fun initListener() {
        //어댑터 아이템 클릭리스너
        movieAdapter.setItemClickListener { movie ->
            Intent(Intent.ACTION_VIEW, Uri.parse(movie.link)).takeIf {
                it.resolveActivity(packageManager) != null
            }?.run(this::startActivity)
        }
        //검색버튼
        btnSearch.setOnClickListener {
            query = etInput.text.toString().trim()
            presenter.requestMovie(query)
        }
    }

    override fun showLoading() {
        pbLoading.visibility = View.VISIBLE
    }

    override fun hideLoading() {
        pbLoading.visibility = View.GONE
    }

    override fun showToast(msg: String) {
        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
    }

    override fun showEmptyQuery() {
        Toast.makeText(this, getString(R.string.search_input_query_msg), Toast.LENGTH_SHORT).show()
    }

    override fun showWait() {
        Toast.makeText(this, getString(R.string.wait_toast), Toast.LENGTH_SHORT).show()
    }

    override fun showNetworkError() {
        Toast.makeText(this, getString(R.string.network_error_msg), Toast.LENGTH_SHORT).show()
    }

    override fun showNoMovie() {
        Toast.makeText(this, getString(R.string.no_movie_error_msg), Toast.LENGTH_SHORT).show()
    }

    override fun showLastPage() {
        Toast.makeText(this, getString(R.string.last_page_msg), Toast.LENGTH_SHORT).show()
    }

    override fun scrollResetState() {
        scrollListener.resetState()
    }

    override fun searchMovieSuccess(movieList: List<Movie>) {
        movieAdapter.clear()
        movieAdapter.setItems(movieList)
        Toast.makeText(this, getString(R.string.load_movie_success_msg), Toast.LENGTH_SHORT).show()
    }

    override fun pagingMovieSuccess(movieList: List<Movie>) {
        movieAdapter.setItems(movieList)
        Toast.makeText(this, getString(R.string.load_movie_success_msg), Toast.LENGTH_SHORT).show()
    }


}

'

 

 

 

Contract : View 와 Presenter의 끈(약속)이라 생각하면 편함. 내부 인터페이스로 View와 Presenter가 있는데 각각 자신의 계층에서 사용할 수 있는 함수들이다. 위와 아래 코드를 보면 View쪽은 Contract.View,  Presenter는 Contrat.Presenter를 상속받아 사용한다. 추가로 설명하면 View쪽은 자신의 Contract.View를 Presenter의 생성자로 넘겨주는데 Presenter는 로직을 처리하고 뷰에 변경사항이 필요하면 넘겨진 생성자 즉 뷰의 함수를 사용하여 뷰에게 화면을 어떻게 바꿔줘 하고 요청하게 되는 형식이다. 

 

메모장 앱이라고 생각하고 요약하면 

1. 뷰에서 메모추가해줘라는 이벤트 감지 및 발생

2. 뷰에서 프레젠터에 알려준다.

3. 프레젠터에서 메모장 작성시에 빈 값(null)인지 확인하는 로직 처리하면서 디비에 넣을 수 있는 메모라면 Model 즉 Repository에서 데이터베이스와 통신하는 작업 수행

4. Model 계층에 메모를 잘 저장했다면 뷰에 토스트 메세지를 보여주거나 화면을 종료(finish()) 해야함 프레젠터에서 뷰에 화면종료하거나 토스트메시지로 잘 저장됬다고 보여달라고 요청

5. 뷰가 프레젠터의 요청대로 화면조작

package com.mtjin.androidarchitecturestudy.ui.search

import com.mtjin.androidarchitecturestudy.data.search.Movie

interface MovieSearchContract {

    interface View {
        fun showLoading()
        fun hideLoading()
        fun showToast(msg: String)
        fun showEmptyQuery()
        fun showWait()
        fun showNetworkError()
        fun showNoMovie()
        fun showLastPage()
        fun scrollResetState()
        fun searchMovieSuccess(movieList: List<Movie>)
        fun pagingMovieSuccess(movieList: List<Movie>)
    }

    interface Presenter {
        fun requestMovie(query: String)
        fun requestPagingMovie(query: String, offset: Int)
    }
}

 

 

Presenter : 로직처리를 담당한다. 그리고 Model인 Repository와 연결되어있다. 로직처리 및 외부 API 또는 로컬 DB와 통신하고 결과에 따라 View의 변경을 요청하는 역할을함

package com.mtjin.androidarchitecturestudy.ui.search

import android.util.Log
import com.mtjin.androidarchitecturestudy.data.search.source.MovieRepository
import retrofit2.HttpException

class MovieSearchPresenter(
    private val view: MovieSearchContract.View,
    private val movieRepository: MovieRepository
) :
    MovieSearchContract.Presenter {

    override fun requestMovie(query: String) {
        if (query.isEmpty()) {
            view.showEmptyQuery()
        } else {
            view.showWait()
            view.showLoading()
            view.scrollResetState()
            movieRepository.getSearchMovies(query,
                success = {
                    if (it.isEmpty()) {
                        view.showNoMovie()
                    } else {
                        view.searchMovieSuccess(it)
                    }
                    view.hideLoading()
                },
                fail = {
                    Log.d(TAG, it.toString())
                    when (it) {
                        is HttpException -> view.showNetworkError()
                        else -> view.showToast(it.message.toString())
                    }
                    view.hideLoading()
                })
        }
    }

    override fun requestPagingMovie(query: String, offset: Int) {
        view.showLoading()
        movieRepository.getPagingMovies(query, offset,
            success = {
                if (it.isEmpty()) {
                    view.showLastPage()
                } else {
                    view.pagingMovieSuccess(it)
                }
                view.hideLoading()
            },
            fail = {
                Log.d(TAG, it.toString())
                when (it) {
                    is HttpException -> view.showNetworkError()
                    else -> view.showToast(it.message.toString())
                }
                view.hideLoading()
            })
    }

    companion object {
        const val TAG = "MovieSearchTAG"
    }
}

 

 

Model : Repositoy로 외부 API와 통신하는 로직 수행

package com.mtjin.androidarchitecturestudy.data.search


import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName

@Entity(tableName = "movie")
data class Movie(
    @SerializedName("actor")
    val actor: String,
    @SerializedName("director")
    val director: String,
    @SerializedName("image")
    val image: String,
    @SerializedName("link")
    val link: String,
    @SerializedName("pubDate")
    val pubDate: String,
    @SerializedName("subtitle")
    val subtitle: String,
    @PrimaryKey(autoGenerate = false)
    @SerializedName("title")
    val title: String,
    @SerializedName("userRating")
    val userRating: String
)
package com.mtjin.androidarchitecturestudy.data.search.source

import com.mtjin.androidarchitecturestudy.data.search.Movie
import com.mtjin.androidarchitecturestudy.data.search.source.local.MovieLocalDataSource
import com.mtjin.androidarchitecturestudy.data.search.source.remote.MovieRemoteDataSource
import com.mtjin.androidarchitecturestudy.utils.NetworkManager

class MovieRepositoryImpl(
    private val movieRemoteDataSource: MovieRemoteDataSource,
    private val movieLocalDataSource: MovieLocalDataSource,
    private val networkManager: NetworkManager
) : MovieRepository {

    override fun getSearchMovies(
        query: String,
        success: (List<Movie>) -> Unit,
        fail: (Throwable) -> Unit
    ) {
        if (networkManager.checkNetworkState()) {
            // remote 검색 전 local에서 먼저 검색해서 데이터 전달
            with(movieLocalDataSource.getSearchMovies(query)) {
                if (this.isNotEmpty()) {
                    success(this)
                }
            }
            // remote 에서 검색
            movieRemoteDataSource.getSearchMovies(
                query,
                success = {
                    // remote 성공시 remote 데이터 전달
                    movieLocalDataSource.insertMovies(it)
                    success(it)
                },
                fail = {
                    // remote 실패시 local 에서 검색
                    with(movieLocalDataSource.getSearchMovies(query)) {
                        if (this.isEmpty()) {
                            fail(it)
                        } else {
                            success(this)
                        }
                    }
                }
            )
        } else {
            // local 에서 검색
            with(movieLocalDataSource.getSearchMovies(query)) {
                if (this.isEmpty()) {
                    fail(Throwable("해당 영화는 존재하지 않습니다.\n네트워크를 연결해서 검색해주세요"))
                } else {
                    success(this)
                }
            }
        }
    }

    override fun getPagingMovies(
        query: String,
        start: Int,
        success: (List<Movie>) -> Unit,
        fail: (Throwable) -> Unit
    ) {
        if (networkManager.checkNetworkState()) {
            movieRemoteDataSource.getSearchMovies(
                query,
                start,
                success = {
                    movieLocalDataSource.insertMovies(it)
                    success(it)
                },
                fail = {
                    fail(it)
                }
            )
        }else{
            fail(Throwable("네트워크가 연결이 되어있지 않습니다."))
        }
    }
}

 

 

 


 

댓글과 공감은 큰 힘이 됩니다. 감사합니다.!

 

출처 및 참고 : https://beomy.tistory.com/43,  http://blog.dramancompany.com/2016/08/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0/ , https://www.youtube.com/watch?v=p4sYbZsTMDohttps://m.blog.naver.com/PostView.nhn?blogId=itperson&logNo=220840607398&proxyReferer=https%3A%2F%2Fwww.google.com%2F

728x90
Comments