Eazy

Eazy 是你 SwiftUI 和 UIKit 应用中缺失的一环。它的目标是以清晰一致的方式协调你的视图与模型之间的通信。Eazy 可以在任何 Apple 平台上使用。

Eazy 是一种单向架构,在状态变更方面采取略有不同的方法。让我们通过一个例子来了解 Eazy 的核心组件。

本例将介绍一个聊天功能。在这个功能中,我们可以发布和接收新消息。让我们从定义视图状态开始。

状态 (State)

struct ChatState: Equatable {
    struct Message: Equatable, Identifiable {
        enum From: Equatable {
            case other
            case me
        }
        let id = UUID()
        let from: From
        let text: String
    }
    enum MessagesState: Equatable {
        case loading
        case success([Message])
        case failure(String)
    }
    
    var messagesState: MessagesState = .loading
    var newMessageString = ""
}

为了检索和发送这些消息,我们需要定义一些动作 (Action)。

动作 (Action)

enum ChatAction: Equatable {
    case getMessages
    case sendMessage
}

由于我们可以接收到传入的消息,因此我们还需要与外部世界进行通信。为此,我们可以使用一个钩子 (Hook),我们将其命名为 messageRecieved。钩子也可用于观察内部状态。让我们添加一个名为 newMessageChanged 的钩子,用于在 newMessageString 更改时,确保在用户退出屏幕时保存任何草稿。

钩子 (Hooks)

enum ChatHook: CaseIterable {
    case messageRecieved
    case newMessageChanged
}

依赖 (Dependencies)

处理依赖关系非常简单,只需定义一个结构体,或者如果你喜欢,可以使用协议。

struct ChatService {
    let getMessages: () async throws -> [ChatState.Message]
    let sendMessage: (ChatState.Message) async -> Void
    let receivedMessage: AnyPublisher<ChatState.Message, Never>
    let cacheDraft: (String) -> Void
}

交互器 (Interactor)

交互器是我们决定状态如何转换的地方。交互器负责处理我们的动作,包括同步和异步动作。对于异步动作,我们可以使用 async await。

import Eazy

struct ChatInteractor: Interactor {
    
    let service: ChatService
    
    func onAction(_ action: ChatAction, store: MutatingStore<ChatState, ChatAction>) async {
        switch action {
        case .getMessages:
            do {
                store.messagesState = .loading
                let messages = try await service.getMessages()
                store.messagesState = .success(messages)
            } catch {
                store.messagesState = .failure("Something went wrong")
            }
        case .sendMessage:
            guard !store.newMessageString.isEmpty else {
                return
            }
            let message = ChatState.Message(from: .me, text: store.newMessageString)
            await service.sendMessage(message)
            store.newMessageString = ""
        }
    }
}

交互器也是我们使用 Combine 发布者配置钩子的地方。

struct ChatInteractor: Interactor {
    // ...
    func publisher(for hook: ChatHook, store: MutatingStore<ChatState, ChatAction>) -> AnyCancellable {
        switch hook {
        case .messageRecieved:
            return HookPublisher(service.receivedMessage)
                .sink { message in
                    if case .success(let messages) = store.messagesState {
                        withAnimation {
                            store.messagesState = .success(messages + [message])
                        }
                    }
                }
        case .newMessageDraftChanged:
            return HookPublisher(store.publisher.newMessageString)
                .sink { newMessage in
                    service.cacheDraft(newMessage)
                }
        }
    }
}

因此,我们可以看到,动作和钩子是我们用来更新状态的方式。

视图 (View)

这建立了一个基本的聊天视图。我们将我们的逻辑封装到一个 Store 中。然后,我们使用 @StateStore 属性包装器与 Store 交互。Store 的行为很像任何 ObservableObject,这意味着我们可以观察状态变化并通过在状态属性前加上 $ 来创建绑定。我们通过调用 store.dispatch 来触发动作。

struct ChatView: View {
    
    @StateStore var store: Store<ChatState, ChatAction>
    
