AndroidではMVCよりMVPの方がいいかもしれない
Android開発していると、なんかMVCうまくいかないなぁとモヤモヤしてきました。そろそろ他のアーキテクチャを模索してみた方がいいんじゃないかと思い始めまして、ある程度考えがまとまったので自分なりの指針を残しておこうと思います。
そもそもアーキテクチャ必要なのか
世の中には色々なアーキテクチャが存在するんですが、なんか概念を読んでもスッと理解できることが少ないんですよね。これはなぜかと言うと アーキテクチャが解決しようとしている問題を理解できないからです。
極端に言うと、HelloWorldを表示するアプリにMVCを導入する必要があるの?って言うと答えはNoですよね。じゃあ猫の名前をリストで表示するアプリだったらどうかと言われると、これもまだ必要ないかもしれません。 つまり、アーキテクチャを適用しなくても問題がないほど小さなアプリにおいては、ただ冗長になるだけなので別にいらないわけです。
そのため、「アーキテクチャが必要なの?」「どんなアーキテクチャが必要なの?」という問いに対しては 「そもそもどんな問題を抱えてるのか」を明確にする必要があります。そうしないと本質を理解できないまま、なんでこんな面倒くさいことするんだろうというモヤモヤを抱えてしまうことになります。
Android開発のあるある問題
じゃあAndroid開発のあるある問題って何なのかというのを自分なりに3点整理してみました。
1. どこに何を書くべきなのかわからなくなる
ActivityやFragmentは、ビジネスロジックが入り込んで肥大化してしまいがちです。いわゆる スマートなUI と呼ばれるアンチパターンで、MVCを意識して書いているはずなのに、ActivityをControllerと捉えた結果、処理のほとんどをActivityに書くことになってしまうことも多いのではないでしょうか。
一方でエンジニアはこの状況を何とかしたい気持ちはあるため、ある人はModelに通信処理を寄せ、またある人はDaoを作って委譲させるなど 個々人のベストプラクティスが横行してしまいがちです。 こうして、どこに何が書いてあるかわからない一貫性のないアプリが育っていくことになります。
新しい機能を追加しようとした時にどういう方針で行えばよいかわからなかったり、バグを修正しようとした時にコードを追うのに時間がかかったりして、開発スピードが減速していくことになります。
2. 非同期処理まわりでバグが起こりやすい
Androidにおいて厄介と言われるのはライフサイクルの独特さです。非同期リクエストが返ってきたタイミングではすでにActivityが死んでいたりします。 非同期処理はコールバックを渡すパターンをよく見られますが、コールバックの中のコールバックのような感じで コールバック地獄が発生して、イベント処理があちこち散らばってしまいがちです。結果、コードの見通しが悪くちょっとした修正でバグを生みやすい状況が生まれてしまいます。
また、ActivityをまたいだModelの更新も問題になりやすいです。例えば詳細画面でいいねボタンを押して一覧画面に戻った時にいいねが反映されていない、などですね。
3. テストが書きにくい
Androidに依存するクラス、例えばFragmentやActivityが関係するとテストが一気に書きにくくなります。Activityを太らせるとよくないと言われるのはこれが大きな理由です。そのためビジネスロジックはなるべくViewやActivityに依存しないところに記述すべきなんですが MVCでいうControllerの部分をAndroidの中でどう捉えるかの指針がない状況ではなかなか難しいです。
また、AndroidはカメラやGCMService、API、DBと外部との連携が多く、連携の実処理といった技術的関心部分がビジネスロジックに入り込みやすいのではないでしょうか。結果、なんだかテストしにくいコードになりがちです。
ビジネスロジックの分離を強制したい
これだけ聞くと MVCで解決できそうじゃんと思うかもしれませんが、1年半くらいチームでAndroid開発してきた感想としては、MVCはうまくいきにくいんじゃないかなぁと感じています。 AndroidにおけるControllerの概念が広すぎて、Viewとごっちゃになりがち だからです。
例えば一覧を取ってきて表示する画面を作るとして、ロードやキャッシュはどこでやるべきなの?というのがわかりづらい。で、結局Activityとかでやっちゃって、ActivityはViewを持ってるのでコールバックの結果をそのままViewに反映しちゃったり。
そこで、そういう外部のデータの取得やViewへの反映などは誰が面倒見るべきかみたいなところが概念としてちゃんと定義されているアーキテクチャの方がいいんじゃないかと思ったわけです。 ビジネスロジックの分離を強制するアーキテクチャが必要でした。
MVP+DDDとの出会い
色々とアーキテクチャを勉強してはAndroidのコードに落としたらどうなるかを考えていたんですが Model-View-Presenterのアーキテクチャは1つの解決策になりそうだなと感じました。
以下参考にしたリンクです。
- The Clean Architecture
- Architecting Android…The clean way?
- MvpCleanArchitecture
- EffectiveAndroidUI
- ドメインモデル中心のアーキテクチャ
- Android-arch
これの何がいいかというと、概念ごとの役割が明確でそのままパッケージ構成に落として理解できるというところなんですよね。まさに ビジネスロジックの分離を強制するという目的に適しているアーキテクチャでした。
パッケージ構成と役割
全体の概念などはArchitecting Android…The clean way?を参考にしていただくとして、パッケージ構成から簡単に役割を説明しておきます。長いのでわかりにくかったら読み飛ばしてください。すみません。。
自分のパッケージ構成は以下のようになりました。
├data │ ├cache │ │ └TweetCache.java │ ├entity │ │ └mapper │ │ └TwitterUserMapper.java │ ├exception │ │ └NetworkConnectionException.java │ ├executor │ │ └JobExecutor.java │ └repository │ └TweetRepositoryImpl.java ├domain │ ├exception │ │ └ErrorBundle.java │ ├executor │ │ └ThreadExecutor.java │ ├model │ │ └Tweet.java │ ├repository │ │ └TweetRepository.java │ └usecase │ └GetTweetListUseCase.java │ ├presentation │ ├di │ │ ├component │ │ ├module │ │ │ ├ActivityModule.java │ │ │ ├ApplicationModule.java │ │ │ └TweetModule.java │ │ ├HasComponent.java │ │ └PerActivity.java │ ├presenter │ │ ├Presenter.java │ │ └TweetListPresenter.java │ ├Service │ │ └GcmService.java │ └view │ ├activity │ │ └TweetListActivity.java │ ├adapter │ │ └TweetsAdapter.java │ ├component │ │ └ProfileHeaderView.java │ ├fragment │ │ └TweetListFragment.java │ └util │ └DateUtil.java │ └MainApplication.java
domain
アプリケーションの中核となる部分です。モデルやビジネスロジックをまとめて書きます。 このパッケージ以下のクラスは、モデルがどこから取得されてどう表示されるかといった外界のことは何も知りません。
外界とのやりとりは全てインターフェースで行われます。実際に処理するクラスは data
パッケージ以下にあって、Daggerを使ってDIすることになります。余談ですが、依存性注入という言葉は意味が分かりにくいので嫌いです。
exception
ビジネスロジック上の例外をハンドリングするための抽象クラスを置きます。
public interface ErrorBundle { Exception getException(); String getErrorMessage(); }
実装は data/exception/NetworkConnectionException.java
のようなクラスで、Presenterがキャッチしてエラー表示など適切に処理します。
private void showErrorView(ErrorBundle errorBundle) { // エラーの表示 }
executor
UIスレッドから処理を行う時に違うスレッドで実行するためのインターフェースを置きます。
public interface ThreadExecutor { void execute(final Runnable runnable); }
model
モデルクラスを置きます。DDDではユビキタス言語で理解できる概念をクラスにするべきとのことですが、Entitiyをそのままモデルクラスにしてもいいかもしれません。
repository
modelを取得、更新するためのインターフェースです。CRUDに沿って書くのがわかりやすいと思います。
data/repository
以下に実際に処理するクラスを実装します。ここをインターフェースにしておくことで、API経由から取ってくるか、DBから取ってくるかといった技術的な部分はdomainパッケージでは知らずにすみます。
public interface TweetRepository { void getHomeTweetList(Long lastTweetId, TweetListCallback callback); void getUserTweetList(long userId, Long lastTweetId, TweetListCallback callback); interface TweetListCallback { void onTweetListLoaded(Collection<TweetModel> tweets); void onError(ErrorBundle errorBundle); } }
usecase
ユーザーのTweet一覧を取ってくる、ユーザーのプロフィールを取ってくる、といったビジネスロジックをクラス化します。実装としては、RepositoryとExecutorを使ってModelを取得して返すようにしています。 AndroidではUIスレッドと同じところで処理するとまずいので、以下のUseCaseインターフェースを継承して別スレッドで処理するようにします。
public interface UseCase extends Runnable { void run(); }
ここの実装は、HandlerやAsyncTaskを使ってもいいと思います。
結果の通知にはEventBusを利用することにしました。onEventMainThread(Event event)
で、UIスレッドがイベントを受け取ることができます。
この部分はRxAndroidを利用するケースも多いですが、ちょっとRxは概念を理解するのが難しかったのでとりあえず保留です。
public interface GetHomeTweetListUseCase extends UseCase { void execute(Long lastTweetId); public class OnLoadedEvent { public final Collection<TweetModel> tweetModels; public OnLoadedEvent(Collection<TweetModel> tweetModels) { this.tweetModels = tweetModels; } } public class OnErrorEvent { public final ErrorBundle errorBundle; public OnErrorEvent(ErrorBundle errorBundle) { this.errorBundle = errorBundle; } } }
data
domainパッケージで定義したインターフェースを実装します。APIのコール、DBアクセスといった技術的関心はここに集約されることになります。
cache
キャッシュの書き込み、キャッシュの取り出しなどを実装します。キャッシュ先をファイルにするのかDBにするのかメモリにするのかは実装次第です。
entitiy/mapper
entitiyをdomain/model
に変換するクラスを置きます。
Retrofitを使ってJsonデータのパースまでライブラリが面倒を見てくれている場合などは不要かもしれません。FacebookやTwitterなど外部のEntitiyをそのまま使いたくない場合は、mappterを使ってドメインモデルに変換して扱うのがいいんじゃないかなと思います。変換の良いやり方は模索中です。。
@Singleton class UserMapper { @Inject UserMapper() { } public UserModel transform(User user) { UserModel userModel = null; if (user != null) { userModel = new UserModel(user.id); userModel.setName(user.name); // ... } }
repository
domain/repository
のインターフェースを実装します。
キャッシュがあればキャッシュのデータを取得したり、API経由で取ってきたり、取ってきた物をキャッシュに詰めたりする部分もリポジトリが担います。 大事なのは、ここでどんなロジックで取ってこられようが、domain/repository
は知る由もないということです。まさにビジネスロジックを分離できるように設計されたパターンです。
presentation
ユーザーの入力を受け取って内部のドメインを処理し、Viewに反映する役割を担います。
di
Daggerを使ってDIを行うためのModuleを置きます。Moduleのベストな切り方は考え中ですが、今はAndroid-CleanArchitectureを参考に同じ構成でComponent、Moduleを分けています。newでインスタンス化するクラスをほぼ0にできるのでテストの時には簡単にモッククラスに切り替えることができます。
presenter
UseCaseとViewを持ち、入力によってそれぞれを操作します。例えば、クリックされたら presenter#onClicked()
を呼び出すようにしておいてonClicked()
の中で UseCaseの更新処理を呼び出し、コールバックイベントを受け取って view.showSuccess()
を呼び出すといった具合です。
presenterはライフサイクルを持つFragmentやActivityのイベントを受け取ることになるので、同じライフサイクルを表すonResume()
やonPause()
などのメソッドを定義しておきます。
public interface Presenter { /** * Method that control the lifecycle of the view. It should be called in the view's * (Activity or Fragment) onResume() method. */ void resume(); /** * Method that control the lifecycle of the view. It should be called in the view's * (Activity or Fragment) onPause() method. */ void pause(); }
presenterに持たせるViewを抽象化することで完全にAndroidとの依存をなくしてテストしやすくできますが、画面によってはViewの抽象化をうまくできず冗長な作りになる可能性があるので、あまりこだわる必要はないかもしれません。
@PerActivity public class TweetListPresenter implements Presenter { private final GetHomeTweetListUseCase getHomeTweetListUseCase; private TweetListView tweetListView; @Inject public TweetListPresenter(GetHomeTweetListUseCase getHomeTweetListUseCase) { this.getHomeTweetListUseCase = getHomeTweetListUseCase; } // FragmentはTweetListViewをimplementsしておく public void setView(@NonNull TweetListView view) { this.tweetListView = view; } @Override public void resume() { EventBus.getDefault().register(this); } @Override public void pause() { EventBus.getDefault().unregister(this); } public void loadTweetList() { this.hideViewRetry(); this.showViewLoading(); this.getTweetList(); } public void onTweetClicked(TweetModel tweetModel) { this.tweetListView.showTweet(tweetModel); } private void showViewLoading() { this.tweetListView.showLoading(); } private void hideViewLoading() { this.tweetListView.hideLoading(); } private void showViewRetry() { this.tweetListView.showRetry(); } private void hideViewRetry() { this.tweetListView.hideRetry(); } private void showErrorMessage(ErrorBundle errorBundle) { this.tweetListView.showError(""); } private void showUsersCollectionInView(Collection<TweetModel> tweets) { this.tweetListView.renderTweetList(tweets); } private void getTweetList() { this.getHomeTweetListUseCase.execute(null); } public void onEventMainThread(GetHomeTweetListUseCase.OnLoadedEvent event) { showUsersCollectionInView(event.tweetModels); hideViewLoading(); } public void onEventMainThread(GetHomeTweetListUseCase.OnErrorEvent event) { hideViewLoading(); showErrorMessage(event.errorBundle); showViewRetry(); } }
service
サービスクラスを置きます。Presenterを持ち、処理はPresenterに委譲する形になります。
view
activity、adapter、fragmentなどViewまわりのクラスを置きます。カスタムビューはcomponent以下に置きます。Utilクラスは、Viewに関わることなら view/util
に置きます。
この中はAndroidに完全に依存することになります。他のパッケージは依存しないようにするなら、Viewを抽象化したインターフェースを置いてFragmentやActivityにimplementsさせるとよいです。
public interface TweetListView extends LoadDataView { void renderTweetList(Collection<TweetModel> tweetModelCollection); void showTweet(TweetModel tweetModel); }
作ってみないと理解しにくい
一通り見てみると、思想はわかるけど役割細分化しすぎててちょっと冗長な感じがするなぁと思うかもしれません。例えば、PresenterがUseCaseを扱って中でRepositoryがデータを取得するって聞くと、UseCaseいるの?って思っちゃいそうです。自分も初めはそう思いました。
こういう細かいところは自分で書いてみないと腑に落ちないところがあるんですよね。アプリによっては、一部は改変した方がいい場合もあります。自分の経験上、わからない時はまず手を動かしてみると大体わかってくるので、とりあえずプロジェクト作って試してみることにしています。 まだ試している途中ですが、Twitterクライアントを作って試行錯誤しています。
実装スピードとのトレードオフ
MVPを導入してみて感じたのは、役割を細かく分ける分、新しい機能を作るときはクラスの数が逆に多くなるなぁということです。これは判断が難しいところですが、アプリによっては 必要のない抽象化によって実装スピードが落ちてしまうということになるかもしれません。
例えば、APIからデータを取得してリスト表示している部分にキャッシュを入れることになるかもしれないなぁと想定したとします。その場合、FactoryパターンやStrategyパターンを導入して実装をすげ替えられるように抽象化することになると思いますが、その変更が本当に必要になるのか、またはいつ必要になるのかは予測できない場合も多いです。一般的に抽象化は難しく、設計・実装ともに時間をかける必要があります。 アプリや組織の規模から変化の可能性を見極めて、必要があればアーキテクチャの一部を採用せずスピードを優先させた方がいいかもしれません。
自分の場合は、Presenterが扱うViewは抽象化しないで、AndroidのViewに依存してもいいんじゃないかなぁと思ったりしてます。画面ごとにインターフェースを作るとすごい数になって収集がつかないことになりそうな予感もしています。また、そもそも変化の多いViewまわりはUnitテストコードを書かず別のやり方で品質を担保した方がよい気もします。EspressoやRobolectricを使ってやるのがいいかもしれません。
その他考えたこと
- チームでアーキテクチャを選定した時は、ドキュメントに残すなり、コードレビューを徹底するなり、勉強会を行うなり、チーム全体に浸透させる仕組みが必須。
- RxAndroidは、ストリームの概念を掴むまでのコストが高いのでチームで導入しない方がいいかも。また、TwitterSDKなど外部APIは今まで通りCallbackでやっているので、ここはReactiveでここは違うみたいな状態になるのは微妙。
- Facebookシェアなど外部との連携部分はまだ考え中。
- ライブラリのラッパーについては必要ないと考えている。例えばUniversalImageLoaderをPicassoに変える時のために、
displayImage(url)
のようなメソッドを抽象化しておくべきか、という話。ライブラリをすげ替えるなんてことはほとんどないのでとりあえず考えなくていいかなと思っている。API部分はRepositoryパターンによって抽象化されるので問題なし。 - Dagger、EventBus、Retrofit、AndroidAnnotation、retrolambdaは導入した方がよさげ。