この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
今回、業務で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);
}
}