Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alter viewmodel-sample to show how to use a not always hot StateFlow #274

Open
wants to merge 6 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions sample-viewmodel/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -39,7 +39,7 @@ class MainActivity : ComponentActivity() {
val viewModel by viewModels<PupperPicsViewModel>()
setContent {
RootContainer {
val model by viewModel.models.collectAsState()
val model by viewModel.models.collectAsStateWithLifecycle()
PupperPicsScreen(model) { event -> viewModel.take(event) }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.molecule.viewmodel

import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow

interface MoleculePresenter<Event, Model> {
val seed: Model

@Composable
fun present(events: Flow<Event>): Model
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event, Model> : ViewModel() {
private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
Expand All @@ -34,9 +38,16 @@ abstract class MoleculeViewModel<Event, Model> : ViewModel() {
private val events = MutableSharedFlow<Event>(extraBufferCapacity = 20)

val models: StateFlow<Model> 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need for this initialValue does make the case that having a started parameter on launchMolecule is maybe not the worst idea in the world. Hooking it up with the existing API surface is uhh waves hands possible, for a certain definition of possible, but not one useful to someone who wants to do what MoleculeViewModel is doing

)
}

fun take(event: Event) {
Expand All @@ -45,6 +56,21 @@ abstract class MoleculeViewModel<Event, Model> : ViewModel() {
}
}

@Composable
protected abstract fun models(events: Flow<Event>): Model
StylianosGakis marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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<Event, Model>
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,51 +31,64 @@ sealed interface Event {
data class Model(
val loading: Boolean,
val breeds: List<String>,
val dropdownText: String,
val currentBreed: String?,
val currentUrl: String?,
)
) {
val dropdownText: String = currentBreed ?: "Select breed"
}

class PupperPicsViewModel : MoleculeViewModel<Event, Model>() {
@Composable
override fun models(events: Flow<Event>): Model {
return PupperPicsPresenter(events, PupperPicsService())
override var seed: Model = Model(
loading = false,
breeds = emptyList(),
currentBreed = null,
currentUrl = null,
)

override fun presenterFactory(): MoleculePresenter<Event, Model> {
return PupperPicsPresenter(seed, PupperPicsService())
}
}

@Composable
fun PupperPicsPresenter(events: Flow<Event>, service: PupperPicsService): Model {
var breeds: List<String> 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<Event, Model> {
@Composable
override fun present(events: Flow<Event>): Model {
var breeds: List<String> 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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)

Expand All @@ -51,7 +55,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "akita",
currentBreed = "akita",
currentUrl = null,
),
awaitItem(),
Expand All @@ -65,7 +69,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "akita",
currentBreed = "akita",
currentUrl = "akita.jpg",
),
awaitItem(),
Expand All @@ -77,8 +81,9 @@ class PupperPicsPresenterTest {
fun `selecting breed updates dropdown text and fetches new image`() = runBlocking {
val picsService = FakePicsService()
val events = Channel<Event>()
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")
Expand All @@ -92,7 +97,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "boxer",
currentBreed = "boxer",
currentUrl = "akita.jpg",
),
awaitItem(),
Expand All @@ -101,7 +106,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "boxer",
currentBreed = "boxer",
currentUrl = null,
),
awaitItem(),
Expand All @@ -114,7 +119,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "boxer",
currentBreed = "boxer",
currentUrl = "boxer.jpg",
),
awaitItem(),
Expand All @@ -126,8 +131,9 @@ class PupperPicsPresenterTest {
fun `fetching again requests a new image`() = runBlocking {
val picsService = FakePicsService()
val events = Channel<Event>()
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")
Expand All @@ -139,7 +145,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "akita",
currentBreed = "akita",
currentUrl = null,
),
awaitItem(),
Expand All @@ -151,7 +157,7 @@ class PupperPicsPresenterTest {
Model(
loading = false,
breeds = listOf("akita", "boxer", "corgi"),
dropdownText = "akita",
currentBreed = "akita",
currentUrl = "akita2.jpg",
),
awaitItem(),
Expand Down