皆さん、こんにちは。
今回、初めて Qiita で記事を書きます。
目的
これまでに触れる機会のなかったアーキテクチャパターン「MVPパターン」を学ぶために、単純なカウンターアプリを MVP パターンで実装します。
背景
2023 年に転職活動をしている中で、MVP パターンと RxJava という組み合わせで開発しているプロジェクトをよく見かけました。
新規開発プロジェクトではこの技術の組み合わせで開発することはないと思いますが、昔から継続的に開発しているプロジェクトではこの組み合わせを使っているところもあると思います。
自身の好奇心、そして MVP パターンを採用しているプロジェクトに参画する日も考えて、MVP パターンの基本的なことを学んでおきたいと思いました。
MVP パターンとは?
MVP パターンでは、それぞれ以下の責務を担っています。
- Model
- アプリケーション内のデータを管理する
- View:
- UI を描写する
- ユーザーのアクションに応じて Presenter を呼ぶ
- Presenter:
- Model からのデータを受け取る
- Model から受け取ったデータを基にして View を更新する
- ビジネスロジックを実行すること
Presenter と View は双方向の依存関係で繋がっています。
Presenter は Model から取得したデータを基にして View を更新する一方で、View はユーザーのアクション(例: ボタンの押下)に応じて Presenter を呼ぶからです。
今回のカウンターアプリ例では、前者は model で管理しているカウント値を基にして View を更新する処理、後者はカウント値の増減するボタンが押下された場合の処理が当てはまります。
その一方で、Presenter と Model は単方向の依存関係となっています。
そのため、Model は Presenter の存在を気にする必要がないパターンとなっています。
Contract(契約)の存在
MVP パターンでは、それぞれの役割の間を結ぶインターフェースを Contract
または契約と呼ぶようです(※)。
今回のカウンターアプリでは、それぞれの役割が担う契約は以下のようになります。
- View が保持する契約(Presenter が呼ぶ)
- カウント値を表示する
- Presenter が保持する契約(View が呼ぶ)
- カウント値の描写を更新する
- カウント値を増やす
- カウント値を減らす
- Model が保持する契約(Presenter が呼ぶ)
- カウント値を取得する
- カウント値を増やす
- カウント値を減らす
今回のサンプルアプリでも、この契約を明示するためにも interface
を使って実装しています。
※「Android アプリ設計パターン入門 第3章 MVP パターンを使ったアプリ構成」では、Presenter と View 間のインターフェースのみを Contract
と表現しています。
カウンターアプリの実装例
仕様
- アプリ中央にカウントされた値が表示される
- カウント初期値は 0 である
- 「INCREMENT」ボタンを押下することで、カウント値が 1 増える
- 「DECREMENT」ボタンを押下することで、カウント値が 1 減る
- カウント値は負数にならない
ディレクトリ構造
├── MainApplication.kt
├── data
│ ├── CounterRepository.kt
│ └── CounterRepositoryImpl.kt
├── di
│ └── AppModules.kt
├── model
│ └── Counter.kt
├── presenter
│ ├── CounterPresenter.kt
│ └── CounterPresenterImpl.kt
└── ui
├── CounterView.kt
└── MainActivity.kt
使用ライブラリ
今回のプロジェクトでは、依存注入に Hilt を使用しています。
今回の趣旨とは異なるため、Hilt の話はしません。実装方法は、こちらの公式ドキュメントを参照してください。
dependencies {
....
implementation "com.google.dagger:hilt-android:2.44.2"
kapt "com.google.dagger:hilt-compiler:2.44"
}
View
インターフェース
View に依存する Presenter が使用する振る舞いを定義します。
Presenter は Model から受け取ったデータを基にして View を更新する責務を担います。
interface CounterView {
fun showCount(count: Int)
}
Activity
上記で定義した CounterView
を継承した MainActivity を定義していきます。
updateCountView
で初期のカウント値を描写します。
showCount
メソッドは、引数として渡されたカウント値を基にして View の表示を更新します。
INCREMENT
, DECREMENT
ボタン押下時のイベントを購読し、ボタン押下された際には Presenter で定義している以下の 2 つのメソッドを呼びます。
onIncrementButtonClicked
onDecrementButtonClicked
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), CounterView {
private lateinit var binding: ActivityMainBinding
@Inject
lateinit var presenter: CounterPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
presenter.updateCounterView()
binding.incrementButton.setOnClickListener {
println("Increment button clicked")
presenter.onIncrementButtonClicked()
}
binding.decrementButton.setOnClickListener {
println("Decrement button clicked")
presenter.onDecrementButtonClicked()
}
}
override fun showCount(count: Int) {
binding.counterValue.text = count.toString()
}
}
Presenter
インターフェース
Presenter に依存する View が使用する振る舞いを定義します。
interface CounterPresenter {
fun updateCounterView()
fun onIncrementButtonClicked()
fun onDecrementButtonClicked()
}
詳細な実装
それぞれの実装で、Model で管理しているデータの参照と変更処理を実行するとともに、取得したデータを基にして View を更新する処理を実行します。
class CounterPresenterImpl @Inject constructor(
private val view: CounterView,
private val repository: CounterRepository
) : CounterPresenter {
override fun updateCounterView() {
val currentCount = repository.getCounter().count
view.showCount(currentCount)
}
override fun onIncrementButtonClicked() {
repository.incrementCounter()
updateCounterView()
}
override fun onDecrementButtonClicked() {
repository.decrementCounter()
updateCounterView()
}
}
Model
インターフェース
Repository に依存する Presenter が使用する振る舞いを定義します。
interface CounterRepository {
fun getCounter(): Counter
fun incrementCounter()
fun decrementCounter()
}
詳細な実装
今回はサンプル例なので、カウント値はインメモリで管理するように実装しました。
data class Counter(val count: Int) {
companion object {
const val INITIAL_VALUE = 0
}
}
class CounterRepositoryImpl @Inject constructor() : CounterRepository {
private var counter = Counter(Counter.INITIAL_VALUE)
override fun getCounter(): Counter {
return counter
}
override fun incrementCounter() {
val currentCount = counter.count
counter = Counter(currentCount + 1)
}
override fun decrementCounter() {
val currentCount = counter.count
if (currentCount <= 0) {
return
}
counter = Counter(currentCount - 1)
}
}
実装中に詰まったところ
ビルド時に以下のエラーが発生しました。
error: [Dagger/MissingBinding] com.example.counterappwithmvppattern.ui.MainActivity cannot be provided without an @Inject constructor or an @Provides-annotated method.This type supports members injection but cannot be implicitly provided.
Presenter
を依存注入した MainActivity
でエラーが発生しているようです。
解決方法として、こちらを参考に実装しました。このコードの追加することで、Dagger Hilt が MainActivity を提供できるようになりました。
@Module
@InstallIn(ActivityComponent::class)
object MainActivityModule {
@Provides
fun bindActivity(activity: Activity): MainActivity {
return activity as MainActivity
}
}
サンプルコード