이 포스트를 보기 전에 "안드로이드 mvp와 mvvm 예제로 알아보자(1) - 기본 구현" 포스트를 대충이라도 훑어봐야 한다. 그렇지 않으면 해당 포스트 이해가 힘들 것이다. 


※ MVP(Model - View - Presenter 란?

: MVP 설계는 Model - View - Presenter을 읽컫는 말로 모델과 뷰, 프레젠터라는 세 가지 역할로 나뉜다. 지금 말하는 거에 대해서 이해가 안 될 수 있다. 앞에서 차차 볼 프로젝트 예제를 본 후에 다시 지금 내용을 보면 이해가 갈 것이다. 모델에는 데이터와 비즈니스 로직이 들어가고 뷰는 데이터를 표시한다. 프레젠터는 뷰와 모델 사이에서 서로를 통신하는 역할을 한다. UI를 보여주는 데 있어서 뷰가 모델에 있는 데이터가 필요하다면 프레젠터를 이용한다. 마찬가지로 모델에 있는 데이터가 갱신되어 UI를 바꿔야 한다면 모델이 직접 뷰에 접근하는 게 아니라 프레젠터에 해당 작업을 위임한다. 많이 추상적이여서 이해가 안 될 것이다. 다음의 예를 보면서 알아보자. 다음 예제는  "안드로이드 mvp와 mvvm 예제로 알아보자(1) - 기본 구현" 포스트 에서 본 예제를 MVP 설계를 바탕으로 구현한 것이다. 예제 소스는  https://github.com/wikibook/advanced-android-book/tree/master/tech05/NewGitHubRepos 이다.




Ex) mvp를 이용한 예제(예제 소스의 app-mvp)



: mvp 에는 contract, model, presenter, view 패키지를 가진다. contract는 뷰와 프레젠터가 구현해야할 인터페이스가 정의되어 있다. 


1. RepositoryListContract 인터페이스

contract 패키지 내의 인터페이스는 뷰와 프레젠터가 구현해야할 인터페이스가 정의된다. 아래 코드를 보면 View가 구현할 View 인터페이스와 Presenter가 구현할 Presenter 인터페이스가 있다. 인터페이스를 정의하는 이유는 프레젠터와 뷰 사이의 통신을 위해서이다. 뷰에서는 UI 이벤트(ex. 클릭 이벤트)가 들어올 때 모델에 있는 데이터와 관련된 작업을 하려면 프레젠터에 접근해야 한다. 프레젠터는 이에 대한 결과를 UI 상에 보여주기 위해서는 뷰에 접근해야 한다. 이렇게 둘 사이에 통신을 하기 위해서 인터페이스를 정의한다. 프레젠터는 액티비티가 아닌 인터페이스로 뷰를 조작할 수 있다. 

/**
 * 각자의 역할이 가진 Contract(계약)를 정의해 둘 인터페이스
 */
public interface RepositoryListContract {

  /**
   * MVP의 View가 구현할 인터페이스
   * Presenter가 View를 조작할 때 이용한다
   */
  interface View {
    String getSelectedLanguage();
    void showProgress();
    void hideProgress();
    void showRepositories(GitHubService.Repositories repositories);
    void showError();
    void startDetailActivity(String fullRepositoryName);
  }

  /**
   * MVP의 Presenter가 구현할 인터페이스
   * View를 클릭했을 때 등 View가 Presenter에 알릴 때 이용한다
   */
  interface UserActions {
    void selectLanguage(String language);
    void selectRepositoryItem(GitHubService.RepositoryItem item);
  }
}


2. RepositoryListActivity

