Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions .github/workflows/kotlin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ jobs:
write-cache-key: main-build-artifacts

check:
name: Check
name: Unit Tests
runs-on: macos-latest
needs: build-all
timeout-minutes: 20
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -21,6 +22,7 @@ import com.squareup.workflow1.ui.plus
import com.squareup.workflow1.ui.withEnvironment
import com.squareup.workflow1.ui.workflowContentView
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.plus

private val viewEnvironment =
(ViewEnvironment.EMPTY + ViewRegistry(HelloBinding))
Expand All @@ -47,7 +49,7 @@ class HelloBindingActivity : AppCompatActivity() {
val renderings: StateFlow<Screen> by lazy {
renderWorkflowIn(
workflow = HelloWorkflow.mapRendering { it.withEnvironment(viewEnvironment) },
scope = viewModelScope,
scope = viewModelScope + AndroidUiDispatcher.Main,
savedStateHandle = savedState,
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package com.squareup.sample.compose.hellocomposeworkflow
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -18,6 +19,7 @@ import com.squareup.workflow1.ui.compose.withComposeInteropSupport
import com.squareup.workflow1.ui.withEnvironment
import com.squareup.workflow1.ui.workflowContentView
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.plus

class HelloComposeWorkflowActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -32,7 +34,7 @@ class HelloComposeWorkflowActivity : AppCompatActivity() {
workflow = HelloWorkflow.mapRendering {
it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport())
},
scope = viewModelScope,
scope = viewModelScope + AndroidUiDispatcher.Main,
savedStateHandle = savedState,
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package com.squareup.sample.compose.inlinerendering
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -18,6 +19,7 @@ import com.squareup.workflow1.ui.compose.withComposeInteropSupport
import com.squareup.workflow1.ui.withEnvironment
import com.squareup.workflow1.ui.workflowContentView
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.plus

/**
* A workflow that returns an anonymous
Expand All @@ -37,7 +39,7 @@ class InlineRenderingActivity : AppCompatActivity() {
workflow = InlineRenderingWorkflow.mapRendering {
it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport())
},
scope = viewModelScope,
scope = viewModelScope + AndroidUiDispatcher.Main,
savedStateHandle = savedState,
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -22,6 +23,7 @@ import com.squareup.workflow1.ui.plus
import com.squareup.workflow1.ui.withEnvironment
import com.squareup.workflow1.ui.workflowContentView
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.plus

private val viewRegistry = ViewRegistry(RecursiveComposableFactory)

Expand All @@ -45,7 +47,7 @@ class NestedRenderingsActivity : AppCompatActivity() {
val renderings: StateFlow<Screen> by lazy {
renderWorkflowIn(
workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) },
scope = viewModelScope,
scope = viewModelScope + AndroidUiDispatcher.Main,
savedStateHandle = savedState,
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
)
Expand Down
4 changes: 2 additions & 2 deletions workflow-runtime-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ dependencies {
val composeBom = platform(libs.androidx.compose.bom)

api(project(":workflow-runtime"))
api(libs.androidx.compose.ui.android)
api(libs.androidx.lifecycle.viewmodel.savedstate)

implementation(composeBom)
implementation(project(":workflow-core"))

androidTestImplementation(libs.androidx.compose.ui.android)
androidTestImplementation(composeBom)
androidTestImplementation(libs.androidx.activity.ktx)
androidTestImplementation(libs.androidx.lifecycle.viewmodel.ktx)
androidTestImplementation(libs.androidx.test.core)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,24 @@
androidx.activity:activity-ktx:1.7.0
androidx.activity:activity:1.7.0
androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.4
androidx.compose.runtime:runtime-android:1.7.2
androidx.compose.runtime:runtime-saveable-android:1.7.2
androidx.compose.runtime:runtime-saveable:1.7.2
androidx.compose.runtime:runtime:1.7.2
androidx.compose.ui:ui-android:1.7.2
androidx.compose.ui:ui-geometry-android:1.7.2
androidx.compose.ui:ui-geometry:1.7.2
androidx.compose.ui:ui-graphics-android:1.7.2
androidx.compose.ui:ui-graphics:1.7.2
androidx.compose.ui:ui-text-android:1.7.2
androidx.compose.ui:ui-text:1.7.2
androidx.compose.ui:ui-unit-android:1.7.2
androidx.compose.ui:ui-unit:1.7.2
androidx.compose.ui:ui-util-android:1.7.2
androidx.compose.ui:ui-util:1.7.2
androidx.compose:compose-bom:2024.09.02
androidx.collection:collection:1.0.0
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core:1.12.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.emoji2:emoji2:1.2.0
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0
androidx.core:core-ktx:1.2.0
androidx.core:core:1.2.0
androidx.lifecycle:lifecycle-common-jvm:2.8.7
androidx.lifecycle:lifecycle-common:2.8.7
androidx.lifecycle:lifecycle-livedata-core:2.8.7
androidx.lifecycle:lifecycle-process:2.8.7
androidx.lifecycle:lifecycle-runtime-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx:2.8.7
androidx.lifecycle:lifecycle-runtime:2.8.7
androidx.lifecycle:lifecycle-viewmodel-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7
androidx.lifecycle:lifecycle-viewmodel:2.8.7
androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing:1.0.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.versionedparcelable:versionedparcelable:1.1.0
com.google.guava:listenablefuture:1.0
com.squareup.okio:okio-jvm:3.3.0
com.squareup.okio:okio:3.3.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,20 @@
package com.squareup.workflow1.android

import androidx.annotation.VisibleForTesting
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.lifecycle.SavedStateHandle
import com.squareup.workflow1.RuntimeConfig
import com.squareup.workflow1.RuntimeConfigOptions
import com.squareup.workflow1.Workflow
import com.squareup.workflow1.WorkflowInterceptor
import com.squareup.workflow1.WorkflowTracer
import com.squareup.workflow1.renderWorkflowIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus

/**
* An Android `ViewModel`-friendly wrapper for [com.squareup.workflow1.renderWorkflowIn],
Expand Down Expand Up @@ -58,10 +55,12 @@ import kotlinx.coroutines.plus
* in any workflows, after the initial render pass, will be handled by this scope, and cancelling
* this scope will cancel the workflow runtime and any running workers. Note that any dispatcher
* in this scope will _not_ be used to execute the very first render pass (which happens
* synchronously).
* Also note that if there is no [CoroutineDispatcher] in this scope then Compose UI's
* [AndroidUiDispatcher.Main] will be used as this is the most performant Android UI dispatcher
* for Workflow.
* synchronously). We recommend using a `CoroutineDispatcher` in this scope's `CoroutineContext`
* that will ensure that all dispatched coroutines are run before the next Choreographer frame.
* Compose UI's `AndroidUiDispatcher.Main` provides this behavior in a performant way!
* Another way to achieve that is to use an immediate (or Unconfined) dispatcher like
* Dispatchers.Main.immediate. However, that dispatcher cannot take advantage of some runtime
* optimizations. E.G., see [RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS].
*
* @param savedStateHandle
* Used to restore workflow state in a new process. Typically this is the
Expand Down Expand Up @@ -141,10 +140,12 @@ public fun <OutputT, RenderingT> renderWorkflowIn(
* in any workflows, after the initial render pass, will be handled by this scope, and cancelling
* this scope will cancel the workflow runtime and any running workers. Note that any dispatcher
* in this scope will _not_ be used to execute the very first render pass (which happens
* synchronously).
* Also note that if there is no [CoroutineDispatcher] in this scope then Compose UI's
* [AndroidUiDispatcher.Main] will be used as this is the most performant Android UI dispatcher
* for Workflow.
* synchronously). We recommend using a `CoroutineDispatcher` in this scope's `CoroutineContext`
* that will ensure that all dispatched coroutines are run before the next Choreographer frame.
* Compose UI's `AndroidUiDispatcher.Main` provides this behavior in a performant way!
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should update all the compose samples to demonstrate.

Copy link
Collaborator

Choose a reason for hiding this comment

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

  • Could also mention that compose apps can use Workflow.renderAsState to sidestep thinking about this. Too much?
  • Did we ever extend renderAsState to allow a config to be passed in?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a good point about the samples!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did we ever extend renderAsState to allow a config to be passed in?

I don't think so!

Copy link
Contributor Author

@steve-the-edwards steve-the-edwards Jul 23, 2025

Choose a reason for hiding this comment

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

Did we ever extend renderAsState to allow a config to be passed in?

Turns out we did. It takes the scope by default from rememberCoroutineScope() which will take it from Compose which will likely be AndroidUiDispatcher.Main on Android.

* Another way to achieve that is to use an immediate (or Unconfined) dispatcher like
* Dispatchers.Main.immediate. However, that dispatcher cannot take advantage of some runtime
* optimizations. E.G., see [RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS].
*
* @param prop
* Specifies the sole [PropsT] value to use to render the root workflow. To allow updates,
Expand Down Expand Up @@ -243,10 +244,12 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
* in any workflows, after the initial render pass, will be handled by this scope, and cancelling
* this scope will cancel the workflow runtime and any running workers. Note that any dispatcher
* in this scope will _not_ be used to execute the very first render pass (which happens
* synchronously).
* Also note that if there is no [CoroutineDispatcher] in this scope then Compose UI's
* [AndroidUiDispatcher.Main] will be used as this is the most performant Android UI dispatcher
* for Workflow.
* synchronously). We recommend using a `CoroutineDispatcher` in this scope's `CoroutineContext`
* that will ensure that all dispatched coroutines are run before the next Choreographer frame.
* Compose UI's `AndroidUiDispatcher.Main` provides this behavior in a performant way!
* Another way to achieve that is to use an immediate (or Unconfined) dispatcher like
* Dispatchers.Main.immediate. However, that dispatcher cannot take advantage of some runtime
* optimizations. E.G., see [RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS].
*
* @param props
* Specifies the initial [PropsT] to use to render the root workflow, and will cause a re-render
Expand Down Expand Up @@ -278,7 +281,6 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
* A [StateFlow] of [RenderingT]s that will emit any time the root workflow creates a new
* rendering.
*/
@OptIn(ExperimentalStdlibApi::class)
public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
workflow: Workflow<PropsT, OutputT, RenderingT>,
scope: CoroutineScope,
Expand All @@ -291,16 +293,9 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
): 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.

Would it be too magical to use reflection to look for AndroidUiDispatcher.Main and default to it if available?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think probably?

val updatedContext = if (scope.coroutineContext[CoroutineDispatcher.Key] == null) {
scope.coroutineContext + AndroidUiDispatcher.Main
} else {
scope.coroutineContext
}

val renderingsAndSnapshots = renderWorkflowIn(
workflow,
scope + updatedContext,
scope,
props,
restoredSnap,
interceptors,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,22 @@ import kotlinx.coroutines.plus
* available actions and do another render pass.
* 1. Pass the updated rendering into the [StateFlow] returned from this method.
*
* Note that if this is run on the main thread we must `suspend` in order to release the thread and
* let any actions that can be processed queue up. How that happens will depend on the
* [CoroutineDispatcher] used in [scope].
* When there is UI involved, we recommend using a `CoroutineDispatcher` in [scope]'s
* `CoroutineContext` that runs the above runtime loop until it is stable before the next 'frame',
* whatever frame means on your platform. Specifically, ensure that all dispatched coroutines are
* run before the next 'frame', so that the Workflow runtime has done all the work it can.
*
* If an "immediate" dispatcher is used, then after 1 only 1 action will ever be available since
* as soon as it is available it will resume and start processing the rest of the loop immediately.
* One way to achieve that guarantee is with an "immediate" dispatcher on the main thread - like,
* `Dispatchers.Main.immediate` - since it will continue to run until the runtime is stable before
* it lets any frame get updated by the main thread.
* However, if an "immediate" dispatcher is used, then only 1 action will ever be available
* since as soon as it is available it will resume (step #1 above) and start processing the rest of
* the loop immediately.
* This means that [DRAIN_EXCLUSIVE_ACTIONS] and [CONFLATE_STALE_RENDERINGS] will have no effect.
*
* There is no need to try the [DRAIN_EXCLUSIVE_ACTIONS] loop after each render pass in
* [CONFLATE_STALE_RENDERINGS] because they all happen synchronously so no new exclusive actions
* could have been queued.
* A preferred way to achieve that is to have your dispatcher drain coroutines for each frame
* explicitly. On Android, for example, that can be done with Compose UI's
* `AndroidUiDispatcher.Main`.
*
* ## Scoping
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,61 +1,33 @@
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-experimental:1.4.0
androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.4
androidx.compose.runtime:runtime-android:1.7.2
androidx.compose.runtime:runtime-saveable-android:1.7.2
androidx.compose.runtime:runtime-saveable:1.7.2
androidx.compose.runtime:runtime:1.7.2
androidx.compose.ui:ui-android:1.7.2
androidx.compose.ui:ui-geometry-android:1.7.2
androidx.compose.ui:ui-geometry:1.7.2
androidx.compose.ui:ui-graphics-android:1.7.2
androidx.compose.ui:ui-graphics:1.7.2
androidx.compose.ui:ui-text-android:1.7.2
androidx.compose.ui:ui-text:1.7.2
androidx.compose.ui:ui-unit-android:1.7.2
androidx.compose.ui:ui-unit:1.7.2
androidx.compose.ui:ui-util-android:1.7.2
androidx.compose.ui:ui-util:1.7.2
androidx.compose:compose-bom:2024.09.02
androidx.collection:collection:1.1.0
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core:1.13.1
androidx.customview:customview-poolingcontainer:1.0.0
androidx.documentfile:documentfile:1.0.0
androidx.dynamicanimation:dynamicanimation:1.0.0
androidx.emoji2:emoji2:1.2.0
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-jvm:2.8.7
androidx.lifecycle:lifecycle-common:2.8.7
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7
androidx.lifecycle:lifecycle-livedata-core:2.8.7
androidx.lifecycle:lifecycle-livedata:2.8.7
androidx.lifecycle:lifecycle-process:2.8.7
androidx.lifecycle:lifecycle-runtime-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx:2.8.7
androidx.lifecycle:lifecycle-runtime:2.8.7
androidx.lifecycle:lifecycle-viewmodel-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7
androidx.lifecycle:lifecycle-viewmodel:2.8.7
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.print:print:1.0.0
androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing:1.0.0
Expand Down
Loading
Loading