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



※ MVVM(Model - View - ViewModel) 이란?

: MVVM은 Model, View, ViewModel을 일컫는 말로 Model과 View, ViewModel 3가지 역할을 가진다. MVVM은 사용자 인터페이스와 데이터를 연결(바인딩)하는 메커니즘인 데이터 바인딩을 사용한다. 데이터 바인딩에 대해 모른다면 "예제를 통해 안드로이드 데이터 바인딩에 대해 알아보자" 포스트에서 데이터 바인딩에 대해서 조금이라도 알고 이 포스트를 봐야한다. 데이터 바인딩을 모른다면 해당 포스트 이해가 불가능하다. MVVM은 MVP의 몇몇 단점을 보완하기 위해 등장했다. 여기서 기술한 내용에 대해서 처음에는 이해가 안 갈 수 있다. 다음에 나오는 예제를 본 후 다시 이 부분을 본다면 이해가 갈 것이다. MVVM에서 Model은 MVP와 마찬가지로 데이터와 비즈니스 로직이 들어간다. 뷰 또한 마찬가지로 데이터를 표시한다. 하지만 MVP와 달리 데이터 바인딩을 사용하기 때문에 MVP처럼 UI를 보여주는 로직을 구현할 필요가 없다. ViewModel에서 모델에서 가져온 데이터를 바인딩된 UI에 바로 적용한다. 때문에 mvp 보단 mvvm에서 뷰는 간소화된다. 단, 애니메이션이나 액티비티 전환 등은 ViewModel에서 구현이 어려워 그런 부분은 View에서 구현한다. 기본적인 View의 상태와 UI 로직은 ViewModel에서 구현한다. 처음 접한 사람은 이 내용을 이해하기 어렵다. 다음의 예를 보고 이 내용을 다시 보면 이해가 갈 것이다. 다음 예제는 "안드로이드 mvp와 mvvm 예제로 알아보자(1) - 기본 구현" 포스트와 "안드로이드 mvp와 mvvm 예제로 알아보자(2) - mvp" 포스트에 나온 소스를 mvvm 설계 방식으로 변형한 것이다. 예제 소스는  https://github.com/wikibook/advanced-android-book/tree/master/tech05/NewGitHubRepos 이다.



Ex) mvvm을 이용한 예제(예제 소스의 app-mvvm)



: mvvm은 model, view, contract, viewmodel로 구성된다. contract는 mvp와 마찬가지로 뷰와 프레젠터가 구현할 인터페이스를 구현한다.


1. activity_repository_list.xml

: mvvm 에서는 데이터 바인딩을 사용한다 데이터 바인딩에 연결되는 클래스는 viewmodel이다. 아래를 보면 메인 액티비티에 ~.RepositoryListViewModel을 바인딩 한 걸 알 수 있다. 또한 Spinner에 리스너를 달았다. Spinner에 이벤트가 오면 RepositoryListViewModel의 onLanguageSpinnerItemSelected 메서드가 호출된다. RepositoryListViewModel에서는 progressBarVisibility 변수를 통해 ProgressBar의 visibility 속성을 변경할 수 있다. 

<layout
    
    ....

    <data>
        <!-- RepositoryListViewModel에 바인딩한다 -->
        <variable
            name="viewModel"
            type="com.github.advanced_android.newgithubrepo.viewmodel.RepositoryListViewModel"/>
    </data>

       ....

            <!-- ViewModelのonLanguageSpinnerItemSelected()를 onItemSelectedListener로 이용한다 -->
            <Spinner
                ...
                android:onItemSelected="@{viewModel::onLanguageSpinnerItemSelected}"
                />


        <!-- ViewModelのprogressBarVisibility를 View의 visibility(View 표시 여부)로 이용한다 -->
        <ProgressBar
            .....
            android:visibility="@{viewModel.progressBarVisibility}"
            />
</layout>


2. RepositoryListViewModel

: 아래는 RepositoryListViewModel 클래스 코드이다. 이전 포스트의 MVP와 비교해보자. 일단 Spinner의 리스너가 간략해 졌다. MVP에서는 코드에서 리스너 인터페이스의 메서드를 모두 구현해야 했다. 하지만 mvvm은 데이터 바인딩을 사용해 메서드가 많이 간결해졌다. 또한 Spinner 리스너는 MVP에서는 뷰에서 구현했었다. 하지만 mvvm은 데이터 바인딩을 통해 뷰에 직접 접근하지 않고 데이터를 뷰에 반영이 가능해져 리스너가 viewmodel에서 구현 가능하다. 따라서 view와의 의존이 없어지고 view 즉, 액티비티도 많이 간소화된다. loadRepositories 메서드를 보면 데이터 바인딩 덕에 변수로 progressBar 보이기/숨기기를 할 수 있다. 원래 mvp에서는 presenter에서 view의 메서드를 이용했었다. 마찬가지로 데이터 바인딩으로 view와의 의존을 줄인 걸 알 수 있다. 마지막으로 생성자를 보면 ~Contract를 매개변수로 받는다. Contract를 매개변수로 받는 이유에 대해 알아보자. viewmodel에서 데이터 바인딩을 통해 뷰와의 의존도를 줄였다. 기존 뷰의 역할을 데이터 바인딩으로 대신하는 것이다. 하지만 데이터 바인딩으로는 한계가 있는 부분이 있다. 액티비티 전환이나 애니메이션, 어댑터 접근 등이 있다. 이와 같은 부분은 데이터 바인딩으로 안 된다. 이렇게 한계가 있는 부분은 Contract 패키지 안에서 인터페이스로 정의한 후 View에서 해당 인터페이스 메서드를 재정의하도록 한다. 그리고 viewmodel의 생성자에 인자로 전달한다. 이에 대해선 아래 코드 다음 코드에서 볼 수 있다.
public class RepositoryListViewModel {
  public final ObservableInt progressBarVisibility = new ObservableInt(View.VISIBLE);
 
