AndroidアプリをMVPで実装してみた

はじめに

今回、業務でMVPアーキテクチャパターンを採用しました。
一部クリーンアーキテクチャも利用していますが、基本的にはMVPで実装を進めました。

設計に答えはないのですが、この記事を機会にViewにロジックをモリモリ書いている方が、設計に興味を持って頂ければ幸いです。

※依存性はDaggerを利用して解決しています。ソースは省略しているので、適宜読み取ってください。

実装

Model

モデル層では、各種データクラスとのやり取りを行います。
ex) APIからデータ取得・ローカルDBからデータ取得

また、Viewの処理に依存させずに、「データを操作すること」に特化したクラスにすることで
違う画面でも同じようにデータ取得処理を行うことができます。

モデル層の中で更に役割を分割することもできます。今回はRepository+DataSource方式を採用しました。

RepositoryはDataSourceを使ってPresenterにデータを返却します。
DataSourceはその名の通り、どこからデータを取得するかを持ちます。

今回はRetrofitを採用しているため、DataSourceは極めてシンプルです。

public interface AddressDataSource {

    Single addresses(String token);
}

public interface AddressClient {

    @GET("addresses")
    Single addresses(@Header("Authorization") String token);
}

public class AddressRemoteDataSource implements AddressDataSource {

    private Retrofit mRetrofit;

    public AddressRemoteDataSource(Retrofit retrofit) {
        mRetrofit = retrofit;
    }

    @Override
    public Single addresses(String token) {
        return mRetrofit.create(AddressClient.class).addresses(token);
    }
}

また、こちらではAddressDataSourceをinterfaceにすることで、flavorによるモック切り替えを行っています。
依存性については、Dagger2を利用して解決しています。※最下部参照

View

View層は基本的にはActivity/Fragmentを指します。Serviceなどでユーザーのイベントを受け取って処理する場合もView扱いできると思います。
Viewから直接Model層を利用してデータを扱うことは原則禁止です。Presenterに任せましょう。

また、ViewとPresenterはinterfaceを経由してやり取りするようにします。
Presenterが保持しているViewにDaggerを利用してinjectします。
こうすることで、Presenterのテスト時に、ViewをMockにすることができます。

public interface AddressListContract {

    interface View extends BaseView {
        void showProgress();
        void hideProgress();
        void showAddresses(List addresses);
        void showEmpty();
    }

    interface Presenter extends BasePresenter {
        void getAddressList();
    }
}

public class AddressListFragment extends BaseFragment implements AddressListContract.View {

    @Inject
    AddressListContract.Presenter mPresenter;

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        mPresenter.getAddressList();
    }

    @Override
    public void showProgress() {
        // fixme: プログレスの表示
    }

    @Override
    public void hideProgress() {
        // fixme: プログレスの非表示
    }

    @Override
    public void showAddresses(List addresses) {
        // fixme: データを表示
    }

    @Override
    public void showEmpty() {
        // fixme: データがない場合の表示
    }
}

Presenter

Presenter層はRepositoryからデータを取得したり、Viewでプログレスを表示させたりと
いわゆるビジネスロジックを担当します。
クリーンアーキテクチャではUseCaseを使って、ロジックをPresenterから切り離すこともあります。

しかし、今回は複雑なビジネスロジックが存在しなかったため、Presenterに直接Repositoryを操作させています。
(ex プログレス出して、データ取って、画面に出して、プログレス消す)

public class AddressListPresenter implements AddressListContract.Presenter {

    private AddressListContract.View mView;

    private AddressRepository mAddressRepository;

    public AddressListPresenter(AddressListContract.View view,
                                AddressRepository addressRepository) {
        mView = view;
        mAddressRepository = addressRepository;
    }

    @Override
    public void getAddressList() {
        mView.showProgress();

        mAddressRepository.addresses("token_dummy")
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .doFinally(mView::hideProgress)
            .subscribe(addressesResponse -> {
                if (addressesResponse.addresses.isEmpty()) {
                    mView.showEmpty();
                } else {
                    mView.showAddresses(addressesResponse.addresses);
                }
            }, throwable -> {
                // fixme: 取得失敗時の処理
            });
    }
}

Model層からの返却値をRxJava2を利用した形にしているのでPresenter層での操作がスッキリしています。

あまり特殊なロジックはないですが「データが無ければエンプティ表示をする」
という部分はPresenterが責任を持っています。

そしてエンプティ表示で何が表示されるのかはViewのみが責任を持ちます。
このようにそれぞれの層で責任を分割することで、テストが書きやすくなります。

