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

Add support for suspending observation of flows/state observation while the UI is inactive. #572

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from

Conversation

avelicu
Copy link

@avelicu avelicu commented Jan 26, 2025

This change:

  • Adds a coroutine context element wrapping a stateflow representing whether the molecule is being observed or not. This, for most apps that collect the state via .collectAsStateWithLifecycle, corresponds to whether the UI is active.
  • Add an extension function CoroutineScope.repeatWhileMoleculeActive, which runs and cancels the inner block as the molecule becomes active and inactive, analogous to androidx.lifecycle.repeatOnLifecycle.
  • Add extension functions on Flow and StateFlow to collectAsStateWhileMoleculeActive, attaching and detaching collectors as the molecule becomes and unbecomes active. This pauses production in Flows and in StateFlows that produce .WhileSubscribed().
  • Also adds an optional composeOnlyWhileSubscribed option to launchMolecule which pauses the entire state production while there are no subscribers. I am actually not sure this is necessary anymore with the other 3 changes, and on its own is not really sufficient to avoid extraneous state production, so I am wondering if we should just remove it.

  • CHANGELOG.md's "Unreleased" section has been updated, if applicable.

@avelicu
Copy link
Author

avelicu commented Jan 26, 2025

Here is a sample app using these primitives: https://github.com/avelicu/ReactiveTest/blob/main/app/src/main/java/net/velicu/reactivetest/MainScreenStateProducer.kt

Happy to work to add these into the existing sample apps as well.

@avelicu avelicu force-pushed the while-started branch 7 times, most recently from b7f258c to 0d7dcfa Compare January 27, 2025 01:55
…te while the UI is inactive.

This change:
 - Adds a coroutine context element wrapping a stateflow representing whether the molecule is being observed or not.
   This, for most apps that collect the state via .collectAsStateWithLifecycle, corresponds to whether the UI is
   active.
 - Add an extension function CoroutineScope.repeatWhileMoleculeActive, which runs and cancels the inner block as
   the molecule becomes active and inactive, analogous to androidx.lifecycle.repeatOnLifecycle.
 - Adds an extension function CoroutineScope.awaitMoleculeActive, which suspends until the molecule is active.
   This can be used to avoid state generation inside a produceState until the UI becomes active.
 - Add extension functions on Flow and StateFlow to collectAsStateWhileMoleculeActive, attaching and detaching
   collectors as the molecule becomes and unbecomes active. This pauses production in Flows and in StateFlows that
   produce .WhileSubscribed().
@jingibus
Copy link
Contributor

I think I'd like to put my opinion down as against this PR as written for the following reasons:

  • It's more intrusive than it needs to be: at its heart, the tool is a collectAsState(WhileSubscribed). I believe this can be accomplished without wiring it into Molecule's internals.
  • While I am sure the tool is useful for a lot of folks who have this need, it has some sharp edges as an API surface that this repo would get some traffic about. E.g. "Why does the value of my molecule suddenly change when my ViewModel reappears?" (answer: because it gets rebooted and any state is discarded.) So I don't think it would be a cost-free addition to the API surface.

So while I think the tool isn't without merit, I think it might be better off living in a separate repo. And, failing that, I think the underlying launchMolecule implementation should remain unchanged, with this functionality being implemented as a wrapper.

@avelicu
Copy link
Author

avelicu commented Jan 27, 2025

"Why does the value of my molecule suddenly change when my ViewModel reappears?"

That's the good part about this change - the value of the molecule should not change when the stateflow gets rebooted. The entire composition remains active, including the state it internally retains. This is one fundamental difference from the approach in #274 where the entire composition dies (IIUC).

You're right that it entirely could be implemented as a wrapper, though.

I'm still trying to figure out how to best use Molecule inside a viewmodel and my opinion might change, this PR itself has changed multiple times over the weekend as I figured out the limitations.

@jingibus
Copy link
Contributor

That's the good part about this change - the value of the molecule should not change when the stateflow gets rebooted. The entire composition remains active, including the state it internally retains. This is one fundamental difference from the approach in #274 where the entire composition dies (IIUC).

My apologize - I had thought from my reading that this would cancel collection of an underlying moleculeFlow, which would have the effect of resetting composition.

If that's the case, though — where's the benefit? Ceasing to drive the state into the MutableStateFlow doesn't accomplish that much - it's just a bit of data, after all, and stopping publication of new values has little effect. And keeping composition going will also not do some of the stuff you might not want to do while your ViewModel is paused: e.g. shut down any background work.

Appreciating the opportunity to read your code and think through these issues again!

@avelicu
Copy link
Author

avelicu commented Jan 27, 2025

Well, it's precisely to help you cut off the expensive background work, for example subscription to database updates, while your app is backgrounded (but the data might still change as a result of syncing with a server, for instance - this happens frequently in our app); at the same time without cutting off the entire state composition so it may be available immediately upon resumption.

Imagine that in your state producer you have something like:

@Composable
fun someDataProducer(thingIdentifier: Id) {
 val dataFromDb = remember(thingIdentifier) { createThingObserverFlow(thingIdentifier) }.collectAsState()
}

