阅读视图

发现新文章,点击刷新页面。

Swift 中的函数式核心与命令式外壳:单向数据流

timer_app_cover.png

前言

之前,我们讨论了在 Swift 中的函数式核心与命令式外壳的概念。其目标是通过值类型提取纯逻辑,并将副作用保持在薄薄的对象层中。本周,我们将展示如何以单向数据流的方式应用这一方法。

函数式核心

函数式核心是负责我们应用中所有逻辑的层,我们希望通过单元测试验证它们。它应该是纯粹的,没有任何副作用。我们希望提供输入并验证输出。通常,单向数据流的实现需要许多接收状态和动作并返回新状态的 reducer 函数。让我们在代码中定义 reducer 函数。

如果你不熟悉单向数据流的概念,我强烈建议你阅读我关于“在 SwiftUI 中类似 Redux 的状态容器”的系列文章。

typealias Reducer<State, Action> = (State, Action) -> State

正如你所见,reducer 函数接受当前状态和要应用于该状态的动作,并返回一个新状态。我正在开发一个间歇性禁食追踪的应用。让我们看看我如何实现计时器逻辑。

struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
}

enum TimerAction {
    case start
    case finish
    case reset
}

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    }

    return state
}

这是我代码库中实现计时器管理逻辑的真实示例。正如你所见,它是纯粹的,没有任何副作用。它允许我快速使用单元测试验证逻辑,无需使用 mocks 和 stubs。

import XCTest

final class TimerReducerTests: XCTestCase {
    func testStart() {
        let state = TimerState(goal: 13 * 3600)
        XCTAssertNil(state.start)
        let newState = timerReducer(state, .start)
        XCTAssertNotNil(newState.start)
    }
}

像结构体和枚举这样的值类型是实现应用逻辑的极佳工具,既纯粹又非常可测试。但我们仍然需要副作用。例如,我想通过 CloudKit 与朋友分享计时器状态。

命令式外壳

命令式外壳是通过值类型表示应用状态的对象层。我们还利用对象层进行副作用操作,并将结果应用于状态之上。首先定义一个持有状态的通用对象。

@MainActor public final class Store<State, Action>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>
    ) {
        self.reducer = reducer
        self.state = state
    }

    public func send(_ action: Action) {
        state = reducer(state, action)
    }
}

这是使用 Store 类定义的命令式外壳。正如你所见,我们使用对象层持有通过值类型表示的应用状态。对象层允许我们分享应用状态,并使其成为单一事实来源。我们还通过利用 MainActor 并仅通过将动作传递给 Store 类型的 send 方法来允许变更,提供线程安全。这就是我们在函数式核心与命令式外壳的理念下实现单向数据流的方式。但我们仍然缺少副作用。

副作用

命令式外壳应为我们提供进行副作用操作的方法。我们应该将副作用与应用的纯逻辑分开,但我们仍希望通过集成测试来测试副作用。让我们引入一种称为 Middleware 的新类型,它定义了一个副作用处理程序。

typealias Middleware<State, Action, Dependencies> = (State, Action, Dependencies) async -> Action?

Middleware 类型的主要思想是拦截纯动作,进行副作用操作(如异步请求),并返回一个新的动作,我们可以将其传递给 store 并进行归约。让我们将此功能添加到 Store 类型中。

@MainActor public final class Store<State, Action, Dependencies>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>
    private let dependencies: Dependencies
    private let middlewares: [Middleware<State, Action, Dependencies>]

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>,
        dependencies: Dependencies,
        middlewares: [Middleware<State, Action, Dependencies>] = []
    ) {
        self.reducer = reducer
        self.state = state
        self.dependencies = dependencies
        self.middlewares = middlewares
    }

    public func send(_ action: Action) async {
        state = reducer(state, action)

        await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
            for middleware in middlewares {
                group.addTask {
                    await middleware(state, action, dependencies)
                }
            }

            for await case let action? in group {
                await send(action)
            }
        }
    }
}

正如你所见,我们使用新的 Swift 并发特性在 Store 类型中实现异步工作。它允许我们并行运行副作用并将动作传递给 store。通过标记 Store 类型为 @MainActor,我们确保了对状态的访问。使用 TaskGroup,我们自动获得了副作用的协作取消。Store 类型还持有所有依赖项(如网络、通知中心等),以便提供给 middlewares。

struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
    var sharingStatus = SharingStatus.notShared
}

enum SharingStatus: Equatable {
    case shared
    case uploading
    case notShared
}

enum TimerAction: Equatable {
    case start
    case finish
    case reset
    case share
    case setSharingStatus(SharingStatus)
}

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    case .share:
        state.sharingStatus = .uploading
    case let .setSharingStatus(status):
        state.sharingStatus = status
    }

    return state
}

struct TimerDependencies {
    let share: (Date, Date?) async throws -> Void
}

let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
    switch action {
    case .share:
        guard let start = state.start else {
            return .setSharingStatus(.notShared)
        }

        do {
            try await dependencies.share(start, state.end)
            return .setSharingStatus(.shared)
        } catch {
            return .setSharingStatus(.notShared)
        }
    default:
        return nil
    }
}

下面是实现 middleware 的示例代码。正如你所见,我们拦截传递给 store 的动作,进行异步请求,并向系统提供另一个动作。我们还可以通过 mock TimerDependencies 类型轻松编写集成测试。

import XCTest

final class TimerMiddlewareTests: XCTestCase {
    func testSharing() async throws {
        let state = TimerState(goal: 13 * 3600)
        let dependencies: TimerDependencies = .init { _, _ in }
        let action = await timerMiddleware(state, .share, dependencies)
        XCTAssertEqual(action, .setSharingStatus(.shared))
    }
}

想了解更多关于将异步闭包用作依赖项的信息,请查看我的“在 Swift 中的微应用架构:依赖注入”一文。

import SwiftUI

struct RootView: View {
    @StateObject var store = Store(
        initialState: TimerState(goal: 13 * 3600),
        reducer: timerReducer,
        dependencies: TimerDependencies.production,
        middlewares: [timerMiddleware]
    )

    var body: some View {
        NavigationView {
            VStack {
                if let start = store.state.start, store.state.end == nil {
                    Text(start, style: .timer)
                    
                    Button("Stop") {
                        Task { await store.send(.finish) }
                    }

                    Button("Reset") {
                        Task { await store.send(.reset) }
                    }
                } else {
                    Button("Start") {
                        Task { await store.send(.start) }
                }
            }
            .navigationTitle("Timer")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Share") {
                        Task {
                            await store.send(.share)
                        }
                    }
                }
            }
        }
    }
}

可运行 Demo

上面详细介绍了理论逻辑。下面根据这个些功能提供一个可以运行的 Demo。

我们将创建一个可以运行的 SwiftUI 应用示例,该应用将展示如何使用函数式核心与命令式外壳的理念来实现单向数据流和管理副作用。这个示例将实现一个简单的计时器应用,允许用户启动、停止、重置计时器并分享计时状态。

函数式核心部分

首先,我们定义应用的状态和动作,并实现一个 reducer 函数来管理状态变化。

import SwiftUI
import Combine

// 定义计时器状态
struct TimerState: Equatable {
    var start: Date?
    var end: Date?
    var goal: TimeInterval
    var sharingStatus = SharingStatus.notShared
}

// 定义计时器动作
enum TimerAction: Equatable {
    case start
    case finish
    case reset
    case share
    case setSharingStatus(SharingStatus)
}

// 定义共享状态
enum SharingStatus: Equatable {
    case shared
    case uploading
    case notShared
}

// 定义 Reducer 函数
typealias Reducer<State, Action> = (State, Action) -> State

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
    var state = state

    switch action {
    case .start:
        state.start = .now
    case .finish:
        state.end = .now
    case .reset:
        state.start = nil
        state.end = nil
    case .share:
        state.sharingStatus = .uploading
    case let .setSharingStatus(status):
        state.sharingStatus = status
    }

    return state
}

命令式外壳部分

接下来,我们定义一个 Store 类来持有应用的状态,并处理副作用。

@MainActor
public final class Store<State, Action, Dependencies>: ObservableObject {
    @Published public private(set) var state: State

    private let reducer: Reducer<State, Action>
    private let dependencies: Dependencies
    private let middlewares: [Middleware<State, Action, Dependencies>]

    public init(
        initialState state: State,
        reducer: @escaping Reducer<State, Action>,
        dependencies: Dependencies,
        middlewares: [Middleware<State, Action, Dependencies>] = []
    ) {
        self.reducer = reducer
        self.state = state
        self.dependencies = dependencies
        self.middlewares = middlewares
    }

    public func send(_ action: Action) async {
        state = reducer(state, action)

        await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
            for middleware in middlewares {
                group.addTask {
                    await middleware(state, action, dependencies)
                }
            }

            for await case let action? in group {
                await send(action)
            }
        }
    }
}

副作用处理

定义一个中间件来处理异步副作用,比如共享计时状态。

typealias Middleware<State, Action, Dependencies> = (State, Action, Dependencies) async -> Action?

struct TimerDependencies {
    let share: (Date, Date?) async throws -> Void
}

let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
    switch action {
    case .share:
        guard let start = state.start else {
            return .setSharingStatus(.notShared)
        }

        do {
            try await dependencies.share(start, state.end)
            return .setSharingStatus(.shared)
        } catch {
            return .setSharingStatus(.notShared)
        }
    default:
        return nil
    }
}

SwiftUI 界面

最后,我们创建一个 SwiftUI 界面来展示计时器功能,并连接到 Store

struct RootView: View {
    @StateObject var store = Store(
        initialState: TimerState(goal: 13 * 3600),
        reducer: timerReducer,
        dependencies: TimerDependencies(share: { start, end in
            // 模拟共享计时状态的逻辑
            print("Shared from \(start) to \(String(describing: end))")
        }),
        middlewares: [timerMiddleware]
    )

    var body: some View {
        NavigationView {
            VStack {
                if let start = store.state.start, store.state.end == nil {
                    Text(start, style: .timer)

                    Button("Stop") {
                        Task { await store.send(.finish) }
                    }

                    Button("Reset") {
                        Task { await store.send(.reset) }
                    }
                } else {
                    Button("Start") {
                        Task { await store.send(.start) }
                    }
                }
            }
            .navigationTitle("Timer")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Share") {
                        Task {
                            await store.send(.share)
                        }
                    }
                }
            }
        }
    }
}

@main
struct TimerApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

代码运行截图

代码解释

  1. 状态和动作:我们定义了 TimerStateTimerAction 来表示计时器的状态和可执行的动作。
  2. Reducer 函数timerReducer 函数接受当前状态和动作,并返回一个新的状态。这个函数是纯函数,没有副作用,方便进行单元测试。
  3. Store 类Store 类持有应用的状态,并提供 send 方法来处理动作。我们使用 Swift 的并发特性来处理异步任务和副作用。
  4. 中间件timerMiddleware 用于处理异步副作用,比如共享计时状态。它拦截动作,执行异步任务,并返回一个新的动作来更新状态。
  5. SwiftUI 界面RootView 使用 Store 提供的状态和动作来构建界面。用户可以启动、停止、重置计时器,并共享计时状态。

这个示例展示了如何使用函数式核心与命令式外壳的理念来实现一个简单的计时器应用,利用 Swift 的最新特性处理异步任务和副作用。

总结

这篇文章讨论了如何在 Swift 中结合使用函数式核心与命令式外壳的理念来实现单向数据流,并详细展示了如何在代码中实现这些理念,包括使用 Swift 并发特性处理异步任务和管理副作用。通过这种架构,开发者可以在保持代码清晰和易于测试的同时,处理复杂的应用状态和副作用。

参考资料

  1. swift-unidirectional-flow - 使用最新的 Swift 泛型和 Swift 并发特性实现单向数据流。
  2. “Boundaries”, a talk by Gary Bernhardt from SCNA 2012

自定义 SwiftUI 中符号图像的外观

1722425826929.jpg

前言

符号图像是来自 Apple的SF Symbols 库的矢量图标,设计用于在 Apple 平台上使用。这些可缩放的图像适应不同的大小和重量,确保在我们的应用程序中具有一致的高质量图标。在 SwiftUI 中使用符号图像非常简单,只需使用 Image 视图和所需符号的系统名称。下面是一个快速示例:

import SwiftUI

struct ContentView: View {
    var body: some View {
        Image(systemName: "star")
    }
}

大小

