Eazy 是你 SwiftUI 和 UIKit 应用中缺失的一环。它的目标是以清晰一致的方式协调你的视图与模型之间的通信。Eazy 可以在任何 Apple 平台上使用。
Eazy 是一种单向架构,在状态变更方面采取略有不同的方法。让我们通过一个例子来了解 Eazy 的核心组件。
本例将介绍一个聊天功能。在这个功能中,我们可以发布和接收新消息。让我们从定义视图状态开始。
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)。
enum ChatAction: Equatable {
case getMessages
case sendMessage
}
由于我们可以接收到传入的消息,因此我们还需要与外部世界进行通信。为此,我们可以使用一个钩子 (Hook),我们将其命名为 messageRecieved
。钩子也可用于观察内部状态。让我们添加一个名为 newMessageChanged
的钩子,用于在 newMessageString
更改时,确保在用户退出屏幕时保存任何草稿。
enum ChatHook: CaseIterable {
case messageRecieved
case newMessageChanged
}
处理依赖关系非常简单,只需定义一个结构体,或者如果你喜欢,可以使用协议。
struct ChatService {
let getMessages: () async throws -> [ChatState.Message]
let sendMessage: (ChatState.Message) async -> Void
let receivedMessage: AnyPublisher<ChatState.Message, Never>
let cacheDraft: (String) -> Void
}
交互器是我们决定状态如何转换的地方。交互器负责处理我们的动作,包括同步和异步动作。对于异步动作,我们可以使用 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)
}
}
}
}
因此,我们可以看到,动作和钩子是我们用来更新状态的方式。
这建立了一个基本的聊天视图。我们将我们的逻辑封装到一个 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 的基础知识,请继续阅读以获取更多信息。你可以在这里找到完整的例子。
由于我们在交互器中保持了与依赖项的干净接口,因此测试我们的功能很容易。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)
}
}
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)
}
}
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
}
如果我们遵守 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
}
}
}
}
}
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
// ...
}
}
}
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)
通过选择 File/Add packages... 在 Xcode 中添加软件包,或将其添加到你的 Package.swift
中。
dependencies: [
.package(name: "Eazy", url: "https://github.com/bangerang/swift-eazy.git", .upToNextMajor(from: "0.0.1"))
]
可在此处获得:这里。
有兴趣看到更多 Eazy 实际应用的例子吗?你可以在这里找到所有示例。
非常感谢 Point-Free 及其在 The Composable Architecture 上的工作,它是在构建此库时的一个重要灵感来源。