モバイルアプリ開発の最前線

Android/iPhoneアプリ開発に関する海外のブログ記事を翻訳しています。

【翻訳】AndroidのためのMVP:プレゼンテーション層を整理する

f:id:ayb:20180712130821j:plain

本記事は許可を得て以下の記事を翻訳したものです。

MVP for Android: how to organize the presentation layer


MVP(Model View Presenter)パターンは、有名なMVC(Model View Controller)から派生したもので、Androidアプリケーションにおけるプレゼンテーション層を整理するために最も人気のあるパターンの一つだ。

この記事は2014年4月に公開して、それからずっとよく読まれ続けている。そのため、この記事を更新して疑問点の多くを払拭し、コードをKotlinに変更した。

記事公開当時からアーキテクチャのパターンには大きな変化があり、例えばアーキテクチャコンポーネントを用いたMVVMが出現した。しかしながら、MVPは現在も効果的で、考慮すべき選択肢の一つだ。

MVPとは何か?

MVPパターンは、ロジックからプレゼンテーション層を分離する。MVPパターンは、理想的には、完全に異なり、入れ替えることのできるビューに対して、同一のロジックでそれを実現する。

最初に明らかにしておくべきことは、MVPはそれ自体はアーキテクチャではなく、プレゼンテーション層にのみ責任をもつ。これは議論の余地があるため、詳しく説明しておきたい。

あなたの開発しているアプリのアーキテクチャの一部となることから、MVPをアーキテクチャのパターンとして定義されているのを見るかもしれない。だが、MVPを用いているからといってアーキテクチャが完璧であると考えてはならない。MVPはプレゼンテーション層のみを形成するため、フレキシブルでスケールするアプリを作りたいなら、残りの層については依然として良いアーキテクチャが求められる。

完全なアーキテクチャの例としては、例えばクリーンアーキテクチャがある。

fernandocejas.com

MVPを何故使うのか?

Androidでは、ActivityがUIとデータアクセスのメカニズムの双方と密接に関連しているという事実から問題が生じる。極端な例としてCursorAdapterが挙げられる。これはビューの一部であると同時にデータアクセス層の奥深くに追いやるべきカーソルとのアダプターを提供する。

容易に拡張でき、メンテナンスしやすいアプリケーションを作るために、層を分割することが必要だ。データベースからデータを取得する代わりに、Webサービスから取得する必要が出てきたら?ビューの全体についてやり直す必要が出てきてしまう。

MVPはデータソースからビューを独立させる。アプリケーションを少なくとも3個の異なる層に分割し、それらを個々にテストできるようにする。MVPを用いることで、Activityからロジックのほとんどを取り出し、Instrumentationテストを用いずともテストできるようになる。

AndroidにおけるMVP

MVPには様々なバリエーションがあり、誰でも必要に応じて、より快適なやり方でパターンを調整することができる。基本的には、プレゼンターに委譲する責務によって異なってくる。

プログレスバーを有効化したり無効化したりすることについて、ビューが責務を持つだろうか、それともプレゼンターが責務を持つだろうか?アクションバーにどのアクションを表示するべきかを決めるのは誰だろうか?これらの質問は、難しい決定の始まりだ。私の場合の例を提示するが、標準的な実装方法というものは存在しないため、この記事はMVPをどのように適用すべきかについての厳密なガイドラインではなく議論の場としたい。

ログイン画面とメイン画面を持つとても簡単なサンプルを実装して、GitHubにリポジトリを公開しているので確認して欲しい。簡単のために本記事のコードはKotlinで書いているが、リポジトリ上はJava 8のコードも確認できる。

github.com

モデル

完全に階層化されたアーキテクチャを持つアプリケーションでは、モデルはドメイン層またはビジネスロジックへの入り口となる。アンクルボブのクリーンアーキテクチャを用いている場合、モデルは多分ユースケースのInteracorだ。しかし、本記事の目的に照らせば、ビューに表示したいデータの源と考えれば十分だ。

コードを見れば、サーバーへのリクエストをシミュレートする人工的な遅れを伴う、2個のモックInteractorを作っていることに気付くだろう。そのInteractorのうち一つは、以下のような構造になっている。

class LoginInteractor {

    // 中略

    fun login(username: String, password: String, listener: OnLoginFinishedListener) {
        // Mock login. I'm creating a handler to delay the answer a couple of seconds
        postDelayed(2000) {
            when {
                username.isEmpty() -> listener.onUsernameError()
                password.isEmpty() -> listener.onPasswordError()
                else -> listener.onSuccess()
            }
        }
    }
}

ユーザー名とパスワードを受け取ってバリデーションを行う、シンプルな関数だ。