  public RepositoryListViewModel(RepositoryListViewContract repositoryListView, GitHubService gitHubService) {
    this.repositoryListView = repositoryListView;
    this.gitHubService = gitHubService;
  }

  public void onLanguageSpinnerItemSelected(AdapterView<?> parent, View view, int position, long id) {
    //  스피너의 선택 내용이 바뀌면 호출된다
    loadRepositories((String) parent.getItemAtPosition(position));
  }

  /**
   * 지난 일주일간 만들어진 라이브러리를 인기순으로 가져온다
   */
  private void loadRepositories(String langugae) {
    // 로딩 중이므로 진행바를 표시한다
    progressBarVisibility.set(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을 이용해 서버에 액세스한다

    // 지난 일주일간 만들어졌고 언어가 language인 것을 쿼리로 전달한다
    Observable<GitHubService.Repositories> observable = gitHubService.listRepos("language:" + langugae + " " + "created:>" + text);
    // 입출력(IO)용 스레드로 통신하고, 메인스레드로 결과를 받도록 한다
    observable.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Subscriber<GitHubService.Repositories>() {
      @Override
      public void onNext(GitHubService.Repositories repositories) {
        // 로딩이 끝났으므로 진행바를 표시하지 않는다
        progressBarVisibility.set(View.GONE);
        // 가져온 아이템을 표시하고자, RecyclerView에 아이템을 설정해 갱신한다
        repositoryListView.showRepositories(repositories);
      }

      @Override
      public void onError(Throwable e) {
        // 통신에 실패하면 호출된다
        // 여기서는 스낵바를 표시한다(아래에 표시되는 바)
        repositoryListView.showError();
      }

      @Override
      public void onCompleted() {
        // 아무것도 하지 않는다
      }
    });
  }
}


3. RepositoryListViewContract 인터페이스와 RepositoryListActivity 액티비티

: 위에서 말했듯이 데이터 바인딩에는 한계가 있기 때문에 viewmodel은 뷰에 정의된 메서드를 사용해야 한다. viewmodel에서 사용할 뷰의 메서드를 아래와 같이 인터페이스로 정의한다.  
public interface RepositoryListViewContract {
  void showRepositories(GitHubService.Repositories repositories);

  void showError();

  void startDetailActivity(String fullRepositoryName);
}

위 인터페이스를 View에서 구현한다. 구현한 메서드들은 데이터 바인딩으로는 할 수 없는 작업들을 하는 걸 알 수 있다. onCreate를 보면 binding.setVeiwModel로 레이아웃을 실제 인스턴스와 연결한 걸 알 수 있다. 이전 포스트 mvp와 비교해보면 뷰에서 하는 일이 현저히 줄어들 걸 알 수 있다. 다음은 RecyclerView의 각 목록을 mvvm으로 어떻게 구현했는 지 알아보자.

public class RepositoryListActivity extends AppCompatActivity implements RepositoryListViewContract {
  
  ....

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ActivityRepositoryListBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_repository_list);
    final GitHubService gitHubService = ((NewGitHubReposApplication) getApplication()).getGitHubService();
    binding.setViewModel(new RepositoryListViewModel((RepositoryListViewContract) this, gitHubService));

    // 뷰를 셋업
    setupViews();
  }

  /**
   * 목록 등 화면 요소를 만든다
   */
  private void setupViews() {
    // 툴바 설정
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    // Recycler View
    RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_repos);
    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    repositoryAdapter = new RepositoryAdapter((Context) this, (RepositoryListViewContract) this);
    recyclerView.setAdapter(repositoryAdapter);

    // SnackBar 표시에서 이용한다
    coordinatorLayout = (CoordinatorLayout) findViewById(R.id.coordinator_layout);

    // Spinner
    languageSpinner = (Spinner) findViewById(R.id.language_spinner);
    ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item);
    adapter.addAll("java", "objective-c", "swift", "groovy", "python", "ruby", "c");
    adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    languageSpinner.setAdapter(adapter);
  }

  // =====RepositoryListViewContract 구현=====
  // 여기서 Presenter로부터 지시를 받아 뷰의 변경 등을 한다

  @Override
  public void startDetailActivity(String full_name) {
    DetailActivity.start(this, full_name);
  }

  @Override
  public void showRepositories(GitHubService.Repositories repositories) {
    repositoryAdapter.setItemsAndRefresh(repositories.items);
  }

  @Override
  public void showError() {
    Snackbar.make(coordinatorLayout, "읽을 수 없습니다", Snackbar.LENGTH_LONG)
            .setAction("Action", null).show();
  }

}


