Workflow for Kotlin and Swift

repository·main·Indexed 22 days ago

https://github.com/square/workflow-kotlin

An application framework providing architectural primitives for building scalable, composable applications using unidirectional data flow and state machines. It features immutable data, UI binding for Android and iOS, and a dedicated testing framework. The library includes support for Jetpack Compose and provides various artifacts for core JVM logic, RxJava2 integration, and Android UI.

Tokens
17.9K
Snippets
47
Records
63
Agent score
78%

What's inside workflow-kotlin

  1. What is Workflow?

    main

    Workflow is an application framework providing architectural primitives for Kotlin and Swift. It is designed as a unidirectional data flow library that uses immutable data.

    Key characteristics include:

    • Unidirectional Data Flow: Data flows from source to UI, and events flow from UI to business logic.
    • State Machine Model: Business logic and complex UI navigation are written as state machines, enabling reasoning about state and correctness.
    • Composability: Optimized for scaling features and screens.
    • UI Binding: Provides frameworks to bind Rendering data classes (views and event callbacks) to Android and iOS mobile UI frameworks.
    • Testing: Includes a testing framework for unit testing application business logic.
  2. Understand the Dungeon Crawler Sample architecture

    main

    The Dungeon Crawler Sample demonstrates how to structure a complex game using workflows. The architecture is split into two main parts:

    1. common module: Contains the core game logic.
    2. app module: Contains an Android application that runs the game.

    Core Logic Model

    • GameWorkflow: The root workflow that manages the overall game state, tracks locations, and detects collisions.
    • Actor Workflows: Every AI and player actor is implemented as a child workflow of GameWorkflow.
    • Actor Behavior: Each actor workflow is responsible for rendering its own avatar and outputting movement directions.
    • Movement Cadence: Actor workflows inject a GameTicker to perform movement at a regular cadence.
    • State Updates: GameWorkflow collects movement events from all child actor workflows to update the global game state.
  3. Define and manage state in a StatefulWorkflow

    main

    A StatefulWorkflow uses a StateT parameter type to represent its internal state. This state should contain all the data for which the workflow is responsible, typically corresponding to the UI state. You define the initial state by overriding the initialState method.

    To ensure the workflow is reactive, the state is managed by the workflow infrastructure. You do not modify the state directly within the render function; instead, you trigger state transitions using Actions.

    object WelcomeWorkflow : StatefulWorkflow<Unit, State, Output, Screen>() {
    
      data class State(
        val prompt: String
      )
    
      override fun initialState(
        props: Unit,
        snapshot: Snapshot?
      ): State = State(prompt = "Hello Workflow!")
    
      override fun render(
        renderProps: Unit,
        renderState: State,
        context: RenderContext<Unit, State, Output>
      ): WelcomeScreen = WelcomeScreen(
        promptText = renderState.prompt,
        onLogInTapped = {}
      )
    }
  4. Understand the relationship between Screens, ViewFactories, and ViewRunners

    main

    In Workflow, a Screen is a value type (typically a data class) that acts as a view model for a logical screen. It contains the data and event handlers needed to drive a UI, decoupled from platform-specific concerns.

    To render a Screen on Android using classic Views, you must provide a ScreenViewFactory. This factory handles two responsibilities:

    1. Inflation: Creating the View (e.g., via View Binding).
    2. Updating: Providing a ScreenViewRunner to update the View whenever the Screen state changes.

    Workflow provides two specialized interfaces for Android:

    • AndroidScreen: For classic Android Views.
    • ComposeScreen: For Jetpack Compose @Composable functions.
    /**
     * A Screen represents the view model.
     */
    data class WelcomeScreen(
      val promptText: String,
      val onLogInTapped: (String) -> Unit
    ) : AndroidScreen<WelcomeScreen> {
    
      /**
       * The factory defines how to inflate the view and which runner to use.
       */
      override val viewFactory =
        ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner)
    }
    
    /**
     * The runner is called once for inflation, and its update lambda 
     * is called every time the UI needs to be refreshed.
     */
    private fun welcomeScreenRunner(
      viewBinding: WelcomeViewBinding
    ) = ScreenViewRunner<WelcomeScreen> { screen: WelcomeScreen, _ ->
      viewBinding.prompt.text = screen.promptText
      viewBinding.logIn.setOnClickListener { 
        screen.onLogInTapped(viewBinding.username.text.toString()) 
      }
    }
  5. Manage workflow steps using sealed interfaces

    main

    To handle different UI states within a single workflow (e.g., switching between a 'List' view and an 'Edit' view), use a Step sealed interface within the workflow's State. This allows the render method to use a when expression to decide which screens to return based on the current step.

    object TodoListWorkflow : StatefulWorkflow<ListProps, State, Back, List<Screen>>() {
      data class State(
        val todos: List<TodoModel>,
        val step: Step
      )
    
      sealed interface Step {
        object ShowList : Step
        data class EditItem(val index: Int) : Step
      }
    
      override fun render(
        renderProps: ListProps,
        renderState: State,
        context: RenderContext
      ): List<Screen> {
        return when (val step = renderState.step) {
          is Step.ShowList -> listOf(todoListScreen)
          is Step.EditItem -> listOf(todoListScreen, todoEditScreen)
        }
      }
    }
  6. How WorkflowNode manages state and lifecycle

    main

    A WorkflowNode acts as the core state machine host for a specific part of the workflow tree. It manages several critical responsibilities:

    • State & Cache: Manages per-node state, a remember cache, and side effects using an ActiveStagingList.
    • Child Management: Uses a SubtreeManager to handle child subtrees.
    • Dirty Tracking: Tracks selfStateDirty and subtreeStateDirty to enable partial tree rendering (when PARTIAL_TREE_RENDERING is enabled).
    • Commit Phase: After every render, a commit phase occurs where:
      • subtreeManager.commitRenderedChildren() is called.
      • Staged side effect jobs are started.
      • Obsolete side effects are cancelled.
      • Remembered entries are committed.
  7. Pass data to child workflows using Props

    main

    Workflows communicate downwards via "props". Every workflow has a PropsT parameter (the first type parameter in StatefulWorkflow). Props are read-only and owned by the parent.

    When a parent calls context.renderChild(ChildWorkflow, props), the provided props are passed to the child in:

    • initialState: The first time the child is created.
    • onPropsChanged: Called when the parent provides a new, unequal props value.
    • render: The current props used for the current render pass.
    • WorkflowAction.apply: The last props used when an action is applied.

    Think of props as the "public" part of a workflow's state.

    // Child Workflow receiving props
    object TodoListWorkflow : StatefulWorkflow<ListProps, State, Nothing, Screen>() {
      data class ListProps(val username: String)
    
      override fun render(
        renderProps: ListProps,
        renderState: State,
        context: RenderContext<ListProps, State, Nothing>
      ): TodoListScreen = TodoListScreen(
        username = renderProps.username,
        // ...
      )
    }
    
    // Parent Workflow passing props
    val todoScreen = context.renderChild(
      child = TodoListWorkflow,
      props = ListProps(username = renderState.username)
    )
  8. Refactor workflows by splitting into parent and child

    main

    When a single workflow handles too many concerns (e.g., managing both UI behavior and complex navigation logic), you can refactor it by extracting responsibilities into a parent workflow.

    Pattern:

    1. Create a Parent Workflow: A new StatefulWorkflow (e.g., TodoNavigationWorkflow) that owns the shared state and manages the lifecycle of multiple child workflows.
    2. Convert Children to Stateless: If a child workflow no longer needs to manage its own state and instead receives data via Props, convert it from StatefulWorkflow to StatelessWorkflow.
    3. Communicate via Outputs: The child workflow should emit Output events (e.g., TodoSelected, BackPressed) using setOutput(). The parent workflow captures these outputs within the context.renderChild handler to trigger state changes or navigation actions.
    // Parent managing children
    val todoListScreen = context.renderChild(
      TodoListWorkflow,
      props = ListProps(username = "user", todos = renderState.todos)
    ) { output -> 
      when (output) {
        is TodoSelected -> editTodo(output.index)
        Output.BackPressed -> goBack()
      }
    }
  9. Understand Workflow runtime correctness constraints

    main

    When implementing or extending the workflow runtime, the following semantic constraints must be preserved to ensure backward compatibility and correctness:

    • Child lifecycle semantics: Retain existing children on identity match; cancel dropped children on commit.
    • Deterministic ordering: Maintain the deterministic active ordering used by action traversal.
    • Duplicate-key behavior: Maintain parity in duplicate-key failure behavior and error messages.
    • remember identity semantics: Identity must be based on the tuple (key, resultType, inputs).
    • Side effect semantics: Side effects must start after render, be retained by key, and be cancelled when not rendered.
  10. How child reconciliation works via SubtreeManager

    main

    The SubtreeManager is responsible for the lifecycle of child nodes (rendering, reuse, and teardown).

    When renderChild is called:

    1. Uniqueness Check: The runtime scans the staging collection (forEachStaging) to validate that sibling keys are unique.
    2. Retention/Creation: It attempts to retainOrCreate a child by searching the active collection. If a match is found, the existing node is reused; otherwise, a new one is created.
    3. Update: The handler is updated and the child is rendered.

    On the commit phase, any children that were present in the old active list but are no longer present are cancelled.

  11. Compose workflows using child workflows

    main

    You can build complex navigation structures by composing multiple workflows. A parent workflow can render child workflows using context.renderChild(). When renderChild is called, the infrastructure starts a child workflow session if one is not already running.

    To render a child workflow that has no output, use context.renderChild(ChildWorkflow) without a trailing lambda. If the child workflow has an output type OutputT, the parent must provide a lambda to map that output into a WorkflowAction (e.g., updating the parent's state).

    // Parent rendering a child with output
    val welcomeScreen = context.renderChild(WelcomeWorkflow) { output ->
      // Map child output to parent action
      logIn(output.username)
    }
    return welcomeScreen
  12. Measure rendering performance with RenderPassTest

    main

    The RenderPassTest in the performance-poetry module measures the efficiency of the rendering pipeline by tracking two metrics:

    1. Number of Render Passes: The total count of render passes triggered by a specific scenario (e.g., the Raven scenario).
    2. Rendering Ratio: The ratio of 'fresh renderings' to 'stale renderings'.
      • A rendering is fresh if the node's state has changed.
      • A rendering is stale if its state remains the same.

    Workflow is designed for cheap, idempotent renderings, so the fresh rendering ratio will not be 1.0 by design. A poor ratio indicates 'render churn' that should be tracked and optimized.