要旨
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 }
}
}
前置き
以下を満たすアプリを実験として作成していく。
- Qiita APIを用いて記事一覧の取得し表示する
- MVP(Model-View-Presenter)アーキテクチャ
- 依存性注入(DI)による関心の分離と疎結合(本記事では説明を省略する)
- Presenter部分におけるいくつかのユニットテスト
なおこの記事では各種ライブラリに関する説明はある程度省略する。
アーキテクチャ図
全体のソースコード
Model
Qiita APIと直接やり取りし記事一覧を取得するArticleRepository
を定義する。
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)で状態保持している。
rememberRetained
はandroidx.lifecycle.ViewModel
(MVVMにおけるViewModelではなくComposeのLocalViewModelStoreOwner
にて状態を保持してくれるクラス)の上に作られており、Uiの再構成時に状態保持され、またナビゲーションフレームワークの画面のポップによってLocalViewModelStoreOwner
が復元された場合に状態が復元される。
rememberRetained
の詳しい説明は該当GitHubのREADMEにて確認できる。
LocalViewModelStoreOwner
の復元に対応したナビゲーションフレームワークはandroidx.navigation.composeのほか、adrielcafe/voyagerやTlaster/PreCompose(1.7.0-alpha02以降)がある。
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
を定義する。 一部のみ掲載する。
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
が空っぽなので値を入れてやる必要がある。
詳細は省略するが以下によりテストできる。
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.