编写 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 的一行代码。
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
)
}
}
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 世界。
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()
调用
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
需要提供匹配的 ScreenViewFactory
。ScreenViewFactory
返回包装在 ScreenViewHolder
对象中的 View
实例。ScreenViewHolder.show
由 Workflow UI 运行时调用,以使用 ScreenViewHolder.canShow
认为可接受的 Screen
实例更新视图。
在此示例中,fromViewBinding
函数创建了一个 ScreenViewFactory
,它使用 Jetpack View Binding(HelloViewBinding
,大概派生自 hello_view_binding.xml
)构建 View
实例。fromViewBinding
的 lambda 参数提供了 ScreenViewHolder.show
的实现,并保证给定的 helloScreen
参数是适当的类型。
提供了其他工厂函数,可以直接使用布局资源,或者完全从代码构建视图。
data class HelloScreen(
val message: String,
val onClick: () -> Unit
) : ComposeScreen<HelloScreen> {
@Composable override fun Content(viewEnvironment: ViewEnvironment) {
Button(onClick) {
Text(message)
}
}
}
这里,HelloScreen
正在实现 ComposeScreen
。ComposeScreen
扩展了用于经典 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
实现可以与其视图代码完全分开定义,稍后绑定。
struct WelcomeScreen {
var name: String
var onNameChanged: (String) -> Void
var onLoginTapped: () -> Void
}
extension WelcomeScreen: Screen {
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
return WelcomeViewController.description(for: self, environment: environment)
}
}
private final class WelcomeViewController: ScreenViewController<WelcomeScreen> {
// ...
data class HelloScreen(
val message: String,
val onClick: () -> Unit
) : Screen
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 开箱即用地提供了两个根容器视图,即上面在引导启动下讨论的 ContainerViewController
和 WorkflowLayout
类。它们的大部分工作都是通过委托给另一对支持视图类完成的:iOS 的 ScreenViewController
和 Android 的 WorkflowViewStub
。Android 还为 Jetpack Compose 提供了 @Composable fun WorkflowRendering()
。对于像 SplitScreen
这样的渲染,你需要编写自己的视图代码来实现相同的功能。
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)
}
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
来显示嵌套的 leadingContent
和 trailingContent
Screens。DescribedViewController
在需要时使用 Screen.viewControllerDescription
构建新的 UIViewController
,如果可以则更新现有实例。其他部分都只是普通的 iOS 视图代码。
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)
}
}
<?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>
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()
)
}
}
}