Jetpack Compose Support in Workflow
A deep, deep dive into making Square Workflow @Composable
Abstract
Square’s mobile Point of Sale apps are largely built using a library called Workflow, on both Android and iOS. We write business logic as declarative state machines, and then build our UI on top of them using the target platform’s UI toolkit — Views on Android.
In the spring of 2020, we started exploring the possibility of adopting Jetpack Compose for our view layer. We started with two questions:
- How could we take advantage of Compose’s unique syntactic features to make our APIs more ergonomic and idiomatic to Compose developers?
- What would we need to do to ensure that, at worst, our infrastructure doesn’t break the underlying mechanics and mental model of Compose, and at best, fit with the mechanics and mental model of Workflow so tightly that using the two together feels natural and unsurprising?
We answer these questions in this post, and provide a roadmap for what Compose support in Workflow will look like in the near future.
This post is not an introduction to Workflow, or to Compose — it assumes knowledge of basic concepts and terms from both, terms like ViewFactory
, “workflow rendering”, WorkflowViewStub
, rememberSaveable
, etc.). But if you’re a jump-into-the-deep-end sort, you’ll find a lot to chew on here. In particular, we go into a lot of depth on Compose’s CompositionLocal
scheme, and the View
persistence lifecycle (both the old View.onSaveInstanceState
and the new Androidx SavedStateRegistry
).
Motivation
If you’ve considered adopting Compose in your own app, the motivation for doing so might seem obvious. But we had some very specific reasons for starting this project.
First, we wanted to future-proof our infrastructure. Not to put too fine a point on it, Jetpack Compose is the future. This post was written the day after Compose 1.0 was released. While classic Android Views aren’t going anywhere, Google is investing a lot in Compose. In addition to being the new official UI toolkit for Android, it is just subjectively so much better to work with than the old toolkit.
Given this inevitability, it was clear that we needed to enable our developers to build apps with it. Just before the whole Compose thing got off the ground, we had independently started building our own declarative UI library, which we’ve called Mosaic, using classic Views under the hood. It turns out this is a lot of work, and when the first roadmap to Compose 1.0 was announced we realized that it didn’t pay to continue to invest in building something similar, but less capable, in parallel. We were also in the middle of building out our new design system, and decided to start supporting Compose even before it hit the 1.0 release.
The other reason is that Workflow, or more specifically Workflow UI, was designed from day one to separate navigation and business logic from the view layer, just like all the popular architectural acronyms: MVI, MVVM, etc. Workflows communicate with the view layer by publishing immutable values that contain data to be rendered. They are strongly-typed, and know nothing of the view layer itself.
Data flows through workflows in a single direction — they enforce a unidirectional data flow architecture. Feature code provides ViewFactory
bindings that define how to create and update views for particular Workflows’ rendering values. Workflows form a tree, and their rendering values typically form a parallel tree. Every time something changes, the view layer gets a new tree of immutable rendering values.
Workflows assume that the view layer is smart enough to diff changes to its view models, and probably that it uses some sort of declarative toolkit. When we made that decision our declarative Mosaic library was in its infancy, but we already had Blueprint, something similar and much more mature on the iOS side of the fence, and other companies had developed their own variations of this idea, so it seemed like a safe bet.
Unfortunately, internal adoption of Mosaic could only happen so fast. Many developers still ended up writing view code with XML layouts and working with the view APIs directly. This meant they were also responsible for manually diffing changes to view data. This assumption was not communicated very well, and often developers didn’t realize it was a requirement. Views ended up being very inefficient in many cases.
Even if we didn’t know it at the time, we had designed Workflow for a Compose world. It was time for Workflow to fulfill its destiny.
Timeline and Process
Compose entered beta in the first half of 2020. Since we were all locked in our homes with no social lives, it was the perfect time to start exploring what integration between Compose and Workflows would look like. This was very experimental work — Compose APIs were changing drastically every two weeks. To say the least, it was not “ready for production.” However, it was important to suss out what sort of integration points were available to us, what API shapes felt natural, and where the rough edges were. In addition to figuring out our own adoption story, we have also been able to contribute a lot of feedback to Google (see our case study), and some of the features we initially wrote specifically for workflow integration ended up making it into the library (e.g. automatic subcomposition linking in child View
s).
Goals and Non-Goals
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.
Goals
- 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.
Non-Goals
- Convert existing screens in our apps to Compose.
- Provide design system components in Compose. (This is planned, but as a separate project that depends on this one.)
- Anything with our own internal declarative UI toolkit, Mosaic (sunsetting it, integrating with it, or otherwise).
Major Components
There are a few major areas this project needs to focus on to support Compose from Workflows: navigation, ViewFactory
support, and hosting.
Navigation support
Workflow isn’t just a state management library — Workflow UI includes navigation containers for things like backstacks and 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
,ViewTreeSavedStateRegistry
, 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 ViewTreeSavedStateRegistry
, 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 container already manages 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.
Lifecycle
For LifecycleOwner
support, we need to think of anything that can ask the ViewRegistry
for 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 composition will be disposed.
We 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.
For more information about how this is implemented, see the design issue on GitHub.
SavedStateRegistry
SavedStateRegistry
is a bit more complicated because of the sequencing of lifecycle and “view instance state” calls with the SavedStateRegistry
ones. Luckily, unlike LifecycleOwner
s, not every container needs to provide a valid SavedStateRegistryOwner
— only containers that manage a backstack and need to have explicit control over saving and restoring state (i.e. BackStackContainer
) need to be concerned with this.
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 default EditText
values. An ID should be set.
View
is added as a child of a ViewGroup
and attached to a window.
After the hosting Activity
moves to the STARTED
state, onRestoreInstanceState
is called for every view in the hierarchy (even the View
’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 to onSaveInstanceState
. 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 the ViewTree*Owner
functions.
View
is eventually added to a ViewGroup
and attached to the window. Now the view has a parent, so the onAttached
callback can search up the tree for the ViewTreeLifecycleOwner
. It also looks for the ViewTreeSavedStateRegistryOwner
— 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
, the SavedStateRegistry
can be queried. The view’s initialization logic can now call consumeRestoredStateForKey
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 a ViewTreeSavedStateRegistry 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.
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.
Because the SavedStateRegistry
contract says that it must be consumable as soon as the lifecycle is in the CREATED
state, containers must also ensure that they don’t move to that state until they’ve had a chance to actually seed the registry with restored data.
For more information about how this is implemented, see the design issue on GitHub.
ViewFactory
support
Again, Workflow UI is built around the ViewFactory
interface, functions that build and update classic Android View instances for each type of view model rendered by a Workflow tree. Because Compose supports seamless integration from and to the classic Android View
world, technically we don’t really need to do anything to allow people to write Compose code inside ViewFactory
s, at least to get 90% support. However, by providing some more convenient APIs, we not only remove some boilerplate, but also create the opportunity for some simplifications. There are also some edge cases that require a little more effort that we actually do need to build support into Workflows for.
Each View instantiated by a ViewFactory
is managed by an implementation of the LayoutRunner
interface. We could make a similar interface for Compose-based factories, but since Composables are just functions, we don’t even need an interface. Compose-based ViewFactory
s will all share a common supertype, and share the same wiring logic. This logic will encapsulate the correct wiring of AbstractComposeView
s into the Workflow-managed view hierarchy, as well as wiring up the binding so that rendering changes are correctly propagated into the composition. (The detailed API for this is covered under API Design, below.)
We will also provide a construction analogous to WorkflowViewStub
to allow Compose-based factories to idiomatically display child renderings.
The above two concepts coordinate, and 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.
Compose has a mechanism for sharing data implicitly between different composables that call each other. They’re called “composition locals”. A composable can “provide” a value for a given CompositionLocal
(or “local” for short) for a particular subtree of composables underneath it. Locals always flow down the tree. They are type-safe. Each is defined by a global property that provides a process-global “key” for the local, associates it with the type of value it can hold, and the default value if a composable tries reading it before any value has been provided.
Within a composition, even if that composition includes subcompositions, these locals flow seamlessly down the composition from parents to children. However, they also flow correctly down the tree if a composition includes an embedded AndroidView
that in turns embeds another composition. Compose sets a view tag on Android View
s hosted in compositions with a special value that will be read by child AbstractComposeView
s to link the compositions and ensure locals continue to flow. This means that for most cases Workflow doesn’t need to do anything special to make this work.
Compose didn’t always link compositions in a view tree automatically. Until around late 2020, the Workflow infrastructure had to pass this composition link through its analagous
ViewEnvironment
, which prevented anyViewFactory
from usingAndroidView
orAbstractComposeView
itself. We submitted a feature request to move this behavior into the core library. Fortunately, it got accepted, and now all Compose/Android view integrations do this automatically. This is a great example of why this early experimentation was very helpful.
However, because the Workflow modal infrastructure manages independent view trees (each Dialog
hosts its own view tree), we need to make sure that compositions hosted inside modals are created as child compositions of any compositions enclosing the ModalContainer
. This is one feature that has not yet been implemented in the experimental integration project, because the automatic linking of compositions is fairly recent. The proposed solution is described in the Linking modal compositions section below.
Hosting
Hosting a Workflow runtime from a composition is not very interesting as far as our internal apps are concerned, because we have a few other layers of infrastructure at the root of our apps. For our use cases, we’re only allowing Compose to be used inside of the ViewFactory
constructions specified above, so we don’t need to worry about how to host a Workflow runtime inside a composition for now. However, it is exciting to think about using Workflows in an app that is fully Compose-based, and even if we don’t use it internally, it may be useful for external consumers of the library. Details of the hosting API are specified in the API Design section below.
API Design
The following APIs will be packaged into two Maven artifacts. Most of them will live in a core “Workflow-compose” module, and the preview tooling support will live in a “Workflow-compose-tooling” module.
Alternatively, it may also make sense to split the runtime/hosting APIs into a third module, since the main Workflow modules are split by core/runtime, and most Workflow code doesn’t need runtime stuff. The actual runtime code added for Compose support is quite small, but requires a transitive dependency on the Workflow-runtime module, so splitting the compose modules in kind would keep the transitive deps of non-runtime consumers cleaner.
Core APIs
Defining Compose-based ViewFactory
s
inline fun <reified RenderingT : Any> composedViewFactory(
noinline content: @Composable (
rendering: RenderingT,
environment: ViewEnvironment
) -> Unit
): ViewFactory<RenderingT>
This is the primary API that most feature developers will touch when combining Workflow and Compose. It’s a single builder function that takes a composable lambda that emits the UI for the given rendering type. 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 it could be used:
val contactFactory = composedViewFactory { rendering, _ ->
Column {
Text(rendering.name)
Text(rendering.phoneNumber)
}
}
This inline function creates an instance of a special concrete ViewFactory
type. This type is currently internal-only, but it may make sense to make it public to allow creating such view factories via subclassing to allow Dagger injection. Such a class would simply look like this:
abstract class ComposeViewFactory<RenderingT : Any> : ViewFactory<RenderingT> {
@Composable abstract fun Content(
rendering: RenderingT,
viewEnvironment: ViewEnvironment
)
final override fun buildView(...) = ...
}
and it would be used by subclassing it:
class ContactFactory @Inject constructor(
private val …
) : ComposeViewFactory() {
@Composable override fun Content(
rendering: RenderingT,
viewEnvironment: ViewEnvironment
) {
Column {
…
}
}
}
As a ViewFactory
, this class will basically just return a ComposeView
that is configured to show renderings. But using a special class for all these factories, the WorkflowRendering
composable discussed below can just bypass the step of creating an Android view and hosting it in the calling composition, and just invoke the ComposeViewFactory
’s Content
function directly.
Delegating to a child ViewFactory
from a composition
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 displays the rendering from the environment’s ViewRegistry
.
@Composable fun WorkflowRendering(
rendering: Any,
viewEnvironment: ViewEnvironment,
modifier: Modifier = Modifier
)
It takes a Modifier
parameter, as well-behaved and idiomatic Compose components should, to allow 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:
val contactFactory = composedViewFactory { rendering, viewEnvironment ->
Column {
Text(rendering.name)
WorkflowRendering(
rendering.details,
viewEnvironment,
Modifier.fillMaxWidth()
)
}
}
Previewing Compose-based ViewFactory
s
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, it would be nice to be able to write preview functions for view factories.
This use case doesn’t just apply to composable view factories! Because Workflow Compose supports mixing Android and Compose factories, we can preview any ViewFactory
, which means we could even use it to preview classic Android view factories such as LayoutRunner
s.
We don’t technically need any special work to let you preview view factories. However, lots of view factories nest other renderings’ factories, so preview functions need to provide some bindings in the ViewRegistry
, via the ViewEnvironment
, to fake out those nested factories. To make this easier, we provide a composable function as an extension on ViewFactory
that takes a rendering object for that factory and renders it, filling in visual placeholders for any calls to WorkflowRendering
.
@Composable fun <RenderingT : Any> ViewFactory<RenderingT>.Preview(
rendering: RenderingT,
// Modifier applied to the entire ViewFactory content.
modifier: Modifier = Modifier,
// Modifier applied to the content of each nested child rendering,
// when rendered using WorkflowRendering.
placeholderModifier: Modifier = Modifier,
viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null
)
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.
We can also provide a version of this method that’s an extension on AndroidViewRendering
so you can @Preview
your renderings!
Here’s an example of the contact card UI from above that uses a nested WorkflowRendering
:
@Preview
@Composable fun ContactViewFactoryPreview() {
contactViewFactory.Preview(
ContactRendering(
name = "Dim Tonnelly",
details = ContactDetailsRendering(
phoneNumber = "555-555-5555",
address = "1234 Apgar Lane"
)
)
)
}
As you can see in this screenshot, the child rendering is displayed using an obnoxious placeholder that just shows the child rendering’s toString()
representation:
Hosting a Workflow runtime from a composition
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>
It’s 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 (which, if you’re not familiar, is basically Compose’s analog to BehaviorRelay
).
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 (i.e. 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. 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 could even be distributed in an artifact that has dependencies only on the Compose runtime and not on Compose UI. 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 = remember { ... }
val rootRendering by rootWorkflow.renderAsState(
props = rootProps
) { output ->
handleOutput(output)
}
WorkflowRendering(rootRendering, viewEnvironment)
}
Inline composable renderings
In Workflow UI Android, rendering types can implement the AndroidViewRendering
interface to specify their own view factories directly, instead of requiring their view factories to be registered explicitly in the ViewRegistry
.
This feature presents an interesting potential construct for Compose integration: workflows that are defined as composable functions which emit their own UI directly instead of going through the render —> rendering —> ViewFactory
steps. Here’s what an API for defining such workflows could look like:
abstract class ComposeWorkflow<in PropsT, out OutputT : Any> :
Workflow<PropsT, OutputT, ComposeRendering> {
@Composable abstract fun render(
props: PropsT,
outputSink: Sink<OutputT>,
viewEnvironment: ViewEnvironment
)
}
class ComposeRendering : AndroidViewRendering<ComposeRendering>
This render method takes a PropsT
just like a traditional workflow, but that’s where the similarities end. It doesn’t get any state value (but that doesn’t mean it is stateless - see below). It does not get a RenderContext
, which means it cannot render child Workflows or run workers. It can however still delegate to other view factories via the WorkflowRendering
composable. It does get access to a Sink
, although it’s not the usual actionSink
- it does not accept arbitrary WorkflowAction
s, because it doesn’t need to due to the lack of workflow state. The sink simply accepts OutputT
values directly, which are effectively all “rendering events”. The render method gets called not as part of the workflow render pass but rather as part of the view update pass that occurs once the workflow runtime has emitted a new rendering tree. This is why it can’t render child Workflows - it gets invoked too late in the pipeline. Its rendering type is an opaque, final concrete class that has only one possible use: to be rendered via a WorkflowViewStub
or the WorkflowRendering
composable.
Such a workflow may be stateful, although not in the usual sense: it does not actually store any state in the workflow tree itself. Instead, it can use Compose’s memoization facility (ie the remember
function) to store “view” state in the composition, or perhaps even the multiple compositions, into which it’s composed.
The distinction that any state managed by workflows defined this way is “view state” is important. While it might look like workflow state because it’s inlined into the definition of the workflow itself, such state is owned by the view layer and not the workflow layer. Consider that a single Workflow rendering can potentially be displayed multiple times in different places in the UI - in which case any state required by the rendering’s UI layer will be created and managed separately by each occurrence.
Similarly, while such workflows cannot run Workers or workflow side effects, they may perform long-running and potentially concurrent tasks that are scoped to their composition by using the standard Compose effect APIs, just like any composable.
These workflows do not define their own rendering types, and thus do not have anywhere to define rendering event handler functions. Instead, they can send outputs to their parent workflows directly from composable event callbacks via the outputSink
parameter.
These workflows can only be leaf workflows since they can’t render children. However, they may be very convenient in modules that already mix their Workflow
and ViewFactory
definitions in the same module and want to factor out workflows for self-contained components.
Here’s an example of how it could be used:
// Child Workflow
object ContactWorkflow : ComposeWorkflow<
Contact, // PropsT
Output // OutputT
>() {
enum class Output {
CLICKED, DELETED
}
@Composable override fun render(
props: Contact,
outputSink: Sink<Clicked>,
viewEnvironment: ViewEnvironment
) {
ListItem(
primary = { Text(props.name) },
secondary = { Text(props.phoneNumber) },
modifier = Modifier
.clickable { outputSink.send(CLICKED) }
.swipeToDismissable { outputSink.send(DELETED) }
)
}
}
// Parent Workflow
class ContactList : StatelessWorkflow<...> {
override fun initialState(...) = ...
override fun render(...) = ListRendering(
contactRows = props.contacts.map { contact ->
context.renderChild(
props = contact,
Workflow = ContactWorkflow
) { output -> ... }
}
)
}
Potential risk: Data model
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.
Alternative design: Propagating ViewEnvironment
s through CompositionLocal
s
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).
Conclusion
While we feel confident these APIs are a good direction to take the library, we have not used any of them in anger or at scale yet. They are a first draft, if you will. As with all software, it’s likely we may need to iterate on them to improve functionality, fix bugs, or improve ergonomics. This is a natural part of the software engineering process.
Square has invested a lot in adopting Workflow for our Point of Sale apps, helping us scale our already-large codebase as it’s grown significantly over the last few years. With Jetpack Compose finally released as stable, we have also been investing a lot in preparing to adopt it for our new design system.
One of the design goals of Workflow was to give us the flexibility to easily swap out our view layer implementations, and it is very exciting to see these two independent investments come together so nicely at last. Workflow’s strict separation of view code from business logic has prepared us for incremental compose adoption that feels natural. And because workflow lets us write our business logic in a mostly declarative way, we’ve already climbed one of the steepest parts of the Compose learning curve.
Further Reading
- Jetpack Compose: Missing piece to the MVI puzzle?, Garima Jain’s post that discusses some related concepts in a more abstract sense.
- Compose in your existing architecture, the official Compose documentation page acknowledging that UDF architectures are ideal for adopting Compose.
- square/workflow GitHub issue for this design, including more details and changes since this post was published.