尽管符号被放置在Image视图中,但它应被视为文本。要调整符号的大小,我们可以应用 font() 修饰符,就像在Text视图中一样。这使我们能够将符号的大小与不同的文本样式对齐,确保UI的视觉一致性。

HStack {
    Image(systemName: "star")
        .font(.title)
    
    Image(systemName: "star")
        .font(.body)
    
    Image(systemName: "star")
        .font(.caption)
}

我们可以使用 fontWeight() 修饰符来调整符号的重量。这个修饰符改变符号笔画的粗细,使我们能够将符号与周围的文本匹配或对比。

HStack {
    Image(systemName: "star")
        .fontWeight(.light)
    
    Image(systemName: "star")
        .fontWeight(.bold)
    
    Image(systemName: "star")
        .fontWeight(.black)
}

要根据字体大小相对缩放图像,我们应该使用 imageScale() 修饰符。有三个选项:小、中、大,它们根据字体大小按比例缩放符号。如果没有明确设置字体,符号将从当前环境中继承字体。

HStack {
    Image(systemName: "star")
        .imageScale(.small)
    
    Image(systemName: "star")
        .imageScale(.medium)
    
    Image(systemName: "star")
        .imageScale(.large)
}
.font(.headline)

不建议通过应用resizable()修饰符并设置框架来调整符号图像的大小,因为这样做会使图像停止作为符号图像,从而影响其与文本的布局和对齐。

颜色

使用SwiftUI中的foregroundStyle()视图修饰符,可以轻松自定义符号图像的颜色。这个修饰符允许我们直接设置符号图像的颜色。

Image(systemName: "star")
    .foregroundStyle(.orange)

foregroundStyle() 修饰符可以采用任何 ShapeStyle,包括渐变,这为我们的符号图像提供了广泛的自定义可能性。在这个例子中,星形符号使用了从黄色到红色的线性渐变,从顶部到底部过渡。

Image(systemName: "star")
    .foregroundStyle(
        LinearGradient(
            colors: [.yellow, .red],
            startPoint: .top,
            endPoint: .bottom
        )
    )

渲染模式

我们可以通过使用不同的渲染模式进一步自定义符号图像的外观。SF Symbols有四种不同的渲染模式,这些模式会改变符号的颜色和外观。一些渲染模式使整个图标保持相同颜色,而其他模式则允许多种颜色。

要在SwiftUI中设置符号图像的首选渲染模式,我们使用 symbolRenderingMode() 修饰符。

单色

单色是默认的渲染模式。在这种模式下,符号的每一层都是相同的颜色。

Image(systemName: "thermometer.snowflake")
    .symbolRenderingMode(.monochrome)

分层

分层模式将符号渲染为多个层,每层应用不同的不透明度。层次结构和不透明度在每个符号中是预定义的,但我们仍然可以使用 foregroundStyle() 修饰符自定义颜色。

HStack {
    Image(systemName: "thermometer.snowflake")
    Image(systemName: "thermometer.snowflake")
        .foregroundStyle(.indigo)
}
.symbolRenderingMode(.hierarchical)

symbolRenderingMode() 修饰符既可以直接应用于图像视图,也可以通过将其应用于包含多个符号图像的父视图来在环境中设置。这样,父元素内的所有符号图像都会受到影响。

调色板

调色板模式允许符号以多层呈现,每层具有不同的颜色。这种模式非常适合创建色彩丰富的多层图标。

Image(systemName: "thermometer.snowflake")
    .symbolRenderingMode(.palette)
    .foregroundStyle(.blue, .teal, .gray)

我们不需要显式地指定调色板呈现模式。如果我们在 foregroundStyle() 修饰符中应用多个样式,则调色板模式将自动激活。

Image(systemName: "thermometer.snowflake")
    .foregroundStyle(.blue, .teal, .gray)

如果我们为一个定义了三个层次结构的符号指定两种颜色,那么第二层和第三层将使用相同的颜色。

Image(systemName: "thermometer.snowflake")
    .foregroundStyle(.blue, .gray)

多色

多色模式使用由 Apple 定义的一组固定颜色渲染符号。在使用多色渲染时,我们无法自定义符号的颜色,它将使用预定义的颜色。