: onCreate()에서 Presenter을 생성한다. 인자로 ~Contract.View로 캐스팅한 this를 넘긴다. Presenter는 View를 조작할 때 인자로 넘어온 this를 이용한다. 이렇게 액티비티가 아닌 인터페이스를 전달하는 이유는 자료형을 교환하기 위해서다.  만약 테스트를 할 시 해당 인터페이스를 테스트용 구현으로 교환이 가능하다. setupViews()는 이전 포스트에서 알아본 setupViews()와 거의 동일하다. 다만 스피너의 항목을 선택했을 때 불려지는 메서드 하나가 다르다. 이전 포스트에서는 loadRepositories(language)가 호출됬었다. 이는 RepositoryListActivity에 구현됬었던 메서드였다. 하지만 MVP 설계에서는 ~Presenter.selectLanguage() 메서드 호출을 통해 프레젠터에게 뷰 이벤트 발생을 알리고 이에 필요한 작업을 프레젠터에게 위임한다. 즉, 이전 포스트에 있던 loadRepositories() 작업을 뷰(Activity)에서 직접 하는 게 아니라 프레젠터가 하게 되고 프레젠터가 직접 모델에 접근해서 RecyclerView의 목록을 가져온다. 또한 loadRepositories 메서드에서는 프로그래스 바 보이기/감추기, 스택바 보이기 같은 UI를 변경하는 메서드들이 있었다. 이런 사소한 UI 변경 작업도 뷰가 하지 않고 프레젠터에 위임한 것이다. 가져온 목록 데이터를 뷰에 보여주는 코드는 아래 코드의 다음 코드에서 볼 것이다. 아래 코드의 마지막을 보면 RecyclerView 목록 리스너 구현 부분이 나온다. 마찬가지로 뷰에서 직접 작업을 하지 않고 프레젠터에 위임한 걸 알 수 있다.

public class RepositoryListActivity extends AppCompatActivity implements RepositoryAdapter.OnRepositoryItemClickListener,
    RepositoryListContract.View {

    ......

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_repository_list);

    // View를 설정
    setupViews();

    // Presenter의 인스턴스를 생성
    final GitHubService gitHubService = ((NewGitHubReposApplication) getApplication()).getGitHubService();
    repositoryListPresenter = new RepositoryListPresenter((RepositoryListContract.View) this, gitHubService);
  }

  /**
   * 목록 등의 화면 요소를 만든다
   */
  private void setupViews() {

    ....View 초기화 및 Adpater 설정

    languageSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
      @Override
      public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        //  스피너의 선택 내용이 바뀌면 호출된다
        String language = (String) languageSpinner.getItemAtPosition(position);
        //  Presenter에 프로그래밍 언어를 선택했다고 알린다
        repositoryListPresenter.selectLanguage(language);
      }

      @Override
      public void onNothingSelected(AdapterView<?> parent) {

      }

    });
  }

  /**
   * RecyclerView에서 클릭됐다
   * @see RepositoryAdapter.OnRepositoryItemClickListener#onRepositoryItemClickListener
   */
  @Override
  public void onRepositoryItemClick(GitHubService.RepositoryItem item) {
    repositoryListPresenter.selectRepositoryItem(item);
  }

  ..... 다음 부분에서 볼 예정

}

인제 ~Contract.View 인터페이스를 구현한 부분을 보자. 해당 메서드들은 프레젠터가 뷰를 조작하기 위해 사용된다. 아래 코드를 보면 대부분이 뷰를 조작하는 메서드인 걸 알 수 있다. 이들 메서드들은 Presenter에서 호출된다.

// =====RepositoryListContract.View 구현===== // 이곳에서 Presenter로부터 지시를 받아 View의 변경 등을 한다 @Override public void startDetailActivity(String full_name) { DetailActivity.start(this, full_name); } @Override public String getSelectedLanguage() { return (String) languageSpinner.getSelectedItem(); } @Override public void showProgress() { progressBar.setVisibility(View.VISIBLE); } @Override public void hideProgress() { progressBar.setVisibility(View.GONE); } @Override public void showRepositories(GitHubService.Repositories repositories) { // 리포지토리 목록을 Adapter에 설정한다 repositoryAdapter.setItemsAndRefresh(repositories.items); } @Override public void showError() { Snackbar.make(coordinatorLayout, "읽을 수 없습니다", Snackbar.LENGTH_LONG) .setAction("Action", null).show(); }


3. RepositoryListPresenter

