MVP(Model-View-Presenter)をComposeでやってみる

## 要旨

ComposeアプリにおけるアーキテクチャはMVVM(Model-View-ViewModel)がよく用いられるが、今回はMVP(Model-View-Presenter)を用いてアプリを作成していく。

## MVP(Model-View-Presenter)について一言で

Presenterはビジネスロジック（Model）とUi（View）の仲介役である。

```plantuml
package "Model" {
}

package "Presenter" {
}

package "View" {
}

[Presenter] <.. [Model] : Update
[Presenter] --> [Model] : Fetch
[Presenter] <-- [View] : Emit\nUiEvent
[Presenter] ..> [View] : Update\nUiState
```

今年のDroidKaigiアプリはMVPアーキテクチャで作成された。

https://github.com/DroidKaigi/conference-app-2024

## ComposeでPresenterとViewを実装する

コード例を以下に示す。

### Presenter

```kotlin
sealed interface UiEvent {
    data object Refresh : UiEvent
}

sealed interface UiState {
    data object Nothing : UiEvent
    data object Success : UiEvent
}

@Composable
fun presenter(eventFlow: Flow<UiEvent>): UiState {
    var uiState by remember { mutableStateOf<UiEvent>(UiState.Nothing) }
        
    // イベントを収集する
    LaunchedEffect(eventFlow) {
        eventFlow.collect { event: UiEvent ->
            launch {
                when (event) {
                    UiEvent.Refresh -> { uiState = UiState.Success }
                    else -> {}
                }
            }
        }
    }
    return uiState
}
```

### View

```kotlin
@Composable
fun View() {
    val eventFlow = remember { MutableSharedFlow<UiEvent>() }
    val uiState: UiState = presenter(eventFlow)

    // ボタンを押したときにイベントを発火させる
    Button(onClick = { eventFlow.tryEmit(UiEvent.Refresh) }) {
        Text("Emit event")
    }
    Text("UiState: $uiState")
}
```

### Presenterのユニットテスト