HStack {
    Image(systemName: "thermometer.snowflake")
    Image(systemName: "thermometer.sun.fill")
}
.symbolRenderingMode(.multicolor)

值得注意的是,由于这些颜色是固定的,它们不适应明暗模式。例如,我们的温度计符号具有白色轮廓,在白色背景上是不可见的。

并非所有符号都支持每种呈现模式。图层较少的符号在不同模式下看起来可能相同,分层和调色板模式看起来类似于单色。

可变值

在 SwiftUI 中显示符号图像时,我们可以提供一个 0.0 到 1.0 之间的可选值,渲染的图像可以使用它来自定义外观。如果符号不支持可变值,此参数无效。我们应该在 SF Symbols 应用程序中检查哪些符号支持可变值。

HStack {
    Image(systemName: "speaker.wave.3", variableValue: 0)
    Image(systemName: "speaker.wave.3", variableValue: 0.3)
    Image(systemName: "speaker.wave.3", variableValue: 0.6)
    Image(systemName: "speaker.wave.3", variableValue: 0.9)
}

可变值可以表示一个随着时间变化的特性,例如容量或强度。这使得符号的外观可以根据应用程序的状态动态变化。

struct ContentView: View {
    @State private var value = 0.5
    
    var body: some View {
        VStack {
            Image(
                systemName: "speaker.wave.3",
                variableValue: value
            )
            Slider(value: $value, in: 0...1)
                .padding()
        }
        .padding()
    }
}

在这个例子中,符号 speaker.wave.3 根据 Slider 提供的值改变其外观。

我们应该使用可变值来传达状态的变化,例如音量、电池电量或信号强度,为用户提供动态状态的清晰视觉表示。为了传达深度和视觉层次,我们应该使用分层渲染模式,它可以提升某些图层,并区分符号内的前景和背景元素。

设计变体

符号可以有不同的设计变体,例如填充和斜杠,以帮助传达特定的状态和操作。斜杠变体可以表示项目或操作不可用,而填充变体可以表示选择。

在 SwiftUI 中,我们可以使用 symbolVariant() 修饰符来应用这些变体。

HStack {
    Image(systemName: "heart")
    
    Image(systemName: "heart")
        .symbolVariant(.slash)
    
    Image(systemName: "heart")
        .symbolVariant(.fill)
}

不同的符号变体用于各种设计目的。轮廓变体在工具栏、导航栏和列表中非常有效,而填充变体则用于强调选择的状态。

HStack {
    Image(systemName: "heart")
        .symbolVariant(.circle)
    
    Image(systemName: "heart")
        .symbolVariant(.square)
    
    Image(systemName: "heart")
        .symbolVariant(.rectangle)
}

不同的符号变体具有不同的设计用途。轮廓变体在工具栏、导航栏和列表中非常有效,因为这些地方通常会与文本一起显示符号。将符号封装在圆形或方形等形状中可以增强其可读性,特别是在较小尺寸下。填充变体由于其实心区域,使符号更具视觉强调性,非常适合用于 iOS 标签栏、滑动操作以及指示选择的强调颜色场景。

在许多情况下,显示符号的视图会自动选择合适的变体。例如,iOS 标签栏通常使用填充变体,而导航栏则偏好轮廓变体。这种自动选择确保符号在不同上下文中有效使用,而无需明确指定。

示例代码

import SwiftUI

struct ContentView: View {
    @State private var value = 0.5
    
    var body: some View {
        VStack {
            Image(
                systemName: "speaker.wave.3",
                variableValue: value
            )
            .symbolRenderingMode(.hierarchical)
            .foregroundStyle(.blue)
            Slider(value: $value, in: 0...1)
                .padding()
        }
        .padding()
    }
}

运行 Demo

  1. 打开Xcode并创建一个新的 SwiftUI 项目。
  2. 将上述代码粘贴到 ContentView.swift 文件中。
  3. 运行项目,查看效果。

结论

在SwiftUI中增强符号图像可以显著改善应用程序的外观和感觉。通过调整大小、颜色、渲染模式、可变值和设计变体,我们可以创建使应用程序更直观和视觉吸引力的图标。SwiftUI使这些调整变得简单易行,使我们能够轻松实现和改进这些自定义以提供更好的用户体验。

❌