This module hosts the workflow-ui compose integration, and this file describes in detail how that integration works and why.
It was originally published as a blog post. Since then, our approach has been overhauled. With that overhaul this document has been updated to reflect how the new system works, but it's still very design doc like. To skip past all the big picture and implementation verbiage and get right to how to use this stuff, jump down to [API Design][#api-design] below.
At Square, when we start a project, we like to enumerate and distinguish goals and explicit non-goals to delineate the scope of the project.
- Allow Workflow screens to be written in Compose.
- Allow interop in both directions using the usual idioms:
- Non-Compose Workflow screens to nest Compose-based Workflow screens.
- Compose-based Workflow screens to nest both Compose-based and non-Compose Workflow screens.
- Provide a way for Compose-based apps to host a Workflow runtime and display Compose apps, e.g. a parallel entry point to how non-Compose apps can use
WorkflowLayout
. - Ensure that data flow through
CompositionLocal
s is propagated to child Compose-based screens, regardless of whether there are non-Workflow screens sitting in between them.
- Convert existing screens in our apps to Compose.
- Provide design system components in Compose.
There are a few major areas this project needs to focus on to support Compose from Workflows: navigation, UI factory support (that is, ScreenViewFactory
and the other types collected by ViewRegistry
), and hosting.
Workflow isn’t just a state management library — Workflow UI includes navigation containers for things like backstacks and windows -- including modals. (Support for complex navigation logic was one of our main drivers in writing the library — we outgrew things like Jetpack Navigation a long time ago.).
Because these containers define “lifecycles” for parts of the UI, they need to communicate that to the Compose primitives through the AndroidX concepts of LifecycleOwner
and SavedStateRegistry
. When a composition is hosted inside an Android View
, the AbstractComposeView
that bridges the two reads the ViewTreeLifecycleOwner
to find the nearest Lifecycle
responsible for that view.
AndroidX recently introduced the concept of
ViewTree*
helpers — these are static getters and setters that setView
tags, and search up the view hierarchy for those tags, to allow views to communicate in an ambient way with their children.ViewTreeLifecycleOwner
, for example, allows any view to find the nearestLifecycleOwner
by looking up theView
tree. AndroidXFragment
s andComponentActivity
s set theViewTreeLifecycleOwner
,SavedStateRegistry
, and other owners on their root views to support this.
The Lifecycle
is then observed, both to know when it is safe to restore state, and to know when to dispose the composition because the navigation element is going away. The view also reads the SavedStateRegistry
, wraps it in a SaveableStateRegistry
, and provides it to the composition via the LocalSaveableStateRegistry
. As per the SavedStateRegistry
contract, the registry is asked to restore the composition state as soon as the Lifecycle
moves to the CREATED
state. Any rememberSaveable
calls in the composition will use this mechanism to save and restore their state.
In order for this wiring to all work with Workflows, the Workflow navigation containers must correctly publish Lifecycle
s and SavedStateRegistry
s for their child views. The containers already manage state saving and restoration via the Android View
“hierarchy state” mechanism that all View
classes participate in, so it’s not much of a stretch for them to support this new AndroidX stuff as well. The tricky part is that the sequencing of these different state mechanisms is picky and a little complicated, and we ideally want the Workflow code to support this stuff even if the Workflow view root is hosted in an environment that doesn’t (e.g. a non-AndroidX Activity
).
None of the AndroidX integrations described in this section actually have anything to do with Compose specifically. They are required for any code that makes use of the AndroidX
ViewTree*Owners
from within a Workflow view tree. Compose just happens to rely on this infrastructure, so Workflow has to support it in order to support Compose correctly.
For LifecycleOwner
support, we need to think of anything that can ask the ViewRegistry
to build a view as a LifecycleOwner
. This is because all such containers know when they are going to stop showing a particular child view (e.g. because the rendering type has changed, or a rendering is otherwise incompatible with the current one, and a new view must be created and bound). When that happens, they need to move the Lifecycle
to the DESTROYED
state to ensure the hosted composition will be disposed.
We can provide an API for this so that containers only need to make a single call to dispose their lifecycle, and everything else “just works.” And luckily, most developers building features with Workflow will never write a container directly but instead use WorkflowViewStub
, which we will make do the right thing automatically.
WorkflowLifecycleOwner
is the class we use to nest Lifecycle
s. A WorkflowLifecycleOwner
is a LifecycleOwner
with a few extra semantics. WorkflowLifecycleOwner
s form a tree. The lifecycle of a WorkflowLifecycleOwner
follows its parent, changing its own state any time the parent state changes, until either the parent enters the DESTROYED
state or the WorkflowLifecycleOwner
is explicitly destroyed. Thus, a tree of WorkflowLifecycleOwner
s will be synced to the root Lifecycle
(probably an Activity
or a Dialog
), but a container can set the state of an entire subtree to DESTROYED
early – this will happen whenever the container is about to replace a view. When a container can show different views over its lifetime, it must install a WorkflowLifecycleOwner
on each view it creates and destroy that owner when the managed view is about to be replaced. A WorkflowLifecycleOwner
s automatically finds and observes its parent Lifecycle
by the usual method — searching up the view tree.
SavedStateRegistry
is a bit more complicated because of the sequencing of lifecycle and “view instance state” calls with the SavedStateRegistry
ones.
Before all this AndroidX stuff, here’s how view state saving and restoration worked:
-
View
is instantiated. Constructor probably performs some initialization, e.g. setting defaultEditText
values. An ID should be set. -
View
is added as a child of aViewGroup
and attached to a window. -
After the hosting
Activity
moves to theSTARTED
state,onRestoreInstanceState
is called for every view in the hierarchy (even theView
’s children, if it has any).EditText
s, for example, use this callback to restore any previously-entered text. Because this callback happens after initialization, it looks to the app user like the text was just restored — they never see the initial value. -
View
gets arbitrarily-many calls toonSaveInstanceState
. The last one of these before the view is destroyed is what may be used to restore the view later.
The old mechanism depends on View
s having their IDs set. These IDs are used to associate state with particular views, since there is no other way to match view instances between different processes.
Here’s how the view restoration system works with AndroidX’s SavedStateRegistry
:
-
View
is instantiated. Because the view hasn’t been attached to a parent yet, it can’t use theViewTree*Owner
functions. -
View
is eventually added to aViewGroup
, and attached to the window. Now the view has a parent, so theonAttached
callback can search up the tree for theViewTreeLifecycleOwner
. It also looks for theSavedStateRegistryOwner
— it can’t use it yet though. -
One or more
SavedStateProvider
s are registered on the registry associated with arbitrary string keys — these providers are simply functions that will be called arbitrarily-many times to provide saved values when the system needs to save view state. -
The
Lifecycle
is observed, as long as the view remains attached. -
When the lifecycle state moves to
CREATED
, theSavedStateRegistry
can be queried. The view’s initialization logic can now callconsumeRestoredStateForKey
to read back any previously-saved values associated with string keys. If there were no values available, null will be returned and the view should fallback to some default value. -
When the view goes away, the
SavedStateProvider
s should be unregistered.
Note the difference in when the restoration happens relative to the lifecycle states. The following table summarizes the differences between the instance state mechanism and SavedStateRegistry
.
Instance state | SavedStateRegistry |
---|---|
All views participate in this mechanism, and can’t opt-out. | Views must opt-in by getting access to a SavedStateRegistry either via findViewTreeSavedStateRegistryOwner or some other way. |
Save/restore callbacks are built into the View base class as overridable methods. Restoration is “push-based” — views are told when to restore themselves. |
Views must register saved state providers explicitly in order to get the save callbacks. Restoration is “pull-based” — views must request previously-saved values at the appropriate time. Registry API requires coordination with Lifecycle API. |
Restored after lifecycle STARTED . |
Restored after lifecycle CREATED . |
Identified by hierarchy of view IDs. | Identified by string keys. |
IDs only need to be unique within their parent view. | Keys must be unique within a given registry (this scope is not as clearly defined, but usually means within the navigation “frame”). |
Saved state is represented as Parcelable s, only not really because well-behaved views should actually return their state as subclasses of a special base class. |
Saved state is represented as Bundle s. Compatible with Parcelable s but much more convenient API for every-day use. |
These differences make tying these together and supporting both from a single container a little complicated.
Every container must support both of these mechanisms, but ideally using a single source of truth for saved state.
Because containers and WorkflowViewStub
s can exist anywhere in a view tree, they must be able to identify themselves to the different state mechanisms appropriately. It turns out that the legacy instance state approach of using view IDs is capable of supporting the registry string key approach, but it’s not really feasible the other way around. So the source of truth needs to be the instance state, and the registry state is stored in and restored from that.
Because the SavedStateRegistry
contract says that it must be consumable as soon as the lifecycle is in the CREATED
state, containers must also be able to control the lifecycle to ensure that it isn’t moved to that state until they’ve had a chance to actually seed the registry with restored data.
The last two points form a cycle: we don’t get the onRestoreInstanceState
callback until we’re in the STARTED
state, but we can’t advance our children’s lifecycle past the CREATED
state until we have read the registry state out of the instance state and seeded the registry. So the sequence we need to implement is:
- Create a
Lifecycle
to provide to our children, but hold it in theINITIALIZED
state. Create aSavedStateRegistry
to provide to our children as well. - In
onRestoreInstanceState
, read both the instance state for our children, and the registry state. - Seed our
SavedStateRegistry
with the restored state. - Advance state to
CREATED
. Children will consume state from the registry. - Advance state to
STARTED
. (This can actually be done in a single step with the previous one, the Lifecycle APIs ensure every state is hit.) - Invoke children’s
onRestoreInstanceState
methods.
We have looked at a few ways of implementing this:
- Single source of state:
SavedStateRegistry
.BackStackContainer
only usesSavedStateRegistry
to save and restore state, and implements support for classic view state on top of the newer registry APIs. This approach is infeasible because it requires emulating the way that state is namespaced withView
IDs with string keys, and that is surprisingly difficult. - Single source of state: view state.
BackStackContainer
only uses view state, and implements support for the newer registry APIs on top of classic view state. While easier to implement than (1), it requires changingWorkflowLifecycleOwner
to give the container more control over the lifecycle to comply withSavedStateRegistry
's contract about when states can be restored. - Use both.
BackStackContainer
uses the classic view state hooks to manage classic view state, and usesSavedStateRegistry
hooks to manage registry state. This allows each mechanism to keep its advantage, and doesn't require emulating one's behavior with the other.
Workflow UI is built around UI factory interfaces like ScreenViewFactory
and OverlayDialogFactory
, functions that build and update classic Android View
and Dialog
instances for each type of view model rendered by a Workflow tree. To find the appropriate factory to express a rendering of a particular type, container classes like [WorkflowViewStub
] delegate most of their work to factory finder interfaces like ScreenViewFactoryFinder
and [OverlayDialogFactoryFinder]
(https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt).
To add seamless Compose support, we add another UI factory and finder interface pair: ScreenComposableFactory
and ScreenComposableFactoryFinder
.
And to make those as easy to use as possible, we introduce ComposeScreen
, a Compose analog to AndroidScreen
We also provide @Composable fun WorkflowRendering()
, a construction function analogous to WorkflowViewStub
to allow Compose-based factories to idiomatically display child renderings.
You'll note that there is no
OverlayComposableFactory
family of interfaces. So far, all of our window management is strictly via classic AndroidDialog
calls. There is nothing stopping us (or you) from adding Compose-basedOverlay
support in the future as a replacement, but we're definitely not making any promises on that front.
Finally, we provide support for gluing together the Classic and Compose worlds. The ViewEnvironment.withComposeInteropSupport()
function replaces the ScreenViewFactoryFinder
and ScreenComposableFactory
objects in the receiver with implementations that are able to delegate to the other type.
All of the above coordinates nicely, so that when a Compose-based factory is delegating to a child rendering that is also bound to a composable factory, we can skip the detour out into the Android view world and simply call the child composable directly from the parent. It is also possible to provide both Classic and Compose treatments of any type of rendering. We use this technique with the standard NamedScreen
and EnvironmentScreen
wrapper types to prevent needless journeys through @Composable fun AndroidView()
and the ComposeView
class. (#546)
The following APIs will are packaged into two Maven artifacts. Most of them live in a core “Workflow-compose” module, and the preview tooling support is in a “Workflow-compose-tooling” module.
Alas, we can't make this just work out of the box without you making a bootstrap call to put the key pieces in place.
Even if your own UI code is strictly built in Compose, the stock BackStackScreen
and BodyAndOverlaysScreen
types are still implemented only via classic View
code.
You need to call ViewEnvironment.withComposeInteropSupport()
somewhere near the top.
For example, here is how to do it with your renderWorkflowIn()
call:
private val viewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport()
renderWorkflowIn(
workflow = HelloWorkflow.mapRendering {
it.withEnvironment(viewEnvironment)
},
scope = viewModelScope,
savedStateHandle = savedState,
)
The most straightforward and common way to tie a Screen
rendering type to a @Composable
function is to implement ComposeScreen
, the Compose-friendly analog to AndroidScreen
.
data class HelloScreen(
val message: String,
val onClick: () -> Unit
) : ComposeScreen {
@Composable override fun Content(viewEnvironment: ViewEnvironment) {
Button(onClick) {
Text(message)
}
}
}
ComposeScreen
is a convenience that automates creating a ScreenComposableFactory
implementation responsible for expressing, say, HelloScreen
instances by calling HelloScreen.Content()
.
This ScreenComposableFactory
interface is the lynchpin API combining Workflow and Compose. It’s basically a single builder function that takes a @Composable
lambda that emits the UI for the given Screen
rendering type. In Compose terms, the Screen
acts as hoisted state for thre related @Composable
. The rendering and view environment are simply provided as parameters, and Compose’s machinery takes care of ensuring the UI is updated when a new rendering or view environment is available.
Here’s an example of how ScreenComposableFactory
can be used directly to keep a rendering type decoupled from the related Compose code:
data class ContactScreen(
val name: String,
val phoneNumber: String
): Screen
val contactUiFactory = ScreenComposableFactory<ContactScreen> { rendering, viewEnvironment ->
Column {
Text(rendering.name)
Text(rendering.phoneNumber)
}
}
private val viewEnvironment = ViewEnvironment.EMPTY +
(ViewRegistry to ViewRegistry(contactUiFactory))
.withComposeInteropSupport()
renderWorkflowIn(
workflow = HelloWorkflow.mapRendering {
it.withEnvironment(viewEnvironment)
},
scope = viewModelScope,
savedStateHandle = savedState,
)
Aka, WorkflowViewStub
— Compose Edition! The idea of “view stub” is nonsense in Compose — there are no views! Instead, we simply provide a composable that takes a rendering and a view environment, and tries to display the rendering from the environment’s ViewRegistry
.
@Composable fun WorkflowRendering(
rendering: Screen,
viewEnvironment: ViewEnvironment,
modifier: Modifier = Modifier
)
The Modifier
parameter is also provided as it is idiomatic for composable functions representing UI elements to do so, and allows the caller to control layout and virtually all aspects of the child rendering’s UI.
Here’s an example of how it could be used:
data class ContactScreen(
val name: String,
val details: Screen
): Screen
val contactUiFactory = ScreenComposableFactory<ContactScreen> { rendering, viewEnvironment ->
Column {
Text(rendering.name)
WorkflowRendering(
rendering.details,
viewEnvironment,
Modifier.fillMaxWidth()
)
}
}
Compose provides IDE support for previewing composables by annotating them with the @Preview
annotation. Because previews are composed in a special environment in the IDE itself, they often cannot rely on the external context around the composable being set up as it would normally in a full app. For Workflow integration, we provide support to write preview functions for UI factories.
We don’t technically need any special work to support this. However, lots of view factories nest other renderings’ factories, so preview functions need to provide some bindings in the ViewRegistry
to fake out those nested factories. To make this easier, we provide a composable function as an extension on Screen
that takes a rendering object for that factory and renders it, filling in visual placeholders for any calls to WorkflowRendering()
.
@Composable fun Screen.Preview(
modifier: Modifier = Modifier,
placeholderModifier: Modifier = Modifier,
viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null
)
This doesn’t just apply to composable UI!
You can call Preview()
on any Screen
that has been bound to UI code, regardless of how that UI code is implemented.
The function takes some additional optional parameters that allow customizing how placeholders are displayed, and lets you add more stuff to the ViewEnvironment
if your factory reads certain values that you’d like to control in the preview.
Here’s an example of a contact card UI that uses a nested WorkflowRendering
that is filled with a placeholder:
@Preview
@Composable fun ContactViewFactoryPreview() {
ContactScreen(
name = "Dim Tonnelly",
details = ContactDetailsRendering(
phoneNumber = "555-555-5555",
address = "1234 Apgar Lane"
)
).Preview()
}
Unlike the other apis described here, which all exist to allow individual Workflows’ renderings’ view factories to be defined as Compose code, this API is intended for use at the top of a purely-Workflow application (or anywhere that needs to host a Workflow runtime). Its non-Compose counterpart is the renderWorkflowIn
function. It is an extension function on a Workflow that mirrors other Composable APIs for subscribing to reactive state (e.g. Flow.collectAsState
, LiveData.observeAsState
).
@Composable
fun <PropsT, OutputT : Any, RenderingT>
Workflow<PropsT, OutputT, RenderingT>.renderAsState(
props: PropsT,
interceptors: List<WorkflowInterceptor> = emptyList(),
onOutput: suspend (OutputT) -> Unit
): State<RenderingT>
Its parameters roughly match those of renderWorkflowIn
: it takes the props for the root Workflow, an optional list of interceptors, and a suspending callback for processing the root Workflow’s outputs. It returns the root Workflow’s rendering value via a State
object (basically Compose’s analog to StateFlow
).
This function initializes and starts an instance of the Workflow runtime when it enters a composition. It uses the composition’s implicit coroutine context to host the runtime and execute the output callback. It automatically wires up Snapshot
saving and restoring using Compose’s SaveableStateRegistry
mechanism (ie using rememberSaveable
).
Because this function binds the Workflow runtime to the lifetime of the composition, it is best suited for use in apps that disable restarting activities for UI-related configuration changes (which really is the best way to build a Compose-first application). That said, because it automatically saves and restores the Workflow tree’s state via snapshots, it would still work in those cases, just not as efficiently.
Note that this function does not have anything to do with UI itself - it can even be placed in a module that has no dependencies on Compose UI artifacts and only the Compose runtime. If the root Workflow’s rendering needs to be displayed as Android UI, it can be easily done via the WorkflowRendering
composable function.
Here’s an example:
@Composable fun App(rootWorkflow: Workflow<...>) {
var rootProps by remember { mutableStateOf(...) }
val viewEnvironment = ...
val rootRendering by rootWorkflow.renderAsState(
props = rootProps
) { output ->
handleOutput(output)
}
WorkflowRendering(rootRendering, viewEnvironment)
}
Passing both the rendering and view environment down as parameters through the entire UI tree means that every time a rendering updates, we’ll recompose a lot of composables. This is how Workflow was designed, and because compose does some automatic deduping we’ll automatically avoid recomposing the leaves of the UI for a particular view factory unless the data for those bits of ui actually change. However, any time a leaf rendering changes, we’ll also be recomposing all the parent view factories just in order to propagate that leaf to its composable. That means we’re not able to take advantage of a lot of the other optimizations that compose tries to do both now and potentially in the future.
In other words: “Workflow+views” < “Workflow+compose” < “data model designed specifically for compose + compose”.
It should be straightforward to address this issue for view environments - see the Alternative design section for more information. However, it’s not clear how to solve this for renderings without abandoning our current rendering data model. Today, renderings are an immutable tree of immutable value types that require the entire tree to be recreated any time any single piece of data changes. The reason for this design is that it was the only way to safely propagate changes without adding a bunch of reactive streams to renderings everywhere. The key word in that sentence is “was”: Compose’s snapshot state system makes it possible to expose simple mutable properties and still get change notifications that will ensure that the UI stays up-to-date (For an example of how this system can be used to model complex state systems with dependencies, see this blog post).
Workflow could take advantage of this by allowing renderings to actually be mutable, so that when one Workflow deep in the tree wants to change something, it can do so independently and without requiring every rendering above it in the tree to also change. Making such a change to such a fundamental piece of Workflow design could have significant implications on other aspects of Workflow design, and doing so is very far outside the scope of this post.
We want to call this out because it seems like we’ll be losing out on one of Compose’s optimization tricks, but we’re not sure how much of a problem this will turn out to be in the real world. The only performance issues that we’re aware of that we’ve run into with Workflow UI so far are issues with recreating leaf views on every rerender, and that in particular is something Compose will automatically win at, even with our current data model.
You’ll notice that all the APIs described above explicitly pass ViewEnvironment
objects around. This mirrors how other Workflow UI code works, as well as the Mosaic integration. Compose has the concept of “composition local” — which is similar in spirit to ViewEnvironment
itself (and SwiftUI’s Environment
). So why not just pass view environments implicitly through composition locals?
This is what we did at first, but it made the API awkward for testing and other cases. Google advises against using composition locals in most cases for a reason. Because Workflow UI requires a ViewRegistry
to be provided through the ViewEnvironment
, there’s no obvious default value — what is the correct behavior when no ViewEnvironment
local has been specified? Crashing at runtime is not ideal. We could provide an empty ViewRegistry
, but that’s just another way to crash at runtime a few levels deeper in the call stack. Requiring explicit parameters for ViewEnvironment
solves all these problems at the expense of a little more typing, and matches how the existing ViewFactory
APIs work.
On the other hand, providing an API to access individual view environment elements from a composable that hides the actual mechanism and uses composition locals under the hood would let us take much better advantage of Compose’s fine-grained UI updates. We could ensure that, when a view environment changes, only the parts of the UI that actually care about the modified part of the environment are recomposed. However, renderings typically change an order of magnitude more frequently than view environments, so there’s probably not much point solving this problem until we’ve solved the same problem with renderings (discussed above under Potential risk: Data model).