and assume that createThingObserverFlow creates a StateFlow that refreshes every time the data changes WhileSubscribed(), but also caches the latest data.

If we collectAsState the flow will continuously read from the database while the app is in the background, because the composition is permanently subscribed to it. However, if we collectAsStateWhileMoleculeActive, it won't. But the last loaded value will remain available for the rest of the composition and the UI won't flash when it resumes.

Similarly, if you don't already have a DAO that produces this type of stateflow and instead just have an api to read data and get change notifications (like our app currently does..), this type of flow can be implemented inside a composable function with the awaitMoleculeActive primitive, like this:

@Composable
fun getDataForThing(thingIdentifier: Id): Thing = produceState(initialValue = null, thingIdentifier) {
  while (true) {
    awaitMoleculeActive()
    value = async(backgroundThread) { loadData(thingIdentifier) }.await()
    awaitDataChangeNotification()
  }
}.value

where awaitDataChangeNotification is a suspend fun that suspends until a database change occurs.

This will:

  • Continuously produce up-to-date data for thingIdentifier while the molecule is active (because the UI is active)
  • Cache the latest data even while it's not producing new data

(I'm typing out pseudocode based on a working local prototype so apologies for mistakes)

@avelicu
Copy link
Author

avelicu commented Jan 27, 2025

In other words, the .collectAsStateWhileMoleculeActive allows us to bridge the always active composition to stateflows that care about whether or not somebody is subscribed to them, and the awaitMoleculeActive / repeatWhileMoleculeActive APIs allow you to accomplish similar behavior when you're implementing your state producers in molecules.

By the way, you talked about moleculeFlow in your comment but this change only changes launchMolecule by wrapping directly the stateflow returned from it. If we were to do this as a wrapper to the molecule code we could certainly allow the user of the wrapper to specify a custom signal for whether a moleculeFlow is "active" or not.

@avelicu
Copy link
Author

avelicu commented Jan 27, 2025

And keeping composition going will also not do some of the stuff you might not want to do while your ViewModel is paused: e.g. shut down any background work.

It's beneficial for the user to control which background work should pause while the UI is away and which background work should continue. For example, mutations should never cancel or pause while the UI is away (more generally, they should not even go away when the viewmodel goes away, they should be ran in a larger scope) but data loads should. And, of course, state should always be retained - otherwise there's not really a point in using a viewmodel over just rememberSaveable state in activity-lifecycled components.

@jingibus
Copy link
Contributor

If this state is intended to be consumed within the Molecule, would it be more idiomatic to expose it as a State<Boolean> rather than as a StateFlow<Boolean>?

Finally, if the goal is to provided the programmatic ability to flip tasks on and off within the composition, would it make sense to skip the parameter, and instead provide collection state as an always-on API?

(I can't speak for Jake, btw! But: pulling the thread on this idea)

@avelicu
Copy link
Author

avelicu commented Jan 27, 2025

Right now the state is actually always consumed within coroutines started within the molecule, not within the Composable bits of the molecule, so in that sense a stateflow in a coroutine context is necessary. A previous version of this change also created a compositionlocal that exposed whether the flow is active, but I'm not sure that's necessary anymore. It might allow you to do something like

@Composable
fun(..) {
  WhenCompositionActive {
    LaunchedEffect(...)
  }
}

but it will always restart the launched effect when the composition flips from inactive to active, whereas this variant allows you to eg. only restart parts of your coroutine, for instance restart data collection but not drop cacheable state.

Finally, if the goal is to provided the programmatic ability to flip tasks on and off within the composition, would it make sense to skip the parameter, and instead provide collection state as an always-on API?

Not sure I follow - what do you mean by an always-on API?

@jingibus
Copy link
Contributor

but it will always restart the launched effect when the composition flips from inactive to active, whereas this variant allows you to eg. only restart parts of your coroutine, for instance restart data collection but not drop cacheable state.

I think a State based API would support those uses as well, but in a more Compose-native way. They could also be used in coroutines via snapshotFlow. And the composable APIs are handy!

Not sure I follow - what do you mean by an always-on API?

I just mean that, if this is worthwhile, there doesn't have to be a flag - always provide this state in a context key, and if you don't use it, you don't use it, no problem.

@avelicu
Copy link
Author

avelicu commented Jan 28, 2025

I think a State based API would support those uses as well, but in a more Compose-native way. They could also be used in coroutines via snapshotFlow. And the composable APIs are handy!

Let me look into that and share a diff and we can see how it looks.

On your second point:
Ah, I didn't intend for it to be a "flag", in fact it gets created internally in the launchMolecule variant that creates a StateFlow.

The arg is added to this overload:

launchMolecule(
  mode: RecompositionMode,
  emitter: (value: T) -> Unit,
  context: CoroutineContext = EmptyCoroutineContext,
  body: @Composable () -> T,
)

I'm not sure if this is actually used in cashapp or other users of the library and is actually intended to be actually public, but if the emitter is specified by the caller like that I don't think we can provide the signal internally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants