跳到内容

编写 Workflow UI

本页将 Workflow UI 的高级讨论转化为 Android 和 iOS 代码。

关注点分离

Workflow 在其核心运行时和 UI 支持之间保持严格分离。Workflow 核心模块严格基于 Swift 和 Kotlin,不依赖于任何 UI 框架。对 Android 和 iOS 的依赖仅限于 Workflow UI 模块,正如你所期望的那样。这种固有的分离自然而然地引导开发者避免将视图关注点与他们的应用逻辑纠缠在一起。

值得注意的是,我们说的是“应用逻辑”,而不是“业务逻辑”。在任何有趣的应用中,管理导航和其他与 UI 相关行为的代码在规模和复杂性上都可能超过我们通常认为是模型关注点的代码。

我们都很擅长将业务关注点封装在整洁的面向对象模型中,比如待售商品、购物车、支付卡等等,与 UI 世界很好地解耦。但是应用的其余部分,特别是关于用户如何导航的部分呢?传统上很难将这些特定于应用的逻辑集中起来,以便你了解正在发生的事情;更难将其与视图系统解耦,以便轻松测试。Workflow UI 和 Workflow 核心之间严格的划分会意外地引导你维护这种分离。

引导启动

以下代码片段演示了如何使用 Workflow 驱动 iOS 和 Android 应用的根视图。但实际上,你可以在任何可以显示视图的地方托管由 Workflow 驱动的 UI,无论“视图”在你的平台上意味着什么。

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)

        window?.rootViewController = ContainerViewController(workflow: RootWorkflow())

        window?.makeKeyAndVisible()

        return true
    }
}

Android 经典版让事情有点复杂(很自然),因为你的 Workflow 运行时必须在配置更改中存活下来。我们的习惯是使用 Jetpack ViewModel 来解决这个问题,这通常是 Workflow 应用中唯一处理 Jetpack Lifecycle 的一行代码。

HelloWorkflowActivity.kt
class HelloWorkflowActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // This ViewModel will survive configuration changes. It's instantiated
    // by the first call to androidx.activity.viewModels(), and that
    // original instance is returned by succeeding calls.
    val model: HelloViewModel by viewModels()
    setContentView(
      WorkflowLayout(this).apply { take(lifecycle, model.renderings) }
    )
  }
}

class HelloViewModel(savedState: SavedStateHandle) : ViewModel() {
  val renderings: StateFlow<HelloRendering> by lazy {
    renderWorkflowIn(
      workflow = HelloWorkflow,
      scope = viewModelScope,
      savedStateHandle = savedState
    )
  }
}
HelloComposeActivity.kt
class HelloComposeActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      val rendering by HelloWorkflow.renderAsState(props = Unit, onOutput = {})
      WorkflowRendering(rendering, ViewEnvironment.EMPTY)
    }
  }
}

Android 开发者应该注意,经典和 Compose 的引导启动是完全可互换的。每种风格都能够显示任何类型的 Screens,无论它们是设置为膨胀 View 实例还是运行 @Composeable 函数。

从 Screens 构建视图

你好,Screen 世界。

WelcomeScreen.swift
struct WelcomeScreen: Screen {
    var name: String
    var onNameChanged: (String) -> Void
    var onLoginTapped: () -> Void

    func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
        return WelcomeViewController.description(for: self, environment: environment)
    }
}

private final class WelcomeViewController: ScreenViewController<WelcomeScreen> {
    override func viewDidLoad() {  }
    override func viewDidLayoutSubviews() {  }

    override func screenDidChange(from previousScreen: WelcomeScreen, previousEnvironment: ViewEnvironment) {
        super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment)

        nameField.text = screen.name
    }
}

iOS Screen 类需要提供匹配的 ViewControllerDescription 实例。一个 ViewControllerDescription 可以按需构建一个 UIViewController,或者如果被 ViewControllerDescription.canUpdate(UIViewController) 识别,则更新现有实例。

这些职责都由提供的 open class ScreenViewController 完成。它就像任何其他 ViewController 一样,并额外提供了:

  • 一个 open screenDidChange() 方法,Workflow UI 运行时会用指定类型的一系列 Screen 实例调用它
  • 一个 description() 类方法,非常适合从 Screen.viewcontrollerDescription() 调用
HelloScreen.kt
data class HelloScreen(
  val message: String,
  val onClick: () -> Unit
) : AndroidScreen<HelloScreen> {
  override val viewFactory: ScreenViewFactory<HelloScreen> =
    fromViewBinding(HelloViewBinding::inflate) { helloScreen, viewEnvironment ->
      helloMessage.text = helloScreen.message
      helloMessage.setOnClickListener { helloScreen.onClick() }
    }
}

