-
Notifications
You must be signed in to change notification settings - Fork 113
Alter viewmodel-sample to show how to use a not always hot StateFlow #274
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
base: trunk
Are you sure you want to change the base?
Changes from all commits
4e80877
e9dcfda
1ec6561
8c1621b
395125d
19bd668
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,31 +20,45 @@ 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() { | ||
| abstract class MoleculeViewModel<Event, Model>( | ||
| initialState: Model, | ||
| started: SharingStarted = SharingStarted.WhileSubscribed(5.seconds), | ||
| presenter: @Composable (seed: Model, events: Flow<Event>) -> 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<Event>(extraBufferCapacity = 20) | ||
|
|
||
| private var seed: Model = initialState | ||
|
|
||
| val models: StateFlow<Model> by lazy(LazyThreadSafetyMode.NONE) { | ||
| scope.launchMolecule(mode = ContextClock) { | ||
| models(events) | ||
| } | ||
| moleculeFlow(mode = ContextClock) { | ||
| presenter(seed, events) | ||
| }.onEach { | ||
| seed = it | ||
| }.stateIn( | ||
| scope = scope, | ||
| started = started, | ||
| initialValue = seed, | ||
|
Comment on lines
+48
to
+55
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This whole pattern really feels like it should be encapsulated in something. I could see it being one (or more) of the following:
I legitimately cannot figure out what to do with this PR. The use case is certainly valid. But the amount of code required to accomplish it as-is seems like it would overwhelm someone trying to learn how to use Molecule with AndroidX View Model rather than guide them.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (This is not really a reflection of your PR, more of the current library design, the design of View Model, and the general sadness of lifecycle.)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes I understand and I agree. It really shouldn't be this way, but it is the current reality unfortunately.
So my entire reasoning behind starting this PR came down to the steps we took to adopt molecule in the app I work for: We were looking into adopting Molecule for our codebase for some time now.
Basically then, I fear that people who use AAC ViewModel will go up to step # 2 here, and then stop there without realizing there's more to be done. Then eventually when they figure out what happens, they'll blame Molecule instead of ViewModel since that's when this change would have been introduced to them. If we can do something to make this PR even better and make the code more understandable, perhaps from the points you suggest I'd love for that to happen of course. Other than that I personally still feel like it's a net positive if the sample which a lot of people are probably going to follow blindly is actually playing well with AAC ViewModel.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JakeWharton, if we consider On reflection, I think the happy places to land are likely to be:
The last two are most appealing to me. But even And is keeping it running really so bad? How many would actually be kept running at one time in the VM framework? Is it just the VMs currently on screen during config change, or have I fallen off the boat here?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's all the VMs in screens in the backstack, so potentially many of them. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's worth noting that a The recommended period for state production to be halted in the absence observers with ViewModels is 5 seconds. If a screen is in the back stack for 5 seconds, it should still have access to it's last produced non parcelable state when resumed IMO. Otherwise, leaving a screen for > 5 seconds and returning to it will have the same behavior as the app cold starting on that screen. In the case of a screen with a paginated list for example, this may mean a progress bar till the list is refreshed and the scroll position properly restored.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yep. |
||
| ) | ||
| } | ||
|
|
||
| fun take(event: Event) { | ||
| if (!events.tryEmit(event)) { | ||
| error("Event buffer overflow.") | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| protected abstract fun models(events: Flow<Event>): Model | ||
|
StylianosGakis marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,22 +31,40 @@ sealed interface Event { | |
| data class Model( | ||
| val loading: Boolean, | ||
| val breeds: List<String>, | ||
| val dropdownText: String, | ||
| val currentBreed: String?, | ||
| val currentUrl: String?, | ||
| ) | ||
|
|
||
| class PupperPicsViewModel : MoleculeViewModel<Event, Model>() { | ||
| @Composable | ||
| override fun models(events: Flow<Event>): Model { | ||
| return PupperPicsPresenter(events, PupperPicsService()) | ||
| } | ||
| ) { | ||
| val dropdownText: String = currentBreed ?: "Select breed" | ||
| } | ||
|
|
||
| class PupperPicsViewModel( | ||
| // This service would typically be injected | ||
| service: PupperPicsService = PupperPicsService(), | ||
| ) : MoleculeViewModel<Event, Model>( | ||
| initialState = Model( | ||
| loading = false, | ||
| breeds = emptyList(), | ||
| currentBreed = null, | ||
| currentUrl = null, | ||
| ), | ||
| presenter = { seed, events -> | ||
| PupperPicsPresenter( | ||
| seed = seed, | ||
| events = events, | ||
| service = service, | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| @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) } | ||
| fun PupperPicsPresenter( | ||
| seed: Model, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The need for it mostly stems from restarting. Consider a timer that counts how much time is spent on a screen doing an activity, like the NYT crossword. It has the following presenter Composable: @Composable
fun TimerPresenter(seed: Long): Long {
var elapsed by remember { mutableStateOf(seed) }
LaunchedEffect(Unit) {
while(true) {
delay(1.seconds)
++elapsed
}
}
}Without the seed, there is no way to restore elapsed to its last seen state in between presenter stop and restarts save for writing it to a data source. If the data source exposed a cold flow, the last seen value will immediately be overwritten: @Composable
fun TimerPresenter(dataSource: Flow<Long>): Long {
// There is no good default to use here upon resumption
val savedElapsed by dataSource.collectAsState(0)
// elapsed will start back at 0
var elapsed by remember { mutableStateOf(savedElapsed) }
LaunchedEffect(Unit) {
while(true) {
delay(1.seconds)
++elapsed
}
}
// Assuming the data source is well behaved and only emits once,
// add the last time elapsed to the current one
LaunchedEffect(savedElapsed) {
elapsed += savedElapsed
}
}In the above, the UI will briefly flash There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't an issue with
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand the LaunchedEffect(Unit) {
delay(1000L)
++elapsed
}Should it be inside a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it should. Thanks for pointing it out!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we do end up using a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the particular case of this example, a no argument The larger point in context being, it would be nice if molecule presenters could be used to create a |
||
| events: Flow<Event>, | ||
| service: PupperPicsService, | ||
| ): 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. | ||
|
|
@@ -75,7 +93,7 @@ fun PupperPicsPresenter(events: Flow<Event>, service: PupperPicsService): Model | |
| return Model( | ||
| loading = currentBreed == null, | ||
| breeds = breeds, | ||
| dropdownText = currentBreed ?: "Select breed", | ||
| currentBreed = currentBreed, | ||
| currentUrl = currentUrl, | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The need for this
initialValuedoes make the case that having astartedparameter onlaunchMoleculeis 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 whatMoleculeViewModelis doing