「Androidアプリ設計パターン入門」を読んで(その3)

今回の記事では、MVP(Model-View-Presenter)と呼ばれるアーキテクチャについて説明していきます。

サンプルアプリ

詳細は前回の記事をご参照ください。

MVP(Model-View-Presenter)とは

MVPは、UIの複雑さを解決することを目的としたアーキテクチャです。

MVPの各要素の主な役割は以下の通りです。

  • Model
    • データ構造を定義する
  • View
    • UIを定義する
    • ユーザ操作やライフサイクルイベントをPresenterへ伝える
  • Presenter
    • ビジネスロジックを定義する
    • Modelからデータを取得し、Viewが使いやすい形でデータを提供する
    • 表示するデータを保持する


※最後に書いた「UIに表示するデータはPresenterで保持する」という内容はしばらくの間きちんと意識できていなかった部分なので、自分のためにあえて強調しています。

TODOアプリの実装

ここからはTODOアプリが具体的にどのように実装されているかを見ていきます。

まず、TODOアプリのプロジェクト構成は以下の通りです。このプロジェクトは基本的に画面単位でパッケージ管理されています。
dataパッケージにはタスクを保存するためのリポジトリとインタフェースが格納されています。utilパッケージにはいわゆるユーティリティクラスが格納されています。

com.example.android.architecture.blueprints.todoapp
 ├ addedittask (追加画面、編集画面)
 ├ data
 ├ statistics  (統計画面)
 ├ taskdetail  (詳細画面)
 ├ tasks       (トップ画面)
 └ util


ここからは、例として詳細画面のソースコードを見ていきたいと思います。
taskdetailパッケージの中身は以下の通りです。

taskdetail
 ├ TaskDetailActivity
 ├ TaskDetailContract
 ├ TaskDetailFragment
 └ TaskDetailPresenter


この4つのクラスの役割と実装について、以下にまとめていきます。

Activityの役割と実装

まず、Activityの役割と実装を見ていきます。

TaskDetailActivityの役割は、FragmentとPresenterを生成することです。

/**
  * Activity
  */
class TaskDetailActivity : AppCompatActivity() {

    companion object {
        val EXTRA_TASK_ID = "TASK_ID"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.taskdetail_act)

        ...

        // タスクIDを受け取る
        val taskId = intent.getStringExtra(EXTRA_TASK_ID)

        // タスク詳細部分のUIを作成する
        var taskDetailFragment = supportFragmentManager.findFragmentById(R.id.contentFrame) as? TaskDetailFragment
        if (taskDetailFragment == null) {
            taskDetailFragment = TaskDetailFragment.newInstance(taskId)
            ActivityUtils.addFragmentToActivity(
                supportFragmentManager,
                taskDetailFragment, 
                R.id.contentFrame
            )
        }

        // Presenterを作成する
        TaskDetailPresenter(
                taskId,
                Injection.provideTasksRepository(applicationContext),
                taskDetailFragment!!
        )
    }

    ...

}


Contractの役割と実装

TaskDetailContractの役割は、ViewとPresenterを橋渡しすることです。
具体的には、TaskDetailContract.ViewTaskDetailContract.Presenterという2つのインタフェースによって、TaskDetailFragmentTaskDetailPresenterを関連付けます。

  • Contract.View
    • PresenterがViewを操作して表示状態を変更したい場合に利用する
  • Contract.Presenter
    • ViewがPresenterにユーザ操作やライフサイクルイベントを伝える場合に利用する


TaskDetailContract.ViewisActiveというプロパティがあります。これはView側の状態、すなわちライフサイクルをチェックするためのプロパティです。
isActiveを利用することによって、Presenterが適切なタイミングでViewの更新を行うことが可能になります。ライフサイクルのチェックを行わない場合、エッジケースでエラーが発生する原因となります。

/**
  * Contract
  */
interface TaskDetailContract {

    // Presenterが呼び出して使う
    interface View : BaseView<Presenter> {
        val isActive: Boolean

        fun setLoadingIndicator(active: Boolean)
        fun showMissingTask()

        fun hideTitle()
        fun showTitle(title: String)

        fun hideDescription()
        fun showDescription(description: String)

        fun showCompletionStatus(complete: Boolean)
        fun showEditTask(taskId: String)
        fun showTaskDeleted()
        fun showTaskMarkedComplete()
        fun showTaskMarkedActive()
    }

    // Viewが呼び出して使う
    interface Presenter : BasePresenter {
        fun editTask()
        fun deleteTask()
        fun completeTask()
        fun activateTask()
    }

}


Presenterの役割と実装

次に、Presenterの役割と実装を見ていきます。

TaskDetailPresenterの役割は、Modelからデータを取得し、Viewが使いやすい形でデータを提供することです。
前述した通り、Contractを経由してViewからユーザ操作やライフサイクルイベントを受け取り、処理を開始します。

