0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-02

要旨

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

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

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

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

ComposeでPresenterとViewを実装する

コード例を以下に示す。

Presenter

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

@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(cashapp/molecule)とFlowをテストするフレームワーク(cashapp/turbine)を用いてテストできる。

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を用いて記事一覧の取得し表示する
  2. MVP(Model-View-Presenter)アーキテクチャ
  3. 依存性注入(DI)による関心の分離と疎結合(本記事では説明を省略する)
  4. Presenter部分におけるいくつかのユニットテスト

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

アーキテクチャ図

全体のソースコード

Model

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

domain/ArticleRepository.kt
interface ArticleRepository {

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

Presenter

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

ArticlesPresenterではUiStateCoroutineScoperememberRetained(rin/Rin)で状態保持している。

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

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

LocalViewModelStoreOwnerの復元に対応したナビゲーションフレームワークはandroidx.navigation.composeのほか、adrielcafe/voyagerTlaster/PreCompose(1.7.0-alpha02以降)がある。

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を定義する。 一部のみ掲載する。

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が参照するLocalLifecycleOwnerLocalViewModelStoreOwnerが空っぽなので値を入れてやる必要がある。

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

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にアップロードしたソースコードにて確認できる。

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

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

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.
0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?