Skip to content

1311:Use Compose's AndroidUiDispatcher.Main to conflate actions in runtime. #1353

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

Open
wants to merge 1 commit into
base: sedwards/move-render-2
Choose a base branch
from

Conversation

steve-the-edwards
Copy link
Contributor

@steve-the-edwards steve-the-edwards commented Jun 19, 2025

Note this also includes some adjustments to the runtime loop. After the first suspension to wait and apply an action, with CONFLATE_STALE_RENDERINGS we loop without suspending - applying any actions that are already queued up on the action queues.

If it is not being used on Android, use it for the runtime whenever on Android (requiring Compose).

As it turns out, Compose's AndroidUiDispatcher.Main and its greedy behaviour to drain all dispatches before the next Android Choreographer frame gives us exactly the behaviour we want.

In fact, CONFLATE_STALE_RENDERINGS is basically a no-op when using the AndroidUiDispatcher.Main to dispatch the runtime and consume the renderings. The greediness means that the updates all happen before the rendering can be consumed by view code (as well as all happen before the next frame). Because this is not an immediate dispatcher, when we pass a new rendering to the stateflow to be consumed by the view code, it is not immediately picked up (it has to be dispatched, even though the AndroidUiDispatcher.Main is going to do that greedily and before the next frame). This changes ordering.

On Android context where we use this dispatcher, we can likely just not use CONFLATE_STALE_RENDERINGS at all. however, for other dispatchers, it could still have an effect. We will want to be careful with how we roll out this change.

Still the code used to drain immediately available actions can be used by DRAIN_EXCLUSIVE_ACTIONS to avoid render passes.

Fixes #1311

This should also take care of #1257 if we start using the Compose AndroidUiDispatcher.

@CLAassistant
Copy link

CLAassistant commented Jun 19, 2025

CLA assistant check
All committers have signed the CLA.

…ntime

If it is not being used on Android, use it for the runtime whenever on Android (requiring Compose)
@steve-the-edwards steve-the-edwards force-pushed the sedwards/compose-dispatcher branch from 3276377 to 229688b Compare June 20, 2025 18:51
@zach-klippenstein
Copy link
Collaborator

In fact, CONFLATE_STALE_RENDERINGS is basically a no-op when using the AndroidUiDispatcher.Main to dispatch the runtime and consume the renderings.

On Android context where we use this dispatcher, we can likely just not use CONFLATE_STALE_RENDERINGS at all. however, for other dispatchers, it could still have an effect.

Is it basically a no-op, or actually a no-op? Because if it's not identical, we shouldn't treat it as optional. We already have so many confusing workflow flags, if we start requiring various configurations with caveats about things like dispatchers, the messiness just explodes.

Copy link
Collaborator

@zach-klippenstein zach-klippenstein left a comment

Choose a reason for hiding this comment

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

Ok so after reading, I'm still not sure I fully understand what the significant behavior difference is between using Compose's main dispatcher and Main.immediate.

Numbering these assertions/questions so you can respond more easily.

  1. When the conflation flag is off and multiple actions are available immediately – then with Compose's dispatcher they may result in multiple emissions to the UI layer with redundant updates, but all will still happen before the frame is drawn. Main.immediate will process them all immediately but also emit to the UI immediately, so effectively the same as far as the UI is concerned. But that was existing behavior.
  2. When no actions are available, but another action is sent before the next Choreographer frame, then the action is still be guaranteed to be processed before the frame is drawn (either by Main.immediate's immediacy or by compose's pre-frame drain). Also existing behavior.
  3. When the flag is on, then we avoid suspending when multiple actions are available, which is theoretically a performance win internally since not suspending is faster than suspending, even with Main.immediate?
    1. Externally, the behavior is the same (only emit last conflated rendering to UI) even with Main.immediate because we already weren't emitting on the renderings flow until after the (previously suspending) conflation loop?
  4. If all these are true, then this change only affects behavior for multiple immediate actions when the conflation flag is off? Otherwise the we don't suspend in the conflation loop, so the choice of dispatcher doesn't matter.