: 뷰에서 호출된 Presenter의 selectLanguage 메서드를 보자. 우리가 이전 포스트에서 보았던 loadRepositories() 메서드가 호출된다. loadRepositories() 메서드를 보면 이전 포스트의 loadRepositories()와 거의 똑같다. UI의 변경 부분만 다르다. UI 변경은 ~Contract.View 에서 정의된 메서드를 통해 한다. 그래서 RepositoryListActivity에서 정의한 showProgress, hideProgress, showRepositories 등을 통해 UI를 변경한다. 

public class RepositoryListPresenter implements RepositoryListContract.UserActions { ....변수 선언 및 초기화 @Override public void selectLanguage(String language) { loadRepositories(); } @Override public void selectRepositoryItem(GitHubService.RepositoryItem item) { repositoryListView.startDetailActivity(item.full_name); } /** * 지난 일주일간 만들어진 라이브러리의 인기순으로 가져온다 */ private void loadRepositories() { // 로딩 중이므로 진행바를 표시한다 repositoryListView.showProgress(); // 일주일 전 날짜 문자열 지금이 2016-10-27이면 2016-10-20 이라는 문자열을 얻는다 final Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DAY_OF_MONTH, -7); String text = DateFormat.format("yyyy-MM-dd", calendar).toString(); // Retrofit을 이용해 서버에 액세스한다 // 지난 일주일간 만들어지고 언어가 language인 것을 쿼리로 전달한다 Observable<GitHubService.Repositories> observable = gitHubService.listRepos("language:" + repositoryListView.getSelectedLanguage() + " " + "created:>" + text); // 입출력(IO)용 스레드로 통신해 메인스레드로 결과를 받아오게 한다 observable.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Subscriber<GitHubService.Repositories>() { @Override public void onNext(GitHubService.Repositories repositories) { // 로딩을 마쳤으므로 진행바 표시를 하지 않는다 repositoryListView.hideProgress(); // 가져온 아이템을 표시하기 위해, RecyclerView에 아이템을 설정하고 갱신한다 repositoryListView.showRepositories(repositories); } @Override public void onError(Throwable e) { // 통신에 실패하면 호출된다 // 여기서는 스낵바를 표시한다(아래에 표시되는 바) repositoryListView.showError(); } @Override public void onCompleted() { // 아무것도 하지 않는다 } }); } }


RepositoryAdapter를 비롯한 나머지 소스 파일들은 이전 포스트와 동일하다. 위 예제에서 보다시피 프레젠터는 뷰의 규현에 대해 자세히 알 필요가 없다. 단지 상위 인터페이스에 정의된 메서드만 호출하면 되기 때문이다. 뷰도 마찬가지다. 이렇게 역할을 명확히 나눌 수 있다.


※ MVP 장점

: 모델, 뷰, 프레젠터의 역할이 명확히 나누어져 코드 관리가 쉽다. 또한 액티비티에 대부분의 구현이 집중되지 않아 작게 만들 수 있다. 프레젠터가 뷰와 모델 사이를 이어주므로 뷰와 모델 서로는 직접적인 의존 관계가 사라진다. 또한 뷰와 프레젠터가 인터페이스를 통해 서로 접근하므로 위에서 말했듯이 교환이 가능해 테스트하기 쉬워진다.


※ MVP 단점

: 모델에서 가져온 데이터를 뷰에 표시하는 것은 개발자가 직접 구현해야해 번거롭다.  MVP 설계에 대한 명확한 기준이 없어서 설계하는 데 난이도가 높다. 뷰와 컨트롤러의 통신을 위해 인터페이스를 정의해야 하는 데 이 인터페이스가 길어지기 쉽다. 그리고 앱이 비대해질수록 프레젠터의 크기도 많이 커지고 복잡해질 가능성이 높다. 위 예제에서 보다시피 Contract 인터페이스 부분에 정의된 메서드를 구현할 때 한 줄로 된 게 많아 복잡하다. MVVM 설계는 MVP의 단점의 일부분을 보완해준다.



이전 포스트와 이번 포스트를 거쳐서 MVP 패턴에 대해서 알아보았다. 다음 포스트에서는 MVP의 단점의 일부분을 보완한 MVVM 설계에 대해서 알아볼 것이다.






: 이 포스트는 "안드로이드 개발 레벨업 교과서" 책을 요약한 내용이다. 구체적인 내용이 궁금하다면 이 책을 구매해서 읽어보면 좋겠다. 해당 포스트에서 활용할 예제 소스는 https://github.com/wikibook/advanced-android-book/tree/master/tech05/NewGitHubRepos 이다. 예제는 Git 웹 서비스 API를 이용해서 프로젝트의 리포지토리를 추출하는 앱이다. 예제에는 rxjava, Glide, Retrofit 등이 쓰였는 데 무엇인지 몰라도 된다. 소스코드 이해를 못해도 상관없다. 여기서는 mvp와 mvvm이 어떻게 구성되는 지 이해하는 게 중요하다. 아래 사진은 예제의 화면을 보여준다. 메인 엑티비티는 RecyclerView를 이용해 각 프로젝트 목록을 나타낸다. 툴바에는 스피너를 부착해 각 언어별로 프로젝트를 보여주도록 한다. RecyclerView의 목록을 누르면 마지막 사진과 같이 해당 프로젝트에 대한 세부 내용이 나온다. 먼저 mvp와 mvvm을 사용하지 않는 기본적인 구현 방법에 대해서 살펴보자. 기본적인 구현한 프로젝트를 mvp와 mvvm 설계 기법을 이용한 프로젝트로 수정해 나갈 것이다. 




Ex). mvp와 mvvm을 사용하지 않은 기본 구현(예제 소스의 app-original)



: app-original의 패키지를 보면 우리가 하는 일반적인 구조이다. RepositoryListActivity는 메인 액티비티이고 RecyclerView를 사용하기에 RepositoryAdapter를 이용한다. DetailActivity는 RecyclerView 의 목록을 누르면 나타나는 세부 내용 Activity이다. Git API 접근은 GitHubService와 NewGitHubReposApplication에서 한다. 


1. RepositoryListActivity

: 아래 코드는 RepositoryListActivity의 onCreate이다. 뷰 초기화 및 RecyclerView Adapter 연결을 한다. 스피너는 선택한 언어별에 따른 프로젝트를 보여줘야 되 Listener에서 loadRepositories 메서드를 호출한다.  

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_repository_list);

    // View를 설정한다
    setupViews();
  }

  private void setupViews() {
   
    ....View 초기화 및 Adpater 설정    


    languageSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
      @Override
      public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        // 선택시 뿐만 아니라 처음에도 호출된다
        String language = (String) languageSpinner.getItemAtPosition(position);
        loadRepositories(language);
      }

      @Override
      public void onNothingSelected(AdapterView<?> parent) {

      }

    });
  }