    var body: some View {
        NavigationView {
            VStack {
                ScrollView {
                    switch store.messagesState {
                 // case .loading:
                    case .success(let messages):
                        LazyVStack {
                            ForEach(messages) { message in
                                Group {
                                    switch message.from {
                                    case .me:
                                        HStack {
                                            Spacer()
                                            MessageView(text: message.text, color: .blue)
                                        }
                                    case .other:
                                        HStack {
                                            MessageView(text: message.text, color: .gray)
                                            Spacer()
                                        }
                                    }
                                }
                                .padding(.vertical, 8)
                            }
                        }

                 // case .failure(let error)
                }
                NewMessageView(message: $store.newMessageString) {
                    store.dispatch(.sendMessage)
                }
                .padding()
            }

            .task {
                await store.dispatch(.getMessages)
            }
            .navigationTitle("Conversation")
        }

    }
}

struct NewMessageView: View {
    // ..
}

struct MessageView: View {
    // ..
}

SwiftUI 动画开箱即用。让我们为收到新消息时添加一个动画。

case .messageRecieved:
    return HookPublisher(service.receivedMessage)
	.sink { message in
	    if case .success(let messages) = store.messagesState {
		withAnimation {
		    store.messagesState = .success(messages + [message])
		}
	    }
	}
}

现在我们只需要提供一个服务实现,我们就完成了!让我们现在创建一个 Mock。

extension Array where Element == ChatState.Message {
    static let mock: [ChatState.Message] = [
        .init(from: .me, text: "Hello my friend"),
        .init(from: .other, text: "Well hello"),
        .init(from: .me, text: "Protein, iron, and calcium are some of the nutritiona benefits associated with cheeseburgers."),
    ]
}

extension ChatService {
    static var mock: Self {
        let subject = PassthroughSubject<ChatState.Message, Never>()
        return ChatService(
            getMessages: {
                try await Task.sleep(nanoseconds: 0_500_000_000)
                return .mock
            },
            sendMessage: {
                subject.send($0)
            },
            receivedMessage: subject.eraseToAnyPublisher(),
            cacheDraft: { _ in }
        )
    }
}

现在我们准备好在屏幕上显示一些东西了!

import SwiftUI
import Eazy

@main
struct ChatApp: App {
    var body: some Scene {
        WindowGroup {
            ChatView(store: Store(state: ChatState(),
                                  interactor: ChatInteractor(service: .mock)))
        }
    }
}

就是这样!我们涵盖了 Eazy 的基础知识,请继续阅读以获取更多信息。你可以在这里找到完整的例子。

测试 (Testing)

由于我们在交互器中保持了与依赖项的干净接口,因此测试我们的功能很容易。Eazy 附带一个专用的 TestStore。

import XCTest
import Eazy
import Combine
@testable import Chat

class ChatTests: XCTestCase {
    
    @MainActor
    func testGetMessages() async {
        let store = TestStore(state: ChatState(), interactor: ChatInteractor(service: .mock))
        await store.dispatch(.getMessages)
        let expected = ChatState(messagesState: .success(.mock), newMessageString: "")
        XCTAssertEqual(store.state, expected)
    }
    
    @MainActor
    func testMessageRecieved() async {
        let subject = PassthroughSubject<ChatState.Message, Never>()
        let newMessage = ChatState.Message(from: .other, text: "Foo")
        let service = ChatService.mock(subject: subject)
        let store = await TestStore.testHook(.messageRecieved,
                                             trigger: subject.send(newMessage),
                                             state: ChatState(messagesState: .success([])),
                                             interactor: ChatInteractor(service: service))
        let expected = ChatState(messagesState: .success([newMessage]))
        XCTAssertEqual(store.state, expected)
    }
}

UIKit

Eazy 在 UIKit 中也很好用,并且为将值分配和绑定到视图提供了一些便利。

struct SomeState: Equatable {
    var text = "Hello"
    var isHidden = false
}

enum SomeAction: Equatable {
    case buttonTapped
}

enum SomeHook: CaseIterable {
    case textChanged
}

struct SomeInteractor: Interactor {
    func onAction(_ action: SomeAction, store: MutatingStore<SomeState, SomeAction>) async {
        switch action {
        case .buttonTapped:
            store.isHidden = !store.isHidden
        }
    }
    
    func publisher(for hook: SomeHook, store: MutatingStore<SomeState, SomeAction>) -> AnyCancellable {
        switch hook {
        case .textChanged:
            return HookPublisher(store.publisher.text)
                .map {
                    $0.count.isMultiple(of: 2)
                }
                .assign(to: \.isHidden, using: store)
        }
    }
}

class ViewController: UIViewController {
    
    let store = Store(state: SomeState(), interactor: SomeInteractor())
  
    var cancellables: Set<AnyCancellable> = []
    
  	let label = UILabel()
  
    lazy var textField: UITextField = {
        let textField = UITextField()
        textField.borderStyle = .roundedRect
        return textField
    }()
    
    lazy var hiddenView: UIView = {
        let view = UIView()
        view.backgroundColor = .red
        return view
    }()
    