Android Screen 接口纯粹是一个标记类型。它没有定义任何 Android 特定的方法,以确保你可以选择保持应用逻辑的纯粹性。如果你不需要那种严格性,如果你的 Screen 渲染实现 AndroidScreen,那么生活会更简单(也更安全,没有运行时错误)。

一个 AndroidScreen 需要提供匹配的 ScreenViewFactoryScreenViewFactory 返回包装在 ScreenViewHolder 对象中的 View 实例。ScreenViewHolder.show 由 Workflow UI 运行时调用,以使用 ScreenViewHolder.canShow 认为可接受的 Screen 实例更新视图。

在此示例中,fromViewBinding 函数创建了一个 ScreenViewFactory,它使用 Jetpack View BindingHelloViewBinding,大概派生自 hello_view_binding.xml)构建 View 实例。fromViewBinding 的 lambda 参数提供了 ScreenViewHolder.show 的实现,并保证给定的 helloScreen 参数是适当的类型。

提供了其他工厂函数,可以直接使用布局资源,或者完全从代码构建视图。

HelloScreen.kt
data class HelloScreen(
  val message: String,
  val onClick: () -> Unit
) : ComposeScreen<HelloScreen> {
    @Composable override fun Content(viewEnvironment: ViewEnvironment) {
      Button(onClick) {
        Text(message)
      }
    }
}

这里,HelloScreen 正在实现 ComposeScreenComposeScreen 扩展了用于经典 Android 的同一个 AndroidScreen 类,定义了 @Composable fun Content() 来完成其工作。Content 总是从 @Composable Box() 上下文调用。

它是上下文感知的

即使 AndroidScreen 提供了一个名为 ScreenViewFactory 的东西来完成其工作,ComposeScreen 构建的工厂也能识别它们是从经典 View 还是从 @Composeable 函数调用,并做正确的事情。Workflow UI 只在需要时创建 ComposeView 实例:当需要在 View 中显示 @Composeable 时。如果工厂要在 @Composable 上下文中使用,则直接调用 Content()

你承诺的“关注点分离”在哪里?

尽管上面大声宣扬了关注点分离,但上面的代码示例看起来可能相当纠缠不清。这是因为,虽然 Workflow 库本身是完全解耦的,但它们并不强制你的应用代码也必须严格如此。

比如说,如果你不是在构建一个核心 Workflow 模块,并且希望将其与 Android 和命令行接口分开分发,那么强制分离除了带来样板代码和运行时错误之外,可能没有任何好处。实际上,你的 Workflow 单元测试不会调用 viewFactory,并且在 JVM 上构建和运行得很好。同样,到目前为止,我们以这种方式构建应用已有数百个工程年了,并且至今没有人调用 viewControllerDescription() 并将 UIViewController 存储在他们的工作流状态中。(这不是一个挑战。)

如果你是少数真正需要在核心模块和 UI 模块之间建立不可渗透边界的人,实现这一点并不难。你的 Screen 实现可以与其视图代码完全分开定义,稍后绑定。

WelcomeScreen.swift
struct WelcomeScreen {
    var name: String
    var onNameChanged: (String) -> Void
    var onLoginTapped: () -> Void
}
WelcomeViewController.swift
extension WelcomeScreen: Screen {
    func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
        return WelcomeViewController.description(for: self, environment: environment)
    }
}

private final class WelcomeViewController: ScreenViewController<WelcomeScreen> {
    // ...

HelloScreen.kt
data class HelloScreen(
    val message: String,
    val onClick: () -> Unit
) : Screen
HelloWorkflowGreenTheme.kt
private object HelloScreenGreenThemeViewFactory: ScreenViewFactory<HelloScreen>
by ScreenViewFactory.fromViewBinding(GreenHelloViewBinding::inflate) { r, _ ->
      helloMessage.text = r.message
      helloMessage.setOnClickListener { r.onClick() }
    }
}
private val viewRegistry = ViewRegistry(HelloScreenGreenThemeViewFactory)

val HelloWorkflowGreenTheme =
    HelloWorkflow.mapRenderings { it.withRegistry(viewRegistry) }

容器 Screens 创建容器视图

容器 screen 是由其他 Screens 构建的。自然地,容器 screen 驱动的是容器 view:一个能够托管由任意类型的 Screen 实例驱动的子视图的视图。