class TaskDetailPresenter(
        private val mTaskId: String,
        private val mTasksRepository: TasksRepository,
        private val mTaskDetailView: TaskDetailContract.View
) : TaskDetailContract.Presenter {

    init {
        mTaskDetailView.setPresenter(this)
    }

    override fun start() {
        openTask()
    }

    private fun openTask() {
        if (mTaskId.isEmpty()) {
            mTaskDetailView.showMissingTask()
            return
        }

        mTaskDetailView.setLoadingIndicator(true)
        mTasksRepository.getTask(mTaskId, object : TasksDataSource.GetTaskCallback {
            override fun onTaskLoaded(task: Task) {
                with (mTaskDetailView) {
                // UIが利用できないタイミングの場合は表示を諦める
                    if (!isActive) {
                        return@onTaskLoaded
                    }
                    setLoadingIndicator(false)
                }
                showTask(task)
            }

            override fun onDataNotAvailable() {
                with (mTaskDetailView) {
                // UIが利用できないタイミングの場合は表示を諦める
                    if (!isActive) {
                        return@onDataNotAvailable
                    }
                    showMissingTask()
                }
            }
        })
    }

    override fun editTask() {
        with (mTaskDetailView) {
            if (mTaskId.isEmpty()) {
                showMissingTask()
                return
            }
            showEditTask(mTaskId)
        }
    }

    ...

    private fun showTask(task: Task) {
        val title = task.title
        val description = task.description

        with (mTaskDetailView) {
            if (title.isNullOrEmpty()) {
                hideTitle()
            } else {
                showTitle(title)
            }

            if (description.isNullOrEmpty()) {
                hideDescription()
            } else {
                showDescription(description)
            }
            showCompletionStatus(task.isCompleted)
        }
    }

}


Fragmentの役割と実装

最後に、Fragmentの役割と実装を見ていきます。

TaskDetailFragmentの役割は、UIの初期設定とユーザ操作やライフサイクルイベントをPresenterへ伝えることです。
MVVMの場合と同様、タスク一覧以外のFloatingActionButtonSnackbarに関する処理はFragmentに書かなくても問題はまったくないのですが、ユーザ操作やライフサイクルイベントをPresenterへ伝える役割をFragmentに集約するためにActivityではなくFragmentで宣言しています。

class TaskDetailFragment : Fragment(), TaskDetailContract.View {

    companion object {
        private val ARGUMENT_TASK_ID = "TASK_ID"
        private val REQUEST_EDIT_TASK = 1

        fun newInstance(taskId: String?): TaskDetailFragment {
            return TaskDetailFragment().apply {
                arguments = Bundle().apply {
                    putString(ARGUMENT_TASK_ID, taskId)
                }
            }
        }
    }

    private var mPresenter: TaskDetailContract.Presenter? = null

    override fun setPresenter(presenter: TaskDetailContract.Presenter) {
        mPresenter = presenter
    }

    override val isActive: Boolean
        get() = isAdded

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? {
        val root = inflater.inflate(R.layout.taskdetail_frag, container, false)

        // FloatingActionButtonの初期設定を行う
        activity?.findViewById<FloatingActionButton>(R.id.fab_edit_task)?.setOnClickListener {
            mPresenter?.editTask()
        }

        setHasOptionsMenu(true)
        return root
    }

    override fun onResume() {
        super.onResume()
        mPresenter?.start()
    }

    override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
        inflater?.inflate(R.menu.taskdetail_fragment_menu, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        when (item?.itemId) {
            R.id.menu_delete -> {
                mPresenter?.deleteTask()
                return true
            }
        }
        return false
    }

    ...

    override fun showEditTask(taskId: String) {
        val intent = Intent(context, AddEditTaskActivity::class.java).apply {
            putExtra(AddEditTaskFragment.ARGUMENT_EDIT_TASK_ID, taskId)
        }
        startActivityForResult(intent, REQUEST_EDIT_TASK)
    }

    ...

    override fun showTaskMarkedComplete() {
        view?.let {
            Snackbar.make(it, getString(R.string.task_marked_complete), Snackbar.LENGTH_LONG).show() 
        }
    }

    override fun showTaskMarkedActive() {
        view?.let {
            Snackbar.make(it, getString(R.string.task_marked_active), Snackbar.LENGTH_LONG).show()
        }
    }

    ...

}


ここまでの話をまとめると以下のようになります。

Android Architecture Blueprintsより引用


バリデーションチェックなどが多いアプリになるとView / Presenter間の往来が多くなり、責務がだんだん曖昧になってきそうだと感じました。
サンプルアプリでは編集画面へ遷移する際にView→Presenter→Viewと処理が往来していますが、気を抜くとView側で画面遷移の処理を直接呼びたくなってしまいそうです(もちろんそれで問題ないケースもありますが)。

実務でもMVPをベースとしたアーキテクチャを採用しているので、Viewの責務は表示Presenterの責務はビジネスロジックということを改めて意識ながら実装していきたいと思います。


MVPアーキテクチャに関する説明は以上です。


追記
この記事はtodo-mvpブランチにあるJavaソースコードを手元でKotlinに書き換えながら書いていたのですが、todo-mvp-kotlinというブランチにKotlinで書かれたソースコードがそもそもありました。笑
github.com


内容としては、Nullチェックが減っていたりユーティリティクラスが拡張関数になっている程度で、記事に記載したものとも大きな乖離はありませんでしたが、気になる方はこちらもご参照いただければ幸いです🙇‍♂️