@@ -118,58 +138,69 @@ class AndroidDispatchersRenderWorkflowInTest(
val renderings = renderWorkflowIn(
workflow = workflow,
scope = backgroundScope +
Dispatchers.Main.immediate,
AndroidUiDispatcher.Main,
Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is the only dispatcher we're testing, is it now a hard requirement that apps using workflow UI on Android must use Compose? If so we should probably communicate that very clearly and update the docs too. If not, then can we test both?

Copy link
Collaborator

Choose a reason for hiding this comment

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

And furthermore, does this imply the behavior broke for other dispatchers? If not, can we just add a parameter to the parameterization that specifies the dispatcher to use, so we can continue to validate it?

props = props,
runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS),
runtimeConfig = runtime.runtimeConfig,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah cool, so this change is saying that the behavior this tests validates now happens for all configs, as long as we're using that dispatcher?

@@ -272,9 +277,17 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
onOutput: suspend (OutputT) -> Unit = {}
): StateFlow<RenderingT> {
val restoredSnap = savedStateHandle?.get<PickledTreesnapshot>(KEY)?.snapshot

// Add in Compose's AndroidUiDispatcher.Main by default if none is specified.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please add this new magical behavior to the kdoc as well.

actionResult = actionResult,
)
) {
if (shouldShortCircuitForUnchangedState(actionResult = actionResult)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Do we really need this parameter to be named here? When the value passed in has the same name, it doesn't really add any readability.

val result = subtreeManager.applyNextAvailableChildAction()

if (result == ActionsExhausted) {
return eventActionsChannel.tryReceive().getOrNull()?.let { action ->
Copy link
Collaborator

@zach-klippenstein zach-klippenstein Jun 23, 2025

Choose a reason for hiding this comment

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

Please confirm if i understand this right: If we hit this branch, it means either no children had actions to run, or some child had an action but it didn't produce an output?

/**
* Will try to apply any immediately available actions in this action queue or any of our
* children's.
*
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
*
*
* Contrast this to [onNextAction], which is used to await the next action when none is available right now.
* Both call [applyAction] when one is available.

@@ -207,24 +206,35 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
* a re-render, e.g. my state changed or a child state changed.
*
* It is an error to call this method after calling [cancel].
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
* It is an error to call this method after calling [cancel].
* It is an error to call this method after calling [cancel].
*
* Contrast this to [applyNextAvailableAction], which is used to check for an action
* that is already available without waiting, and then _immediately_ apply it.
* Both call [applyAction] when one is available.

@@ -83,24 +80,25 @@ internal class WorkflowRunner<PropsT, OutputT, RenderingT>(
* and resume (breaking ties with order of declaration). Guarantees only continuing on the winning
* coroutine and no others.
*/
@OptIn(WorkflowExperimentalRuntime::class)
suspend fun processAction(waitForAnAction: Boolean = true): ActionProcessingResult {
suspend fun waitAndProcessAction(): ActionProcessingResult {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Since you're renaming this anyway, "await" is the more idiomatic term for "listen while suspended". In contrast, "wait" canonically implies blocking a thread.

ActionsExhausted
}
}
rootNode.onNextAction(this)
Copy link
Collaborator

Choose a reason for hiding this comment

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

(Follow-up to nit): Renaming this method would also make this code pretty much self-documenting and the above line comment redundant.

* none immediately available.
*/
fun applyNextAvailableAction(): ActionProcessingResult {
return rootNode.applyNextAvailableAction()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Maybe name this applyNextAvailableSubtreeAction to make it more clear when reading callsites that this actually walks the tree? It took me a few read-throughs of the subtree/WN code to remember these were actually depth-first visitors methods (this and onNextAction).

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.

3 participants