4. repo_item.xml

: repo_item.xml은 RecyclerView의 각 목록 레이아웃이다. 각 목록은 RepositoryItemViewModel과 바인딩된다. 아래 코드의 bind:imageUrl~ 에 대해 잘 모를 것이다. 이에 대해 알아보자. 아래 코드의 다음 부분을 보자. 
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    ...

    <data>

        <variable
            name="viewModel"
            type="com.github.advanced_android.newgithubrepo.viewmodel.RepositoryItemViewModel" />
    </data>

    <android.support.v7.widget.CardView
        android:clickable="true"
        android:onClick="@{viewModel::onItemClick}"
        
        <RelativeLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="8dp">

            <ImageView
                bind:imageUrl="@{viewModel.repoImageUrl}" />

            <TextView
                android:text="@{viewModel.repoName}"/>

            <TextView
                android:text="@{viewModel.repoDetail}" />

            <TextView
                android:text="@{viewModel.repoStar}"/>

        </RelativeLayout>


    </android.support.v7.widget.CardView>
</layout>

bind:imageUrl = "@{viewModel.repoImageUrl} 은 @BindingAdpater({"imageUrl"}) 애노테이션이 붙은 메서드를 호출한다. 따라서 아래의 메서드가 호출된다. 이전 mvp 에서는 별도의 ViewHolder에서 별도의 loadImage 메서드를 호출했었다. 데이터 바인딩을 사용하면 아래와 같이 하면 된다.

public class BindingAdapters {

  @BindingAdapter({"imageUrl"})
  public static void loadImage(final ImageView imageView, final String imageUrl) {
    // 이미지는 Glide라는 라이브러리를 사용해 데이터를 설정한다
    Glide.with(imageView.getContext())
         .load(imageUrl)
         .asBitmap().centerCrop().into(new BitmapImageViewTarget(imageView) {
      @Override
      protected void setResource(Bitmap resource) {
        // 이미지를 동그랗게 오려낸다
        RoundedBitmapDrawable circularBitmapDrawable = RoundedBitmapDrawableFactory.create(imageView.getResources(), resource);
        circularBitmapDrawable.setCircular(true);
        imageView.setImageDrawable(circularBitmapDrawable);
      }
    });
  }
}


5. RepositoryItemViewModel

: RecyclerView의 각 목록에 대한 viewmodel이다. 

public class RepositoryItemViewModel { public ObservableField<String> repoName = new ObservableField<>(); public ObservableField<String> repoDetail = new ObservableField<>(); public ObservableField<String> repoStar = new ObservableField<>(); public ObservableField<String> repoImageUrl = new ObservableField<>(); RepositoryListViewContract view; private String fullName; public RepositoryItemViewModel(RepositoryListViewContract view) { this.view = view; } public void loadItem(GitHubService.RepositoryItem item) { fullName = item.full_name; repoDetail.set(item.description); repoName.set(item.name); repoStar.set(item.stargazers_count); repoImageUrl.set(item.owner.avatar_url); } public void onItemClick(View itemView) { view.startDetailActivity(fullName); } }


6. RepositoryAdaper

: onCreateViewHolder에서 RecyclerView 목록 레이아웃과 viewmodel 인스턴스를 서로 연결한다. onBindViewHolder에서 실제 데이터를 넣는다. 
public class RepositoryAdapter extends RecyclerView.Adapter<RepositoryAdapter.RepoViewHolder> {

  public RepositoryAdapter(Context context, RepositoryListViewContract view) {
    this.context = context;
    this.view = view;
  }

  .....


  /**
   * RecyclerView의 아이템의 뷰 작성과 뷰를 보존할 ViewHolder를 생성
   */
  @Override
  public RepoViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    RepoItemBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.repo_item, parent, false);
    binding.setViewModel(new RepositoryItemViewModel(view));
    return new RepoViewHolder(binding.getRoot(), binding.getViewModel());
  }

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

  }

  /**
   * 뷰를 보존해 둘 클래스
   * 여기서는 ViewModel을 가진다
   */
  static class RepoViewHolder extends RecyclerView.ViewHolder {
    private final RepositoryItemViewModel viewModel;

    public RepoViewHolder(View itemView, RepositoryItemViewModel viewModel) {
      super(itemView);
      this.viewModel = viewModel;
    }

    public void loadItem(GitHubService.RepositoryItem item) {
      viewModel.loadItem(item);
    }
  }


}

※ MVVM 장점
: 데이터 바인딩을 통해 View, 액티비티를 많이 간소화할 수 있다. 또한 viewmodel이 view에 대한 의존도가 줄어들어 테스트가 쉽다. 


※ MVVM 단점
: 데이터 바인딩에 대한 처리가 자동으로 생성되므로 가독성이 낮고 디버깅하기가 어렵다.


+ Recent posts