    lazy var button: UIButton = {
        let button = UIButton(primaryAction: .init(handler: { [weak self] action in
            self?.store.dispatch(.buttonTapped)
        }))
        button.setTitle("Toggle", for: .normal)
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupBindings()
    }
    
    func setupViews() {
        // ...
    }
    
    func setupBindings() {
        textField.bind(to: \.text, using: store, storeIn: &cancellables)
        label.assign(to: \.text, using: store, storeIn: &cancellables)
        store.publisher.isHidden
            .assign(to: \.isHidden, on: hiddenView)
            .store(in: &cancellables)
    }
}

调试 (Debugging)

SwiftUI 应用程序可能有点难以调试。但不要害怕,Eazy 提供了一个 DebugStore 来让这更容易一些。

DebugStore.enableLogging()
Eazy - Initial state:
ChatState(
  messagesState: ChatState.MessagesState.loading,
  newMessageString: ""
)
Eazy - Triggered action:
ChatAction.getMessages
Eazy - State changed:
  ChatState(
-   messagesState: ChatState.MessagesState.loading,
+   messagesState: ChatState.MessagesState.success(
+     [
+       [0]: ChatState.Message(
+         id: UUID(1D72C282-C057-45EA-9632-EFE8A02AA428),
+         from: ChatState.Message.From.me,
+         text: "Hello my friend"
+       ),
+       [1]: ChatState.Message(
+         id: UUID(3F410CD7-CCAF-4DEF-B7A2-1057F7083122),
+         from: ChatState.Message.From.other,
+         text: "Well hello"
+       ),
+       [2]: ChatState.Message(
+         id: UUID(3E57A986-ED89-4C29-BC22-32F885F79806),
+         from: ChatState.Message.From.me,
+         text: """
+           Protein, iron, and calcium are some of the nutritional benefits associated with cheeseburgers.
+           Salad is essentially food for rabbits, so don’t bother wasting your time.
+           """
+       )
+     ]
+   ),
    newMessageString: ""
  )

我们还可以将输出路由到我们自己的输出流,这使得将输出写入文件变得非常简单。

DebugStore.print = { message in
    // Handle message
}

取消动作 (Cancel actions)

如果我们遵守 CancellableAction,则任何先前的动作都将被取消。

enum CatSearchAction: Equatable, CancellableAction {
    case searchCat(String)
    
    var cancelIdentifier: String? {
        switch self {
        case .searchCat:
            return "searchCat"
        }
    }
}

struct CatSearchInteractor: Interactor {
  	// ...
    func onAction(_ action: CatSearchAction, store: MutatingStore<CatSearchState, CatSearchAction>) async {
        switch action {
        case .searchCat(let query):
            do {
                store.cats = try await service.search(cats: query)
            } catch {
                if !Task.isCancelled {
                    store.errorMessage = error.localizedDescription
                }
            }
        }
    }
}

线程 (Threading)

Store 在主线程上运行,并标记为使用 @MainActor。这意味着编译器会帮助我们强制我们从相同的上下文中调用 Store。但是,编译器无法强制 Combine 发布者执行此操作,因此请注意你在哪个调度程序上发送输出。我们需要确保我们的发布者最终在主队列上发布他们的输出。

    func publisher(for hook: SomeHook, store: MutatingStore<SomeState, SomeAction>) -> AnyCancellable {
        switch hook {
        case .someHook:
            return HookPublisher(service.somePublisherThatRunsInADifferentContext)
          	.receive(on: DispatchQueue.main)
                .sink { _ in
			// ...
                }
        }
    }

Combine 扩展 (Combine extensions)

Eazy 提供了一些方便的扩展,用于从钩子发布者分配值和动作。有关更多信息,请参见 Cocktail 和 Form 示例。

case .signUpStateChanged:
    return HookPublisher(store.publisher.signUpState)
	.compactMap {
	    if case .failure(let error) = $0 {
		return error
	    }
	    return nil
	}
	.assign(to: \.notValidText, using: store, animation: .default)

安装 (Installation)

通过选择 File/Add packages... 在 Xcode 中添加软件包,或将其添加到你的 Package.swift 中。

    dependencies: [
        .package(name: "Eazy", url: "https://github.com/bangerang/swift-eazy.git", .upToNextMajor(from: "0.0.1"))
    ]

文档 (Documentation)

可在此处获得:这里

示例 (Examples)

有兴趣看到更多 Eazy 实际应用的例子吗?你可以在这里找到所有示例。

鸣谢 (Credits)

非常感谢 Point-Free 及其在 The Composable Architecture 上的工作,它是在构建此库时的一个重要灵感来源。