From 4e808776cc3fee595c52466d1750b10e0a8b5c4b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 24 Jul 2023 22:38:16 +0200 Subject: [PATCH 1/4] Alter viewmodel-sample to show how to use a not always hot StateFlow Turn the StateFlow in MoleculeViewModel into one created using `stateIn` so that it can turn cold when there are no observers. --- gradle/libs.versions.toml | 1 + sample-viewmodel/build.gradle | 1 + .../molecule/viewmodel/MainActivity.kt | 4 +- .../molecule/viewmodel/MoleculePresenter.kt | 11 +++ .../molecule/viewmodel/MoleculeViewModel.kt | 42 ++++++++-- .../molecule/viewmodel/presentationLogic.kt | 83 +++++++++++-------- .../viewmodel/PupperPicsPresenterTest.kt | 38 +++++---- 7 files changed, 119 insertions(+), 61 deletions(-) create mode 100644 sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c625d70c..096bcac9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ android-plugin = { module = "com.android.tools.build:gradle", version = "8.0.2" androidx-core = { module = "androidx.core:core-ktx", version = "1.10.1" } androidx-test-runner = { module = "androidx.test:runner", version = "1.5.2" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.7.2" } +androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.6.1" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version = "2023.06.01" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } diff --git a/sample-viewmodel/build.gradle b/sample-viewmodel/build.gradle index f6661935..8613d2de 100644 --- a/sample-viewmodel/build.gradle +++ b/sample-viewmodel/build.gradle @@ -5,6 +5,7 @@ apply plugin: 'app.cash.molecule' dependencies { implementation platform(libs.androidx.compose.bom) implementation libs.androidx.activity.compose + implementation libs.androidx.lifecycle.compose implementation libs.androidx.compose.material3 implementation libs.coil.compose implementation libs.squareup.retrofit.client diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MainActivity.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MainActivity.kt index 8ea36f9f..b064ef1d 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MainActivity.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MainActivity.kt @@ -27,10 +27,10 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -39,7 +39,7 @@ class MainActivity : ComponentActivity() { val viewModel by viewModels() setContent { RootContainer { - val model by viewModel.models.collectAsState() + val model by viewModel.models.collectAsStateWithLifecycle() PupperPicsScreen(model) { event -> viewModel.take(event) } } } diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt new file mode 100644 index 00000000..1113306d --- /dev/null +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt @@ -0,0 +1,11 @@ +package com.example.molecule.viewmodel + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.Flow + +interface MoleculePresenter { + val seed: Model + + @Composable + fun present(events: Flow): Model +} diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt index 6214484d..ebe6891e 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt @@ -15,16 +15,20 @@ */ package com.example.molecule.viewmodel -import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.cash.molecule.RecompositionMode.ContextClock -import app.cash.molecule.launchMolecule +import app.cash.molecule.moleculeFlow +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn abstract class MoleculeViewModel : ViewModel() { private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) @@ -34,9 +38,16 @@ abstract class MoleculeViewModel : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 20) val models: StateFlow by lazy(LazyThreadSafetyMode.NONE) { - scope.launchMolecule(mode = ContextClock) { - models(events) - } + moleculeFlow(mode = ContextClock) { + val presenter = remember { presenterFactory() } + presenter.present(events) + }.onEach { + seed = it + }.stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = seed, + ) } fun take(event: Event) { @@ -45,6 +56,21 @@ abstract class MoleculeViewModel : ViewModel() { } } - @Composable - protected abstract fun models(events: Flow): Model + /** + * This value serves as the initial value that the uiState [StateFlow] will emit and then as a + * way to cache the last emission. + * When the flow goes from being cold (when in the backstack and it has no observers) to being + * hot again, by default the value cached using [stateIn] will be overwritten by the Presenter's + * first emission. By default the presenter at that point won't have any notion of what that + * cached value was without us providing this seed [Model]. + * It's the responsibility of the consumer to actually use this seed value when creating the + * Presenter inside the [presenterFactory]. + */ + abstract var seed: Model + + /** + * This will be remembered in the context of the moleculeFlow, so that it stays alive for as long + * as the [models] [StateFlow] is still hot (has observers or the timeout hasn't timed out yet). + */ + protected abstract fun presenterFactory(): MoleculePresenter } diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt index 611493b4..f15ac55a 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt @@ -31,51 +31,64 @@ sealed interface Event { data class Model( val loading: Boolean, val breeds: List, - val dropdownText: String, + val currentBreed: String?, val currentUrl: String?, -) +) { + val dropdownText: String = currentBreed ?: "Select breed" +} class PupperPicsViewModel : MoleculeViewModel() { - @Composable - override fun models(events: Flow): Model { - return PupperPicsPresenter(events, PupperPicsService()) + override var seed: Model = Model( + loading = false, + breeds = emptyList(), + currentBreed = null, + currentUrl = null, + ) + + override fun presenterFactory(): MoleculePresenter { + return PupperPicsPresenter(seed, PupperPicsService()) } } -@Composable -fun PupperPicsPresenter(events: Flow, service: PupperPicsService): Model { - var breeds: List by remember { mutableStateOf(emptyList()) } - var currentBreed: String? by remember { mutableStateOf(null) } - var currentUrl: String? by remember { mutableStateOf(null) } - var fetchId: Int by remember { mutableStateOf(0) } +class PupperPicsPresenter( + override val seed: Model, + private val service: PupperPicsService, +) : MoleculePresenter { + @Composable + override fun present(events: Flow): Model { + var breeds: List by remember { mutableStateOf(seed.breeds) } + var currentBreed: String? by remember { mutableStateOf(seed.currentBreed) } + var currentUrl: String? by remember { mutableStateOf(seed.currentUrl) } + var fetchId: Int by remember { mutableStateOf(0) } - // Grab the list of breeds and sets the current selection to the first in the list. - // Errors are ignored in this sample. - LaunchedEffect(Unit) { - breeds = service.listBreeds() - currentBreed = breeds.first() - } + // Grab the list of breeds and sets the current selection to the first in the list. + // Errors are ignored in this sample. + LaunchedEffect(Unit) { + breeds = service.listBreeds() + currentBreed = breeds.first() + } - // Load a random URL for the current breed whenever it changes, or the fetchId changes. - LaunchedEffect(currentBreed, fetchId) { - currentUrl = null - currentUrl = currentBreed?.let { service.randomImageUrlFor(it) } - } + // Load a random URL for the current breed whenever it changes, or the fetchId changes. + LaunchedEffect(currentBreed, fetchId) { + currentUrl = null + currentUrl = currentBreed?.let { service.randomImageUrlFor(it) } + } - // Handle UI events. - LaunchedEffect(Unit) { - events.collect { event -> - when (event) { - is Event.SelectBreed -> currentBreed = event.breed - Event.FetchAgain -> fetchId++ // Incrementing fetchId will load another random image URL. + // Handle UI events. + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is Event.SelectBreed -> currentBreed = event.breed + Event.FetchAgain -> fetchId++ // Incrementing fetchId will load another random image URL. + } } } - } - return Model( - loading = currentBreed == null, - breeds = breeds, - dropdownText = currentBreed ?: "Select breed", - currentUrl = currentUrl, - ) + return Model( + loading = currentBreed == null, + breeds = breeds, + currentBreed = currentBreed, + currentUrl = currentUrl, + ) + } } diff --git a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt index 91d6546b..7f7fe93e 100644 --- a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt +++ b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt @@ -30,19 +30,23 @@ import org.junit.Assert.assertEquals import org.junit.Test class PupperPicsPresenterTest { + + private val seed: Model = Model( + loading = true, + breeds = emptyList(), + currentBreed = null, + currentUrl = null, + ) + @Test fun `on launch, breeds are loaded followed by an image url`() = runBlocking { val picsService = FakePicsService() + val presenter = PupperPicsPresenter(seed, picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - PupperPicsPresenter(emptyFlow(), picsService) + presenter.present(emptyFlow()) }.distinctUntilChanged().test { assertEquals( - Model( - loading = true, - breeds = emptyList(), - dropdownText = "Select breed", - currentUrl = null, - ), + seed, awaitItem(), ) @@ -51,7 +55,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "akita", + currentBreed = "akita", currentUrl = null, ), awaitItem(), @@ -65,7 +69,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "akita", + currentBreed = "akita", currentUrl = "akita.jpg", ), awaitItem(), @@ -77,8 +81,9 @@ class PupperPicsPresenterTest { fun `selecting breed updates dropdown text and fetches new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() + val presenter = PupperPicsPresenter(seed, picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - PupperPicsPresenter(events.receiveAsFlow(), picsService) + presenter.present(events.receiveAsFlow()) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) picsService.urls.add("akita.jpg") @@ -92,7 +97,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "boxer", + currentBreed = "boxer", currentUrl = "akita.jpg", ), awaitItem(), @@ -101,7 +106,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "boxer", + currentBreed = "boxer", currentUrl = null, ), awaitItem(), @@ -114,7 +119,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "boxer", + currentBreed = "boxer", currentUrl = "boxer.jpg", ), awaitItem(), @@ -126,8 +131,9 @@ class PupperPicsPresenterTest { fun `fetching again requests a new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() + val presenter = PupperPicsPresenter(seed, picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - PupperPicsPresenter(events.receiveAsFlow(), picsService) + presenter.present(events.receiveAsFlow()) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") @@ -139,7 +145,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "akita", + currentBreed = "akita", currentUrl = null, ), awaitItem(), @@ -151,7 +157,7 @@ class PupperPicsPresenterTest { Model( loading = false, breeds = listOf("akita", "boxer", "corgi"), - dropdownText = "akita", + currentBreed = "akita", currentUrl = "akita2.jpg", ), awaitItem(), From e9dcfdac5d1d89edadbfb13f449f7ed6a8c6749a Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 25 Jul 2023 00:06:26 +0200 Subject: [PATCH 2/4] Potential fixes after PR feedback --- .../molecule/viewmodel/MoleculePresenter.kt | 4 +-- .../molecule/viewmodel/MoleculeViewModel.kt | 29 ++++++------------- .../molecule/viewmodel/presentationLogic.kt | 11 +++---- .../viewmodel/PupperPicsPresenterTest.kt | 12 ++++---- 4 files changed, 22 insertions(+), 34 deletions(-) diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt index 1113306d..63b107e4 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt @@ -4,8 +4,6 @@ import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.Flow interface MoleculePresenter { - val seed: Model - @Composable - fun present(events: Flow): Model + fun present(seed: Model, events: Flow): Model } diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt index ebe6891e..f168b6ae 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt @@ -15,7 +15,7 @@ */ package com.example.molecule.viewmodel -import androidx.compose.runtime.remember +import androidx.compose.runtime.Composable import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,6 +23,7 @@ import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.moleculeFlow import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -37,10 +38,13 @@ abstract class MoleculeViewModel : ViewModel() { // small enough to surface issues if they get backed up for some reason. private val events = MutableSharedFlow(extraBufferCapacity = 20) + abstract val initialState: Model + + private var seed: Model = initialState + val models: StateFlow by lazy(LazyThreadSafetyMode.NONE) { moleculeFlow(mode = ContextClock) { - val presenter = remember { presenterFactory() } - presenter.present(events) + models(seed, events) }.onEach { seed = it }.stateIn( @@ -56,21 +60,6 @@ abstract class MoleculeViewModel : ViewModel() { } } - /** - * This value serves as the initial value that the uiState [StateFlow] will emit and then as a - * way to cache the last emission. - * When the flow goes from being cold (when in the backstack and it has no observers) to being - * hot again, by default the value cached using [stateIn] will be overwritten by the Presenter's - * first emission. By default the presenter at that point won't have any notion of what that - * cached value was without us providing this seed [Model]. - * It's the responsibility of the consumer to actually use this seed value when creating the - * Presenter inside the [presenterFactory]. - */ - abstract var seed: Model - - /** - * This will be remembered in the context of the moleculeFlow, so that it stays alive for as long - * as the [models] [StateFlow] is still hot (has observers or the timeout hasn't timed out yet). - */ - protected abstract fun presenterFactory(): MoleculePresenter + @Composable + protected abstract fun models(seed: Model, events: Flow): Model } diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt index f15ac55a..d47ab50f 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt @@ -38,24 +38,25 @@ data class Model( } class PupperPicsViewModel : MoleculeViewModel() { - override var seed: Model = Model( + override val initialState: Model = Model( loading = false, breeds = emptyList(), currentBreed = null, currentUrl = null, ) - override fun presenterFactory(): MoleculePresenter { - return PupperPicsPresenter(seed, PupperPicsService()) + @Composable + override fun models(seed: Model, events: Flow): Model { + val presenter = remember { PupperPicsPresenter(PupperPicsService()) } + return presenter.present(seed, events) } } class PupperPicsPresenter( - override val seed: Model, private val service: PupperPicsService, ) : MoleculePresenter { @Composable - override fun present(events: Flow): Model { + override fun present(seed: Model, events: Flow): Model { var breeds: List by remember { mutableStateOf(seed.breeds) } var currentBreed: String? by remember { mutableStateOf(seed.currentBreed) } var currentUrl: String? by remember { mutableStateOf(seed.currentUrl) } diff --git a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt index 7f7fe93e..a77c8406 100644 --- a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt +++ b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt @@ -41,9 +41,9 @@ class PupperPicsPresenterTest { @Test fun `on launch, breeds are loaded followed by an image url`() = runBlocking { val picsService = FakePicsService() - val presenter = PupperPicsPresenter(seed, picsService) + val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(emptyFlow()) + presenter.present(seed, emptyFlow()) }.distinctUntilChanged().test { assertEquals( seed, @@ -81,9 +81,9 @@ class PupperPicsPresenterTest { fun `selecting breed updates dropdown text and fetches new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() - val presenter = PupperPicsPresenter(seed, picsService) + val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(events.receiveAsFlow()) + presenter.present(seed, events.receiveAsFlow()) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) picsService.urls.add("akita.jpg") @@ -131,9 +131,9 @@ class PupperPicsPresenterTest { fun `fetching again requests a new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() - val presenter = PupperPicsPresenter(seed, picsService) + val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(events.receiveAsFlow()) + presenter.present(seed, events.receiveAsFlow()) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita") From 1ec6561ca9a250ef7d32b2b2ce4e724b60331759 Mon Sep 17 00:00:00 2001 From: dahunsi Date: Tue, 25 Jul 2023 11:28:39 +0100 Subject: [PATCH 3/4] Add initialState and presenter Composable function to MoleculeViewModel constructor --- .../molecule/viewmodel/MoleculePresenter.kt | 9 -- .../molecule/viewmodel/MoleculeViewModel.kt | 15 ++- .../molecule/viewmodel/presentationLogic.kt | 92 ++++++++++--------- 3 files changed, 55 insertions(+), 61 deletions(-) delete mode 100644 sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt deleted file mode 100644 index 63b107e4..00000000 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculePresenter.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.molecule.viewmodel - -import androidx.compose.runtime.Composable -import kotlinx.coroutines.flow.Flow - -interface MoleculePresenter { - @Composable - fun present(seed: Model, events: Flow): Model -} diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt index f168b6ae..d91b64c3 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/MoleculeViewModel.kt @@ -31,25 +31,27 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -abstract class MoleculeViewModel : ViewModel() { +abstract class MoleculeViewModel( + initialState: Model, + started: SharingStarted = SharingStarted.WhileSubscribed(5.seconds), + presenter: @Composable (seed: Model, events: Flow) -> Model, +) : ViewModel() { private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) // Events have a capacity large enough to handle simultaneous UI events, but // small enough to surface issues if they get backed up for some reason. private val events = MutableSharedFlow(extraBufferCapacity = 20) - abstract val initialState: Model - private var seed: Model = initialState val models: StateFlow by lazy(LazyThreadSafetyMode.NONE) { moleculeFlow(mode = ContextClock) { - models(seed, events) + presenter(seed, events) }.onEach { seed = it }.stateIn( scope = scope, - started = SharingStarted.WhileSubscribed(5.seconds), + started = started, initialValue = seed, ) } @@ -59,7 +61,4 @@ abstract class MoleculeViewModel : ViewModel() { error("Event buffer overflow.") } } - - @Composable - protected abstract fun models(seed: Model, events: Flow): Model } diff --git a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt index d47ab50f..e5ed97b1 100644 --- a/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt +++ b/sample-viewmodel/src/main/java/com/example/molecule/viewmodel/presentationLogic.kt @@ -37,59 +37,63 @@ data class Model( val dropdownText: String = currentBreed ?: "Select breed" } -class PupperPicsViewModel : MoleculeViewModel() { - override val initialState: Model = Model( +class PupperPicsViewModel( + // This service would typically be injected + service: PupperPicsService = PupperPicsService(), +) : MoleculeViewModel( + initialState = Model( loading = false, breeds = emptyList(), currentBreed = null, currentUrl = null, - ) - - @Composable - override fun models(seed: Model, events: Flow): Model { - val presenter = remember { PupperPicsPresenter(PupperPicsService()) } - return presenter.present(seed, events) - } -} + ), + presenter = { seed, events -> + PupperPicsPresenter( + seed = seed, + events = events, + service = service, + ) + }, +) -class PupperPicsPresenter( - private val service: PupperPicsService, -) : MoleculePresenter { - @Composable - override fun present(seed: Model, events: Flow): Model { - var breeds: List by remember { mutableStateOf(seed.breeds) } - var currentBreed: String? by remember { mutableStateOf(seed.currentBreed) } - var currentUrl: String? by remember { mutableStateOf(seed.currentUrl) } - var fetchId: Int by remember { mutableStateOf(0) } +@Composable +fun PupperPicsPresenter( + seed: Model, + events: Flow, + service: PupperPicsService, +): Model { + var breeds: List by remember { mutableStateOf(seed.breeds) } + var currentBreed: String? by remember { mutableStateOf(seed.currentBreed) } + var currentUrl: String? by remember { mutableStateOf(seed.currentUrl) } + var fetchId: Int by remember { mutableStateOf(0) } - // Grab the list of breeds and sets the current selection to the first in the list. - // Errors are ignored in this sample. - LaunchedEffect(Unit) { - breeds = service.listBreeds() - currentBreed = breeds.first() - } + // Grab the list of breeds and sets the current selection to the first in the list. + // Errors are ignored in this sample. + LaunchedEffect(Unit) { + breeds = service.listBreeds() + currentBreed = breeds.first() + } - // Load a random URL for the current breed whenever it changes, or the fetchId changes. - LaunchedEffect(currentBreed, fetchId) { - currentUrl = null - currentUrl = currentBreed?.let { service.randomImageUrlFor(it) } - } + // Load a random URL for the current breed whenever it changes, or the fetchId changes. + LaunchedEffect(currentBreed, fetchId) { + currentUrl = null + currentUrl = currentBreed?.let { service.randomImageUrlFor(it) } + } - // Handle UI events. - LaunchedEffect(Unit) { - events.collect { event -> - when (event) { - is Event.SelectBreed -> currentBreed = event.breed - Event.FetchAgain -> fetchId++ // Incrementing fetchId will load another random image URL. - } + // Handle UI events. + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is Event.SelectBreed -> currentBreed = event.breed + Event.FetchAgain -> fetchId++ // Incrementing fetchId will load another random image URL. } } - - return Model( - loading = currentBreed == null, - breeds = breeds, - currentBreed = currentBreed, - currentUrl = currentUrl, - ) } + + return Model( + loading = currentBreed == null, + breeds = breeds, + currentBreed = currentBreed, + currentUrl = currentUrl, + ) } From 19bd668265608110cb1cce757499e0f25da8805e Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 25 Jul 2023 13:12:15 +0200 Subject: [PATCH 4/4] Fix tests --- .../molecule/viewmodel/PupperPicsPresenterTest.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt index a77c8406..ad15e9e3 100644 --- a/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt +++ b/sample-viewmodel/src/test/java/com/example/molecule/viewmodel/PupperPicsPresenterTest.kt @@ -41,9 +41,8 @@ class PupperPicsPresenterTest { @Test fun `on launch, breeds are loaded followed by an image url`() = runBlocking { val picsService = FakePicsService() - val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(seed, emptyFlow()) + PupperPicsPresenter(seed, emptyFlow(), picsService) }.distinctUntilChanged().test { assertEquals( seed, @@ -81,9 +80,8 @@ class PupperPicsPresenterTest { fun `selecting breed updates dropdown text and fetches new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() - val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(seed, events.receiveAsFlow()) + PupperPicsPresenter(seed, events.receiveAsFlow(), picsService) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) picsService.urls.add("akita.jpg") @@ -131,9 +129,8 @@ class PupperPicsPresenterTest { fun `fetching again requests a new image`() = runBlocking { val picsService = FakePicsService() val events = Channel() - val presenter = PupperPicsPresenter(picsService) moleculeFlow(mode = RecompositionMode.Immediate) { - presenter.present(seed, events.receiveAsFlow()) + PupperPicsPresenter(seed, events.receiveAsFlow(), picsService) }.distinctUntilChanged().test { picsService.breeds.add(listOf("akita", "boxer", "corgi")) assertThat(picsService.urlRequestArgs.awaitItem()).isEqualTo("akita")