loadRepositories() 메서드는 다음과 같다. 아래 코드를 이해 못해도 된다. 아래 메서드가 Retrofit라는 라이브러리를 이용해 Git웹 서비스에 액세스해 프로젝트 데이터를 가져온다는 것과 UI 변경만 알면 된다. UI 변경에 대해서 보자. 일단 웹에 접근하기 전에 로딩 시간이 있으므로 로딩중이라는 걸 알려주기 위해 progressBar.setVisibility(View.visible);을 메서드 처음에 호출한다. 웹 서비스에서 데이터를 정상적으로 가져온다면 아래 코드에서 onNext 콜백 메서드가 호출된다. 데이터를 다 가져왔으므로 로딩 중임을 나타내는 프로그레스 바를 View.GONE으로 표시하지 않는다. 데이터를 RecyclerView에 보여줘야 되므로 repositoryAdapter.setItemsAndRefresh()를 호출하고 인자로 가져온 데이터를 전달한다. 만약 웹 서비스에서 데이터를 가져오는 과정 중 오류가 발생한다면 onError 콜백 메서드가 호출 되 스낵바를 보여준다. 이것만 알면 된다. 아래 코드의 구체적인 부분은 몰라도 된다.   

  /**
   * 지난 1주일간 만들어진 라이브러리의 인기순으로 가져온다
   * @param language 가져올 프로그래밍 언어
   */
  private void loadRepositories(String language) {
    // 로딩 중이므로 진행바를 표시한다
    progressBar.setVisibility(View.VISIBLE);

    // 일주일전 날짜의 문자열 지금이 2016-10-27이면 2016-10-20 이라는 문자열을 얻는다
    final Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DAY_OF_MONTH, -7);
    String text = DateFormat.format("yyyy-MM-dd", calendar).toString();

    // Retrofit를 이용해 서버에 액세스한다
    final NewGitHubReposApplication application = (NewGitHubReposApplication) getApplication();
    // 지난 일주일간 만들어지고 언어가 language인 것을 요청으로 전달한다
    Observable<GitHubService.Repositories> observable = application.getGitHubService().listRepos("language:" + language + " " + "created:>" + text);
    // 입출력(IO)용 스레드로 통신하고, 메인스레드에서 결과를 수신하게 한다
    observable.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Subscriber<GitHubService.Repositories>() {
      @Override
      public void onNext(GitHubService.Repositories repositories) {
        // 로딩이 끝났으므로 진행바를 표시하지 않는다
        progressBar.setVisibility(View.GONE);
        // 가져온 아이템을 표시하고자 RecyclerView에 아이템을 설정하고 갱신한다
        repositoryAdapter.setItemsAndRefresh(repositories.items);
      }

      @Override
      public void onError(Throwable e) {
        // 통신 실패 시에 호출된다
        // 여기서는 스낵바를 표시한다(아래에 표시되는 바)
        Snackbar.make(coordinatorLayout, "읽어올 수 없습니다.", Snackbar.LENGTH_LONG)
                .setAction("Action", null).show();
      }

      @Override
      public void onCompleted() {
        // 아무것도 하지 않는다
      }
    });
  }
