いろいろあって最近また設計について考えています。
AndroidでMVPを使用した設計は色々な人が紹介していますが、記事によって定義がそれぞれ異なっていたり、具体的にどうやってテストコードを書けばいいのかイメージ出来なかったりしてモヤモヤしていました。
以前自分で試したものもUIロジックへの厳密なテストを書くのを目的にしていましたが、実装が面倒過ぎて全然現実的ではありませんでした。
しかし、仕事で実際にMVPベースの汎用的な設計を考える必要があったため、今更ながらMVPをベースにどう実装すれば負担が大きくならずちゃんとテストが書ける設計になるのかを改めて色々試行錯誤しています。
その結果、それなりに実用的な設計になってきたんじゃないかなと思うので一旦進捗を共有します。
クラス構成
基本的には android10/Android-CleanArchitecture · GitHub を参考にしています。
クラス構成のイメージは以下の図のような形になります。
オレンジの部分が画面と一体一で作られるクラスで、緑の部分がその他のクラスです。
特徴としてはViewをInterfaceとして定義する事と、Viewと一対一になるクラスを各レイヤーごとに定義しハブとする事でモック化しやすくしているところです。
Presenterが直接Activityなどを保持するとPresenterとViewの責務の切り分けが曖昧になり、Viewのモックも作りにくくなってしまうためViewは抽象化します。
一方でPresenterやUseCaseは抽象化しなくてもモック化が容易で抽象化するメリットがあまり無いためInterfaceは作成しません。
実装サンプル
MVPの概念はまあ色々なサイトで説明されているので、実際に個人で開発しているmitsumineというはてぶリーダーアプリに適用した実装例を貼ります。
mitsumineが元々Kotlinで書かれているためサンプルもKotlinです。
(はてなブログにKotlinのシンタックスハイライトが無くて辛い)
View(Interface)
ViewにはActivity・Fragmentのメソッドを全て定義し、Presenterから呼び出せるようにします。
interface FeedView {
fun initViews()
fun showRefreshing()
fun dismissRefreshing()
fun setFeed(feedList: List<Feed>)
fun removeItem(feed: Feed)
fun clearAllItem()
fun startEntryInfoView(url: String)
fun sendUrlIntent(url: String)
fun sendShareUrlIntent(title: String, url: String)
fun sendShareUrlWithTitleIntent(title: String, url: String)
fun initListCell(holder: FeedAdapter.ViewHolder, feed: Feed)
fun loadThumbnailImage(holder: FeedAdapter.ViewHolder, url: String)
fun loadFaviconUrl(holder: FeedAdapter.ViewHolder, url: String)
}
Fragment(Viewの実体)
Viewを実装ActivityやFragmentです。
Presenterを保持しています。
ActivityやFragmentが行う役割は、ライフサイクルなどのイベントをPresenterに伝える事と、Presenterからの指示で画面を更新する事です。
基本的にはレイアウトやAndroidAPIへのアクセス処理しか書かず、if文やfor文などのロジックが無くなるのが理想です。
public class FeedFragment : Fragment(), FeedView, SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, View.OnLongClickListener { companion object { public fun newFragment(category: Category, type: Type): FeedFragment { val fragment = FeedFragment() val bundle = Bundle() bundle.putSerializable(Category::class.java.canonicalName, category as Serializable) bundle.putSerializable(Type::class.java.canonicalName, type as Serializable) fragment.arguments = bundle return fragment } } private var adapter: FeedAdapter? = null private val presenter: FeedPresenter = FeedPresenter() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return inflater.inflate(R.layout.fragment_feed, container, false) } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) val category = arguments.getSerializable(Category::class.java.canonicalName) as Category val type = arguments.getSerializable(Type::class.java.canonicalName) as Type presenter.onCreate(this, FeedUseCase(FeedRepository(context, category, type))); } override fun onDestroyView() { presenter.onDestroy() super.onDestroyView() } override fun onRefresh() { presenter.onRefresh() } override fun onClick(v: View) { presenter.onClick(v.id, v.tag as Feed) } override fun onLongClick(v: View): Boolean { return presenter.onLongClick(v.id, v.tag as Feed) } override fun initViews() { view.swipeLayout.setColorSchemeResources(R.color.blue, R.color.orange) view.swipeLayout.setOnRefreshListener(this) view.swipeLayout.setProgressViewOffset(false, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, resources.displayMetrics).toInt()) adapter = FeedAdapter(activity.applicationContext, presenter) view.feedListView.adapter = adapter } override fun setFeed(feedList: List<Feed>) { adapter!!.addAll(feedList) } override fun showRefreshing() { view.swipeLayout.isRefreshing = true } override fun dismissRefreshing() { view.swipeLayout.isRefreshing = false } override fun clearAllItem() { adapter!!.clear() } override fun removeItem(feed: Feed) { adapter!!.remove(feed) } override fun sendUrlIntent(url: String) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } override fun startEntryInfoView(url: String) { val intent = Intent(activity, EntryInfoActivity::class.java) intent.putExtras(EntryInfoActivity.buildBundle(url)) startActivity(intent) } override fun sendShareUrlIntent(title: String, url: String) { val share = Intent(Intent.ACTION_SEND) share.setType("text/plain") share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET) share.putExtra(Intent.EXTRA_SUBJECT, title) share.putExtra(Intent.EXTRA_TEXT, url) startActivity(share) } override fun sendShareUrlWithTitleIntent(title: String, url: String) { val share = Intent(Intent.ACTION_SEND) share.setType("text/plain") share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET) share.putExtra(Intent.EXTRA_TEXT, title + " " + url) startActivity(share) } override fun initListCell(holder: FeedAdapter.ViewHolder, feed: Feed) { holder.card.tag = feed holder.card.setOnClickListener(this) holder.card.setOnLongClickListener(this) holder.share.tag = feed holder.share.setOnClickListener(this) holder.share.setOnLongClickListener(this) holder.title.text = feed.title holder.content.text = feed.content holder.domain.text = feed.linkUrl } override fun loadThumbnailImage(holder: FeedAdapter.ViewHolder, url: String) { Picasso.with(context).load(url).into(holder.thumbnail) } override fun loadFaviconUrl(holder: FeedAdapter.ViewHolder, url: String) { Picasso.with(context).load(url).into(holder.favicon) } }
Adapter
ListViewのAdapterにもViewのPresenterを持たせ、getViewなどのイベントをPresenterに渡します。
その際にViewHolderとItemもPresenterに渡し、ViewHolderの更新はView(Activity・Fragment)内のメソッドで行います。
このようにする事でUIロジックPresenter、画面の更新をViewに集約されAdapterの肥大化を防ぐことが出来ます。
public class FeedAdapter(context: Context, val presenter: FeedPresenter) : ArrayAdapter<Feed>(context, 0) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view: View if (convertView == null) { view = LayoutInflater.from(context).inflate(R.layout.row_feed, null) val holder = ViewHolder(cell) view.tag = holder } else { view = convertView } presenter.onGetView(view.tag as ViewHolder, getItem(position)); return cell } class ViewHolder(feedView: View) { val card: View = feedView.findViewById(R.id.FeedFragmentCardView) val thumbnail: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewThumbnail) as ImageView val favicon: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewFavicon) as ImageView val share: ImageView = feedView.findViewById(R.id.FeedFragmentImageViewShare) as ImageView val title: TextView = feedView.findViewById(R.id.FeedFragmentTextViewTitle) as TextView val content: TextView = feedView.findViewById(R.id.FeedFragmentTextViewContent) as TextView val domain: TextView = feedView.findViewById(R.id.FeedFragmentTextViewDomain) as TextView } }
Presenter
PresenterではViewとUseCaseを持ちます。
Presenterの役割はViewからのイベントを受け取り、表出判定などのUIロジックを行いViewへ画面更新指示を返す事です。
UIに絡まない業務ロジックやデータの取得などはUseCaseクラスに任せます。
class FeedPresenter : Subscriber<List<Feed>>() { var view: FeedView? = null var useCase: FeedUseCase? = null fun onCreate(feedView: FeedView, feedUseCase: FeedUseCase) { this.view = feedView this.useCase = feedUseCase view?.initViews() view?.showRefreshing() useCase?.requestFeed(this) } fun onDestroy() { view = null useCase?.unSubscribe() } fun onRefresh() { view?.clearAllItem() view?.showRefreshing() useCase?.requestFeed(this) } override fun onNext(feedList: List<Feed>) { view?.setFeed(feedList) } override fun onError(e: Throwable?) { view?.dismissRefreshing() } override fun onCompleted() { view?.dismissRefreshing() } fun onClick(viewId: Int, feed: Feed) { when (viewId) { R.id.FeedFragmentCardView -> { view?.sendUrlIntent(feed.linkUrl) } R.id.FeedFragmentImageViewShare -> { if (useCase!!.isShareWithTitleSettingEnable()) { view?.sendShareUrlWithTitleIntent(feed.title, feed.linkUrl) } else { view?.sendShareUrlIntent(feed.title, feed.linkUrl) } } } } fun onLongClick(viewId: Int, feed: Feed): Boolean { when (viewId) { R.id.FeedFragmentCardView -> { if (useCase!!.isUseBrowserSettingEnable()) { view?.sendUrlIntent(feed.entryLinkUrl) } else { view?.startEntryInfoView(feed.linkUrl) } return true } R.id.FeedFragmentImageViewShare -> { if (useCase!!.isShareWithTitleSettingEnable()) { view?.sendShareUrlIntent(feed.title, feed.linkUrl) } else { view?.sendShareUrlWithTitleIntent(feed.title, feed.linkUrl) } return true } } return false } fun onGetView(holder: FeedAdapter.ViewHolder, feed: Feed) { view?.initListCell(holder, feed); if (!feed.thumbnailUrl.isEmpty()) { view?.loadThumbnailImage(holder, feed.thumbnailUrl) } if (!feed.faviconUrl.isEmpty()) { view?.loadFaviconUrl(holder, feed.faviconUrl) } } }
UseCase
UseCaseではRepositoryクラスを持ち、画面固有の業務ロジックを担当します。
シンプルな要件のアプリであればUseCaseの処理はほとんどRepositoryに移譲されるかもしれません。
アプリの仕様が複雑だったりAPI側で柔軟な対応が出来ないアプリであれば、UseCaseで画面固有のリスト操作や文字列操作などを行います。
open class FeedUseCase(val repository: FeedRepository) { val subscriptions = CompositeSubscription() open fun isUseBrowserSettingEnable() = repository.isUseBrowserSettingEnable open fun isShareWithTitleSettingEnable() = repository.isShareWithTitleSettingEnable open fun requestFeed(subscriber: Observer<List<Feed>>) { subscriptions.add(repository.requestFeed() .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .filter { feed -> !FeedUtil.contains(feed, repository.readFeedList) && !FeedUtil.containsWord(feed, repository.ngWordList) } .toList() .subscribe(subscriber)) } fun unSubscribe() { subscriptions.unsubscribe() } }
Repository
RepositoryではAPIやデータベースなどへのデータアクセスを担当します。
RepositoryクラスをUseCaseクラスとは別に定義する事でUseCaseのテストをしやすくします。
class FeedRepository(val context: Context, val category: Category, val type: Type) { fun requestFeed(): Observable<Feed> = FeedApi.requestCategory(context, category, type) val readFeedList: List<Feed> get() = FeedDAO.findAll() val ngWordList: List<String> get() = NGWordDAO.findAll() val isUseBrowserSettingEnable: Boolean get() = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.key_use_browser_to_comment_list), false) val isShareWithTitleSettingEnable: Boolean get() = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.key_is_share_with_title), false) }
Presenterのテスト
テストにはMockitoというモックライブラリを使用します。
http://mockito.org/
Mockitoはかなり便利なライブラリで、自動でクラスやインターフェイスからモックインスタンスを生成出来て、そのモックインスタンスのメソッドが何回呼ばれたかなどを判定するテストを書く事が出来ます。
MockitoでViewやUseCaseのモックを作りメソッドが呼びだされたかを確認する事でPresenterへのテストを非常に簡単に書く事が出来ます。
UseCaseへのテストも同じように書けるはずです。
import static org.mockito.Mockito.*; @RunWith(AndroidJUnit4.class) public class FeedPresenterTest { FeedView viewMock; FeedUseCase useCaseMock; @Before public void setup() { // モックインスタンスを生成 viewMock = mock(FeedView.class); useCaseMock = mock(FeedUseCase.class); } @Test public void onCreateTest() { FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); // verifyメソッドでモックオブジェクトのそれぞれのメソッドが呼ばれたかをチェックしている verify(viewMock, times(1)).initViews(); verify(viewMock, times(1)).showRefreshing(); verify(useCaseMock, times(1)).requestFeed(presenter); } @Test public void onRefreshTest() { FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onRefresh(); verify(viewMock, times(1)).clearAllItem(); verify(viewMock, times(2)).showRefreshing(); verify(useCaseMock, times(2)).requestFeed(presenter); } @Test public void onNextTest() { FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); List<Feed> list = new ArrayList<>(); presenter.onNext(list); verify(viewMock, times(1)).setFeed(list); } @Test public void onErrorTest() { FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onError(new Throwable()); verify(viewMock, never()).setFeed(any(List.class)); verify(viewMock, times(1)).dismissRefreshing(); } @Test public void onCompleteTest() { FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onCompleted(); verify(viewMock, never()).setFeed(any(List.class)); verify(viewMock, times(1)).dismissRefreshing(); } @Test public void onClickFeedTest() { // whenメソッドで指定したメソッドの戻り値を指定している when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true); Feed feed = new Feed(); feed.setLinkUrl("http://test"); FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onClick(R.id.FeedFragmentCardView, feed); verify(viewMock, times(1)).sendUrlIntent("http://test"); } @Test public void onFeedLongClickTest() { Feed feed = new Feed(); feed.setLinkUrl("http://test"); feed.setEntryLinkUrl("http://entry"); when(useCaseMock.isUseBrowserSettingEnable()).thenReturn(true); FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onLongClick(R.id.FeedFragmentCardView, feed); verify(viewMock, times(1)).sendUrlIntent("http://entry"); verify(viewMock, never()).startEntryInfoView("http://test"); when(useCaseMock.isUseBrowserSettingEnable()).thenReturn(false); presenter.onLongClick(R.id.FeedFragmentCardView, feed); verify(viewMock, times(1)).sendUrlIntent("http://entry"); verify(viewMock, times(1)).startEntryInfoView(("http://test")); } @Test public void onFeedShareClickTest() { Feed feed = new Feed(); feed.setTitle("title"); feed.setLinkUrl("http://test"); feed.setEntryLinkUrl("http://entry"); when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true); FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onClick(R.id.FeedFragmentImageViewShare, feed); verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test"); verify(viewMock, never()).sendShareUrlIntent("title", "http://test"); when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(false); presenter.onClick(R.id.FeedFragmentImageViewShare, feed); verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test"); verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test"); } @Test public void onFeedShareLongClickTest() { Feed feed = new Feed(); feed.setTitle("title"); feed.setLinkUrl("http://test"); feed.setEntryLinkUrl("http://entry"); when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(true); FeedPresenter presenter = new FeedPresenter(); presenter.onCreate(viewMock, useCaseMock); presenter.onLongClick(R.id.FeedFragmentImageViewShare, feed); verify(viewMock, never()).sendShareUrlWithTitleIntent("title", "http://test"); verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test"); when(useCaseMock.isShareWithTitleSettingEnable()).thenReturn(false); presenter.onLongClick(R.id.FeedFragmentImageViewShare, feed); verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test"); verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test"); } }
所感
すでに実装されているコードをこの設計に書き換えるのはわりと大変ですが、最初からMVPを前提に実装すればそれほど手間は掛からないはずで、むしろ業務の開発規模であれば設計が明確になりテストが書きやすくなるメリットの方が大きくなるんじゃないかなぁと思っています。
Viewのメソッドをどのくらい細かく分けてどの程度の粒度でテストを書くかなどは試行錯誤が必要そうですが、とりあえずテストが書ける環境が整えば試行錯誤もしやすいのではないでしょうか。
サンプルではViewでUseCaseやRepositoryをnewしていますが、Viewのテストも書きたければDaggerなどを使った方がいいかもしれません。