PresenterではViewのshowEmptyが呼べていればOKです。
(ex Mockito.verify(mView).showEmpty())

Viewではダイアログでemptyを表示していればOK or Toastで表示など。

テスト

今回はPresenterのテストを書いています。(Mockitoを利用)

public class AddressListPresenterTest {

    @Mock
    AddressListContract.View mView;

    @Mock
    AddressRepository mAddressRepository;

    private AddressListPresenter mAddressListPresenter;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        RxJavaPlugins.setIoSchedulerHandler(scheduler -> Schedulers.trampoline());
        RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerCallable -> Schedulers.trampoline());
        mAddressListPresenter = new AddressListPresenter(mView, mAddressRepository);
    }

    @After
    public void tearDown() throws Exception {
        RxJavaPlugins.reset();
        RxAndroidPlugins.reset();
    }

    @Test
    public void 一覧取得_正常系() throws Exception {
        AddressesResponse addressesResponse = new AddressesResponse();
        addressesResponse.addresses = new ArrayList<>();
        addressesResponse.addresses.add(new AddressesResponse.Address());
        when(mAddressRepository.addresses("token_dummy")).thenReturn(Single.just(addressesResponse));

        mAddressListPresenter.getAddressList();

        verify(mView).showProgress();
        verify(mView).hideProgress();
        verify(mView, never()).showEmpty();
        verify(mView).showAddresses(addressesResponse.addresses);
    }

    @Test
    public void 一覧取得_エンプティ表示() throws Exception {
        AddressesResponse addressesResponse = new AddressesResponse();
        addressesResponse.addresses = new ArrayList<>();
        when(mAddressRepository.addresses("token_dummy")).thenReturn(Single.just(addressesResponse));

        mAddressListPresenter.getAddressList();

        verify(mView).showProgress();
        verify(mView).hideProgress();
        verify(mView).showEmpty();
        verify(mView, never()).showAddresses(any());
    }
}

Presenter以外の要素はモックにします。(interfaceにしているため可能)
こうすることで、Presenterの責任にのみフォーカスしてテストすることが可能です。

showEmpty()されたら何が表示されるか、はPresenterのテストでは必要ありません。

まとめ

一部省略していますが、MVPのイメージができたでしょうか。
責任を明確にし、クラスを分割することでそれぞれのテストが簡潔に記載できます。

また、変更が発生した場合も最小限の影響で済むようになります。
例えばデータのやり取りとViewの処理が混ざっていると、データのやり取り部分を修正するだけでViewにも影響が出ます。

Viewに全ての処理を詰め込んでいる方は、まずMVPで処理を分けてみることをオススメします。
※Daggerは必須ではありませんが、テストを書くなら利用したほうが良いと思います。

コードはgistで良かったかなぁ・・・

その他

上部に入り切らないソースを以下に記載します。

アプリケーション共通で利用するモジュール

@Module
public class AppModule {

    private Context mContext;

    public AppModule(Context context) {
        mContext = context;
    }

    @Singleton
    @Provides
    Context provideContext() {
        return mContext;
    }

    @Singleton
    @Provides
    Retrofit provideRetrofit(OkHttpClient client) {
        return new Retrofit.Builder()
                .client(client)
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(BuildConfig.API_END_POINT)
                .build();
    }

    @Singleton
    @Provides
    OkHttpClient provideOkHttpClient() {
        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        if (BuildConfig.DEBUG) {
            HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
            logging.setLevel(HttpLoggingInterceptor.Level.BODY);
            builder.addInterceptor(logging);
        }
        return builder.build();
    }
}

アプリケーション共通のコンポーネント

@Singleton
@Component(modules = {AppModule.class, RepositoryModule.class})
public interface AppComponent {

    Context getContext();

    AddressRepository getAddressRepository();
}

データソースの取得先を保持しているモジュール

このモジュールをflavorで切り替えることで、データ取得先をモック・API・ローカルDBなど切り替えることが可能となります。

@Module
public class RepositoryModule {

    @Singleton
    @Provides
    AddressDataSource provideAddressDataSource(Context context) {
        return new AddressMockDataSource(context);
    }
}

Presenterのモジュール

@Module
public class AddressListPresenterModule {

    private AddressListContract.View mView;

    public AddressListPresenterModule(AddressListContract.View view) {
        mView = view;
    }

    @Provides
    AddressListContract.Presenter providePresenter(AddressRepository addressRepository) {
        return new AddressListPresenter(mView, addressRepository);
    }
}