RecyclerView의 목록을 클릭할 시 DetailActivity가 나타나야 한다. 다음은 이에 대한 소스이다.
  /**
   * 상세화면을 표시한다
   * @see RepositoryAdapter.OnRepositoryItemClickListener#onRepositoryItemClickListener
   */
  @Override
  public void onRepositoryItemClick(GitHubService.RepositoryItem item) {
    DetailActivity.start(this, item.full_name);
  }

위 메서드는 RepositoryAdapter안에 OnRepositoryItemClickListener 인터페이스를 상속해 구현했다. 액티비티에서 클릭리스너를 구현한 후 RepositoryAdapter에 넘기면 RepositoryAdapter는 각 목록 별로 해당 Lister을 연결한다. 다음에 살펴볼 RepositoryAdpater.java 구현 부분을 보면 알 것이다. GitHubService.RepositoryItem은 윗 부분에서 본 loadRepositories 메서드에서 Git 웹 서비스에 접근하여 얻은 데이터를 담는 RecyclerView 목록별 데이터 객체이다. 



2. RepositoryAdapter.java

: 생성자에서 RecyclerView의 클릭 리스너를 전달 받는 걸 알 수 있고 해당 리스너 인터페이스도 정의되 있다. 위에서 loadRepositories 메서드 부분에 setItemsAndRefresh(repositories.item) 메서드가 호출됬었다. 아래에 해당 메서드의 구현이 있다. 인자로 웹 서비스에서 가져온 데이터를 전달 받고 notifyDataSetChanged()로 RecyclerView에 적용된 데이터가 변경됬다는 걸 알려 RecyclerView내부 데이터를 refresh 한다. 마지막 부분은 ViewHolder 클래스이다.  

  public RepositoryAdapter(Context context, OnRepositoryItemClickListener onRepositoryItemClickListener) {
    this.context = context;
    this.onRepositoryItemClickListener = onRepositoryItemClickListener;
  }

  /**
   * 리포지토리의 데이터를 설정해서 갱신한다
   * @param items
   */
  public void setItemsAndRefresh(List<GitHubService.RepositoryItem> items) {
    this.items = items;
    notifyDataSetChanged();
  }

  interface OnRepositoryItemClickListener {
    /**
     * 리포지토리의 아이템이 탭되면 호출된다
     */
    void onRepositoryItemClick(GitHubService.RepositoryItem item);
  }

  /**
   * 뷰를 저장해 둘 클래스
   */
  static class RepoViewHolder extends RecyclerView.ViewHolder {
    private final TextView repoName;
    private final TextView repoDetail;
    private final ImageView repoImage;
    private final TextView starCount;

    public RepoViewHolder(View itemView) {
      super(itemView);
      repoName = (TextView) itemView.findViewById(R.id.repo_name);
      repoDetail = (TextView) itemView.findViewById(R.id.repo_detail);
      repoImage = (ImageView) itemView.findViewById(R.id.repo_image);
      starCount = (TextView) itemView.findViewById(R.id.repo_star);
    }
  }