Workflow UI 开箱即用地提供了两个根容器视图,即上面在引导启动下讨论的 ContainerViewControllerWorkflowLayout 类。它们的大部分工作都是通过委托给另一对支持视图类完成的:iOS 的 ScreenViewController 和 Android 的 WorkflowViewStub。Android 还为 Jetpack Compose 提供了 @Composable fun WorkflowRendering()。对于像 SplitScreen 这样的渲染,你需要编写自己的视图代码来实现相同的功能。

SplitScreen.swift
public struct SplitScreen<LeadingScreenType: Screen, TrailingScreenType: Screen>: Screen {
    public let leadingScreen: LeadingScreenType

    public let trailingScreen: TrailingScreenType

    public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
        return SplitScreenViewController.description(for: self, environment: environment)
    }
SplitScreenViewController.swift
internal final class SplitScreenViewController<LeadingScreenType: Screen, TrailingScreenType: Screen>: ScreenViewController<SplitScreenViewController.ContainerScreen> {
    internal typealias ContainerScreen = SplitScreen<LeadingScreenType, TrailingScreenType>

    private var leadingContentViewController: DescribedViewController
    private lazy var leadingContainerView: ContainerView = .init()

    private lazy var separatorView: UIView = .init()

    private var trailingContentViewController: DescribedViewController
    private lazy var trailingContainerView: ContainerView = .init()

    required init(screen: ContainerScreen, environment: ViewEnvironment) {
        self.leadingContentViewController = DescribedViewController(
            screen: screen.leadingScreen,
            environment: environment
        )
        self.trailingContentViewController = DescribedViewController(
            screen: screen.trailingScreen,
            environment: environment
        )
        super.init(screen: screen, environment: environment)
    }

    override internal func screenDidChange(from previousScreen: ContainerScreen, previousEnvironment: ViewEnvironment) {
        super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment)

        update(with: screen)
    }

    private func update(with screen: ContainerScreen) {
        leadingContentViewController.update(
            screen: screen.leadingScreen,
            environment: environment
        )
        trailingContentViewController.update(
            screen: screen.trailingScreen,
            environment: environment
        )

        // Intentional force of layout pass after updating the child view controllers
        view.layoutIfNeeded()
    }

    override internal func viewDidLoad() {
        /** Lay out the two children horizontally, nothing workflow specific here. */

        update(with: screen)
    }

    override internal func viewDidLayoutSubviews() {
        /** Calculate the layout, nothing workflow specific here. */
    }

这里有趣的是使用了 DescribedViewController 来显示嵌套的 leadingContenttrailingContent Screens。DescribedViewController 在需要时使用 Screen.viewControllerDescription 构建新的 UIViewController,如果可以则更新现有实例。其他部分都只是普通的 iOS 视图代码。

SplitScreen.kt
data class SplitScreen<L: Screen, T: Screen>(
  val leadingScreen: L,
  val trailingScreen: T
): AndroidScreen<SplitScreen<L, T>> {
  override val viewFactory: ScreenViewFactory<SplitScreen<L, T>> =
    fromViewBinding(SplitScreenBinding::inflate) { screen, _ ->
      leadingStub.show(leadingScreen)
      trailingStub.show(trailingScreen)
    }
}
split_screen.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    >

  <com.squareup.workflow1.ui.WorkflowViewStub
      android:id="@+id/leading_stub"
      android:layout_width="0dp"
      android:layout_height="match_parent"
      android:layout_weight="30"
      />

  <View
      android:layout_width="1dp"
      android:layout_height="match_parent"
      android:background="@android:drawable/divider_horizontal_bright"
      />

  <com.squareup.workflow1.ui.WorkflowViewStub
      android:id="@+id/trailing_stub"
      android:layout_width="0dp"
      android:layout_height="match_parent"
      android:layout_weight="70"
      />

</LinearLayout>

SplitScreen.kt
data class SplitScreen<L: Screen, T: Screen>(
  val leadingScreen: L,
  val trailingScreen: T
): ComposeScreen<SplitScreen<L, T>> {
  @Composable override fun Content(viewEnvironment: ViewEnvironment) {
    Row {
      WorkflowRendering(
        rendering = leadingScreen,
        modifier = Modifier
          .weight(1 / 3f)
          .fillMaxHeight()
      )
      WorkflowRendering(
        rendering = trailingScreen,
        modifier = Modifier
          .weight(2 / 3f)
          .fillMaxHeight()
      )
    }
  }
}