ビュー

ビューは、通常はActivityによって実装されるが(Fragmentの場合もある。アプリの構造による)、プレゼンターへの参照を保持する。プレゼンターは、理想的にはDaggerなどによる依存性注入(DI)によって提供される。DIを行わない場合、ビューはプレゼンターのオブジェクトの生成に責務を持つ。ビューがするべきただ一つのことは、ユーザーアクションがあるたびにプレゼンターのメソッドを呼ぶことだ(例えばボタンのクリック)。

プレゼンターはビューについて何も知るべきではないため、実装すべきインターフェースを用いる。サンプルでは以下のようにインターフェースを定義している。

interface LoginView {
    fun showProgress()
    fun hideProgress()
    fun setUsernameError()
    fun setPasswordError()
    fun navigateToHome()
}

これはいくつかのユーティリティーメソッドを持つ。プログレスの非表示・非表示、エラーの表示、次の画面へのナビゲーション...。既に述べたように、これを実現する方法は様々あるが、ここでは最もシンプルな方法を示す。

次に、Activityではこれらのメソッドを実装する。理解のためにそのうちいくつかを示す。

class LoginActivity : AppCompatActivity(), LoginView {

    // 中略

    override fun showProgress() {
        progress.visibility = View.VISIBLE
    }
 
    override fun hideProgress() {
        progress.visibility = View.GONE
    }
 
    override fun setUsernameError() {
        username.error = getString(R.string.username_error)
    }
}

ビューはプレゼンターを用いてユーザーからの入力を通知すると言ったことを覚えているかもしれない。これは以下のように行う。

class LoginActivity : AppCompatActivity(), LoginView {
 
    private val presenter = LoginPresenter(this, LoginInteractor())
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
 
        button.setOnClickListener { validateCredentials() }
    }
 
    private fun validateCredentials() {
        presenter.validateCredentials(username.text.toString(), password.text.toString())
    }
 
    override fun onDestroy() {
        presenter.onDestroy()
        super.onDestroy()
    }

    // 略
}

プレゼンターは、Activityのプロパティとして定義されており、ボタンがクリックされた時、validateCredentials()を呼んで、プレゼンターに通知している。

onDestroy()でも処理を行っている。これについては後述する。

プレゼンター

プレゼンターは、ビューとモデルとの仲立ちをすることに責務を持つ。モデルからデータを取り出し、フォーマットしてビューに渡す。

また、典型的なMVCとは異なり、ビューからの入力があった時にどうするのかを決定する。そのため、ユーザーが行う可能性のあるそれぞれの動作に対してメソッドを持つ。以下にその実装を示す。

class LoginPresenter(var loginView: LoginView?, val loginInteractor: LoginInteractor) :
    LoginInteractor.OnLoginFinishedListener {
 
    fun validateCredentials(username: String, password: String) {
        loginView?.showProgress()
        loginInteractor.login(username, password, this)
    }

    // 略
}

MVPにはいくつかのリスクがあり、そのうち最も重要なものは、プレゼンターはビューに対して付属しているということを忘れてしまいがちであるということだ。ビューはActivityであるため、以下を意味する。

  • 長い時間にわたるタスクではActivityがリークする可能性がある
  • 既に死んでいるActivityを更新しようとしてしまう可能性がある

1点目について、バックグラウンドで動かしているタスクが合理的な時間内に終了することを保証するべきだ。Activityが5〜10秒間リークすることは、アプリの状態をさほど悪くはしないし、これに対する解決策は難しいものになる。

2点目についてはもっと厄介だ。サーバーへのリクエストの送信に10秒かかり、ユーザーは5秒後にActivityを閉じたとしよう。コールバックが呼ばれる時、UIを更新しようとするが、Activityは既に終了しているためクラッシュする

この問題を解決するために、onDestroy()を呼んでビューを綺麗にする。

fun onDestroy() {
    loginView = null
}

まとめ

Androidにおいてロジックからインターフェースを分離するのは簡単なことではないが、MVPパターンはActivityが数百行または数千行にわたる密に結合したクラスになるのを防いでくれる。大規模なアプリでは、コードを綺麗に保つことが非常に重要だ。そうでなければ、メンテナンスと拡張は不可能になる。

今日、MVVMなど他の手段もあるが、それらとの比較と統合については、また新たな記事を書きたい。

リポジトリはここにあり、KotlinとJavaの両方でコードを確認することができる。

Kotlinについてもっと学びたい場合、サンプルアプリを確認して欲しい。これは私の書いているAndroid開発者のためのKotlinの一部だ。Kotlinについてのオンラインコースもあるので確認して欲しい。