返り値を持つ```@Composable```をFlow化する[```moleculeFlow```](https://cashapp.github.io/molecule/docs/latest/molecule-runtime/app.cash.molecule/molecule-flow.html)([cashapp/molecule](https://github.com/cashapp/molecule))とFlowをテストするフレームワーク([cashapp/turbine](https://github.com/cashapp/turbine))を用いてテストできる。

https://github.com/cashapp/molecule

https://github.com/cashapp/turbine

```kotlin
val eventFlow = MutableSharedFlow<UiEvent>()

@Test
fun fetchAll() = runTest {
    moleculeFlow(RecompositionMode.Immediate) {
        presenter(eventFlow)
    }.test {
        assertTrue { awaitItem() is UiState.Nothing }
        eventFlow.tryEmit(UiEvent.Refresh)
        assertTrue { awaitItem() is UiState.Success }
    }
}
```

## 前置き

以下を満たすアプリを実験として作成していく。

1. [Qiita API](https://qiita.com/api/v2/docs)を用いて記事一覧の取得し表示する
2. MVP(Model-View-Presenter)アーキテクチャ
3. 依存性注入（DI）による関心の分離と疎結合（本記事では説明を省略する）
4. Presenter部分におけるいくつかのユニットテスト

なおこの記事では各種ライブラリに関する説明はある程度省略する。

https://qiita.com/api/v2/docs

### アーキテクチャ図

```plantuml
package "Model" {
[ArticleRepository]
}

cloud "API" {
[Qiita]
}

[Qiita] <-- [ArticleRepository] : Fetch
[Qiita] ..> [ArticleRepository] : Update

package "Presenter" {
[ArticlePresenter]
}

package "View" {
[MainScreen\nor\nSubScreen]
}

[ArticlePresenter] <.. [ArticleRepository] : Update
[ArticlePresenter] --> [ArticleRepository] : Fetch
[ArticlePresenter] <-- [MainScreen\nor\nSubScreen] : Emit\nUiEvent
[ArticlePresenter] ..> [MainScreen\nor\nSubScreen] : Update\nUiState
```

### 全体のソースコード

https://github.com/oikvpqya/compose-mvp-architecture

## Model

Qiita APIと直接やり取りし記事一覧を取得する```ArticleRepository```を定義する。

```kotlin:domain/ArticleRepository.kt
interface ArticleRepository {

    suspend fun fetchAll() : List<Article>
    suspend fun fetchByTag(tag: String) : List<Article>
}
```

## Presenter

記事一覧を取得する```ArticleRepository```や画面Ui(View)とやり取りする```ArticlesPresenter```を定義する。

```ArticlesPresenter```では```UiState```と```CoroutineScope```を```rememberRetained```([rin/Rin](https://github.com/takahirom/Rin))で状態保持している。

```rememberRetained```は```androidx.lifecycle.ViewModel```(MVVMにおけるViewModelではなくComposeの```LocalViewModelStoreOwner```にて状態を保持してくれるクラス)の上に作られており、Uiの再構成時に状態保持され、またナビゲーションフレームワークの画面のポップによって```LocalViewModelStoreOwner```が復元された場合に状態が復元される。

```rememberRetained```の詳しい説明は該当GitHubのREADMEにて確認できる。

https://github.com/takahirom/Rin

```LocalViewModelStoreOwner```の復元に対応したナビゲーションフレームワークは[androidx.navigation.compose](https://developer.android.com/develop/ui/compose/navigation)のほか、[adrielcafe/voyager](https://github.com/adrielcafe/voyager)や[Tlaster/PreCompose](https://github.com/Tlaster/PreCompose)(1.7.0-alpha02以降)がある。

https://developer.android.com/develop/ui/compose/navigation

https://github.com/adrielcafe/voyager

https://github.com/Tlaster/PreCompose

```kotlin:ui/ArticlesPresenter.kt
sealed interface ArticlesUiState {

    data object Nothing : ArticlesUiState

    data object Loading : ArticlesUiState

    data class Success(
        val articles: List<Article>
    ) : ArticlesUiState
}

sealed interface ArticlesUiEvent {

    data object FetchAll : ArticlesUiEvent

    data class FetchByTag(
        val tag: String,
    ) : ArticlesUiEvent
}

class ArticlesPresenter(
    private val repository: ArticleRepository,
) {

    private val eventFlow = MutableSharedFlow<ArticlesUiEvent>(extraBufferCapacity = 20)

    @Composable
    fun presenter(): ArticlesUiState {
        val coroutineScope = rememberRetained {
            object : RetainedObserver {
                val coroutineScope = CoroutineScope(Dispatchers.Default)

                override fun onForgotten() {
                    coroutineScope.cancel()
                }

                override fun onRemembered() = Unit
            }
        }.coroutineScope

        var uiState by rememberRetained { mutableStateOf<ArticlesUiState>(ArticlesUiState.Nothing) }

        fun eventSink(event: ArticlesUiEvent) {
            when (event) {
                ArticlesUiEvent.FetchAll -> {
                    coroutineScope.launch {
                        uiState = ArticlesUiState.Loading
                        uiState = ArticlesUiState.Success(repository.fetchAll())
                    }
                }

                is ArticlesUiEvent.FetchByTag -> {
                    coroutineScope.launch {
                        uiState = ArticlesUiState.Loading
                        uiState = ArticlesUiState.Success(repository.fetchByTag(event.tag))
                    }
                }
            }
        }

        LaunchedEffect(eventFlow) {
            eventFlow.collect { event ->
                launch {
                    eventSink(event)
                }
            }
        }

        return uiState
    }

    fun produceEvent(event: ArticlesUiEvent) {
        eventFlow.tryEmit(event)
    }
}
```

### View

```ArticlesPresenter```とやり取りする```MainScreen```を定義する。 一部のみ掲載する。

```kotlin:ui/MainScreen.kt
val presenter: ArticlesPresenter

@Composable
fun MainScreen() {
    val uiState = presenter.presenter()

    /* 画面 */

    // 描画時にイベントを発火させる
    LaunchedEffect(Unit) {
        when (uiState) {
            ArticlesUiState.Nothing -> {
                presenter.produceEvent(ArticlesUiEvent.FetchAll)
            }
            ArticlesUiState.Loading, is ArticlesUiState.Success -> Unit
        }
    }
}
```
## その他

### rememberRetainedのユニットテスト

テスト環境では```rememberRetained```が参照する```LocalLifecycleOwner```と```LocalViewModelStoreOwner```が空っぽなので値を入れてやる必要がある。

詳細は省略するが以下によりテストできる。

```kotlin
interface LifecycleAndViewModelStoreOwner : LifecycleOwner, ViewModelStoreOwner

fun createLifecycleAndViewModelStoreOwner(
    initialState: Lifecycle.State = Lifecycle.State.STARTED,
): LifecycleAndViewModelStoreOwner = object : LifecycleAndViewModelStoreOwner {

    override val lifecycle: LifecycleRegistry
        get() = LifecycleRegistry.createUnsafe(this).apply {
            currentState = initialState
        }
    override val viewModelStore: ViewModelStore
        get() = ViewModelStore()
}

@OptIn(InternalComposeApi::class)
@Composable
fun <T> returningCompositionLocalProvider(
    vararg values: ProvidedValue<*>,
    content: @Composable () -> T,
): T {
    currentComposer.startProviders(values)
    val result = content()
    currentComposer.endProviders()
    return result
}

@Composable
fun <T> LifecycleAndViewModelStoreOwner.returningCompositionLocalProvider(
    content: @Composable () -> T,
): T {
    return returningCompositionLocalProvider(
        LocalLifecycleOwner provides this,
        LocalViewModelStoreOwner provides this,
        content = content,
    )
}

class RememberRetainedTest {

    @Test
    fun rememberRetained() = runTest {
        val owner = createLifecycleAndViewModelStoreOwner()
        moleculeFlow(RecompositionMode.Immediate)  {
            owner.returningCompositionLocalProvider {
                rememberRetained {
                    object : RetainedObserver {
                        val scope = CoroutineScope(Dispatchers.Default)
                        val state = mutableIntStateOf(0)
                        override fun onForgotten() {
                            scope.cancel()
                        }

                        override fun onRemembered() {
                            scope.launch {
                                delay(100.milliseconds)
                                state.value = 1
                            }
                        }
                    }
                }.state.value
            }
        }.test {
            assertEquals(0, awaitItem())
            assertEquals(1, awaitItem())
        }
    }
}
```

## 総括

MVPアーキテクチャに則ったアプリを作成した。MVPのView-PresenterはMVVMのごちゃごちゃしやすいView-ViewModelよりも疎結合な関係に作成でき、Presenterのユニットテストをしやすかった。

```@Composable```でPresenterを書くうえで```rememberRetained```による状態保持の恩恵が非常に大きく```rememberSaveable```よりも扱いやすかった。

ただしわざわざMVPにしなくともandroidx.lifecycleによるサポートを受けられる従来のMVVMでよいと言われればそれまでである。

記載を省いたコードについてはGitHubにアップロードしたソースコードにて確認できる。

https://github.com/oikvpqya/compose-mvp-architecture

本記事にて省略したDIフレームワークの[evant/kotlin-inject](https://github.com/evant/kotlin-inject)については以下の恥筆ながら自筆の過去記事にて確認できる。

https://qiita.com/yuya2011/items/c3baea9a2fc4a6fce970

各種ライブラリの詳細をかなり省略してしまい、ある程度理解している前提の殴り書きの記事となってしまいましたが、なにかあれば気軽に記事にコメントください。

## License
```
Copyright 2024 oikvpqya Yuya
 
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```