Adapter에서 실질적인 역할을 하는 onCreateViewHolder와 onBindViewHolder 메서드를 보자. onBindViewer 메서드를 보면 Acitivity에서 전달된 리스너를 각 목록에 연결한다. 아래에 보면 Glide 라이브러리를 사용한 게 보인다. 잘 몰라도 된다. 그냥 RecyclerView의 목록의 ImageView에 image 데이터를 동그랗게 보여주는 것이다. 

  /**
   * RecyclerView의 아이템 뷰 생성과 뷰를 유지할 ViewHolder를 생성
   */
  @Override
  public RepoViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    final View view = LayoutInflater.from(context).inflate(R.layout.repo_item, parent, false);
    return new RepoViewHolder(view);
  }

  /**
   * onCreateViewHolder로 만든 ViewHolder의 뷰에
   * setItemsAndRefresh(items)으로 설정된 데이터를 넣는다
   */
  @Override
  public void onBindViewHolder(final RepoViewHolder holder, final int position) {
    final GitHubService.RepositoryItem item = getItemAt(position);

    // 뷰가 클릭되면 클릭된 아이템을 Listener에게 알린다
    holder.itemView.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        onRepositoryItemClickListener.onRepositoryItemClick(item);
      }
    });

    holder.repoName.setText(item.name);
    holder.repoDetail.setText(item.description);
    holder.starCount.setText(item.stargazers_count);
    // 이미지는 Glide라는 라이브러리로 데이터를 설정한다
    Glide.with(context)
         .load(item.owner.avatar_url)
         .asBitmap().centerCrop().into(new BitmapImageViewTarget(holder.repoImage) {
      @Override
      protected void setResource(Bitmap resource) {
        // 이미지를 동그랗게 만든다
        RoundedBitmapDrawable circularBitmapDrawable =
            RoundedBitmapDrawableFactory.create(context.getResources(), resource);
        circularBitmapDrawable.setCircular(true);
        holder.repoImage.setImageDrawable(circularBitmapDrawable);
      }
    });

  }

GitHubService와 NewGitHubReposApplication는 retrofit 라이브러리를 이용해 Git 웹 서비스에 접근한 후 데이터를 가져오는 역할을 하고 가져온 데이터를 저장할 객체를 가진다. 이 것만 알면 된다. DetailActivity의 코드는 안 봐도 상관 없다. 해당 포스트의 본 목적은 mvp와 mvvm 구조의 이해이기 때문이다.


해당 예제를 보면 대부분의 기능들이 액티비티에 집중해 있는 것을 알 수 있다. 즉, 프로젝트 크기가 비대해지면 액티비티가 비대해진다. 그리고 UI를 변경하는 부분과 UI에 대한 처리(ex. 웹에서 데이터를 불러오기)가 함께 있어 좋은 설계가 아니라고 볼 수 있다. 즉, 코드가 모듈화되지 않아 앱이 복잡해진다.


여기까지가 우리가 평상시 하는 기본적인 구현 방법으로 예제 프로젝트를 살펴봤다. 다음 포스트에서는 이번에 배운 예제를 mvp로 변경한 코드를 살펴 볼 것이다.   

+ Recent posts