使用 @Published 属性进行双向数据流,并能够忽略事件

问题描述 投票:0回答:1

我的 SwiftUI 视图有以下要求:

  1. 屏幕上有 2 个开关
  2. 根据这些切换的值,我需要计算一个状态(1 个值)
  3. 当状态发生变化时,我需要将其发布到服务器
  4. 当视图出现时,我必须根据本地存储的最后状态设置切换
  5. 在某些时候,我必须从服务器请求状态并相应地更新切换。

这是我的代码(在可观察对象中):

@Published var toggleValue1: Bool = false
@Published var toggleValue2: Bool = false

init() {
    toggleValue1 = getValue1()
    toggleValue2 = getValue2()

    cancellable = $toggleValue1
                   .combineLatest($toggleValue2)
                   .map { ... } // Transforms (bool, bool) to SomeType
                   .removeDuplicates()
                   .dropFirst()
                   .sink { ... } // Upload to backend. Starts a Task
}

现在,

.dropFirst
和我的上传任务验证要上传的值是否与本地存储的值(也应该在服务器上)不同的事实相结合,解决了我在视图出现时不上传值的问题但问题出在#5。当我更新这些
@Published
变量来更新 UI 时,它们也会触发上传。

我正在尝试考虑发布者或绑定的任何其他组合来忽略程序更改,但我找不到任何组合。我想我可以设置一个标志来忽略以下 N 个事件,但它看起来很 hacky

swift combine
1个回答
0
投票

您可以通过避免双向绑定并使用单向模式(如 MVVM、MVI、Redux、ELM、TCA 等)来解决此问题

也就是说,您的视图模型(或模型,或存储,您能想到的)的职责是 1.) 发布视图状态和 2.) 接收命令。当收到命令时,它会 3.) 计算新的视图状态。

遵循这种模式,视图本身无法改变其状态 - 它只是渲染状态并将“命令”(也称为用户意图)发送到视图模型。为了实现这一点,您根本不需要 Swift Bindings(除非您在内部必须将其作为参数传递给其他视图)。

当视图模型收到“事件”时,视图状态才会发生变化。事件是用户意图(按钮点击等),也可以是服务生成的具体化结果值。

这些事件可以使用 Swift Enum 进行建模。当用户意图和外部事件(例如某些服务函数的返回值)有不同的情况时,您的视图模型逻辑可以清楚地以不同的方式处理它 - 即使这些事件基本上对状态具有相同的变异效果。

单向流中,视图模型拦截所有事件并根据当前视图状态和事件计算新的视图状态。视图永远不会改变视图状态。

下面是演示该技术的完整示例。它还使用有限状态机 (FSM) 来执行逻辑,这有很多好处,例如使逻辑轻松正确和完整(不再有所谓的“边缘情况”)。您还会注意到,逻辑函数中没有使用合并。组合固然很棒,但在实现逻辑功能时并不需要它。只有这个覆盖所有情况的纯函数实现了整个逻辑。

import SwiftUI

/// A state representing the View
enum State {
    case start
    case idle(value1: Bool, value2: Bool)
    case loading(value1: Bool, value2: Bool)
    case error(Error, value1: Bool, value2: Bool)
}

/// All events that can happen in the system.
enum Event {
    case start(value1: Bool, value2: Bool) // sent from the view
    case toggle1(value: Bool) // user intent
    case toggle2(value: Bool) // user intent
    case update // user intent
    case didDismissAlert // user intent
    case serverResponse(Result<(toggle1: Bool, toggle2: Bool), Error>) // external event
}

struct Env {} // empty, could provide dependencies


// Convenience Assessors for State
extension State {
    var toggle1: Bool {
        switch self {
        case .idle(value1: let value, value2: _), .loading(value1: let value, value2: _), .error(_, value1: let value, value2: _):
            return value
        default:
            return false
        }
    }
    var toggle2: Bool {
        switch self {
        case .idle(value1: _, value2: let value), .loading(value1: _, value2: let value), .error(_, value1: _, value2: let value):
            return value
        default:
            return false
        }
    }
    
    var error: Error? {
        if case .error(let error, _, _) = self { error } else { nil }
    }
    
    var isLoading: Bool {
        if case .loading = self { true } else { false }
    }
}


typealias Effect = Lib.Effect<Event, Env>

/// Implements the logic for the story
func transduce(_ state: inout State, event: Event) -> [Effect] {
    switch (state, event) {
    case (.start, .start(let value1, let value2)):
        state = .idle(value1: value1, value2: value2)
        return []
        
    case (.idle(_, let value2), .toggle1(let newValue)):
        state = .idle(value1: newValue, value2: value2)
        return []
        
    case (.idle(let value1, _), .toggle2(let newValue)):
        state = .idle(value1: value1, value2: newValue)
        return []
        
    case (.loading, .toggle1), (.loading, .toggle2):
        return []
        
    case (.error, .toggle1), (.error, .toggle2):
        return []

    case (.idle(let value1, let value2), .update):
        state = .loading(value1: value1, value2: value2)
        return [
            Effect { _ in
                do {
                    let state = try await API.update(toggle1: value1, toggle2: value1)
                    return .serverResponse(.success(state))
                } catch {
                    return .serverResponse(.failure(error))
                }
            }
        ]
        
    case (.loading(let value1, let value2), .serverResponse(let result)):
        switch result {
        case .success(let args):
            state = .idle(value1: args.toggle1, value2: args.toggle2)
            return []
        case .failure(let error):
            state = .error(error, value1: value1, value2: value2)
            return []
        }

    case (.error(_, let value1, let value2), .didDismissAlert):
        state = .idle(value1: value1, value2: value2)
        return []

    case (.error, _):
        return []
    case (.loading, _):
        return []
    case (.idle, _):
        return []
    case (.start, _):
        return []
    }
    
}



@MainActor
struct ContentView: View {
    let model: Lib.Model<State, Event> = .init(
        intialState: .start,
        env: .init(),
        transduce: transduce(_:event:)
    )
    
    var body: some View {
        let toggle1Binding = Binding<Bool>(
            get: { self.model.state.toggle1 },
            set: { value in self.model.send(.toggle1(value: value))}
        )
        let toggle2Binding = Binding(
            get: { self.model.state.toggle2 },
            set: { value in self.model.send(.toggle2(value: value))}
        )
        VStack {
            switch self.model.state {
            case .start:
                ContentUnavailableView(
                    "Nothing to view",
                    image: "x.circle"
                )
            case .idle, .error, .loading:
                TwoTogglesView(
                    toggle1: toggle1Binding,
                    toggle2: toggle2Binding,
                    updateIntent: { self.model.send(.update) },
                    dismissAlert: { self.model.send(.didDismissAlert) }
                )
                .padding()
                .disabled(self.model.state.isLoading)
            }
        }
        .onAppear {
            self.model.send(.start(value1: false, value2: false))
        }
        .alert(
            self.model.state.error?.localizedDescription ?? "",
            isPresented: .constant(self.model.state.error != nil)
        ) {
            Button("OK", role: .cancel) { self.model.send(.didDismissAlert) }
        }
        .overlay {
            if self.model.state.isLoading {
                ProgressView()
            }
        }
    }
    
}

struct TwoTogglesView: View {
    @Binding var toggle1: Bool
    @Binding var toggle2: Bool
    let updateIntent: () -> Void
    let dismissAlert: () -> Void
    
    var body: some View {
        VStack {
            Toggle("Toggle 1", isOn: $toggle1)
            Toggle("Toggle 2", isOn: $toggle2)

            Button("Update") {
                updateIntent()
            }
        }
    }
}

#Preview {
    ContentView()
}


// Some reusable components put into a "namespace"
enum Lib {
    
    /// An effect value encapsulates a function which may have side effects.
    struct Effect<Event, Env> {
        let f: (Env) async -> Event
        
        init(f: @escaping (Env) async -> Event) {
            self.f = f
        }
        
        func invoke(env: Env) async -> Event {
            await f(env)
        }
    }
    
    /// The world's most simple and concise actor embedding a Finite State Machine and a runtime
    /// component executing side effects in a Task context outside the system.
    ///
    /// - Warning: Use it with care. It's not meant for production.
    
    @MainActor
    @Observable
    final class Model<State, Event> {
        var state: State
        
        private let _send: (Model, Event) -> Void
              
        /// Initialise the actor with an initial value for the state and a transduce function.
        init<Env>(
            intialState: State,
            env: Env,
            transduce: @escaping (_ state: inout State, _ event: Event) -> [Effect<Event, Env>]
        ) {
            self.state = intialState
            
            self._send = { model, event in
                let effects = transduce(&model.state, event)
                effects.forEach { effect in
                    Task { @MainActor [weak model] in
                        guard let model = model else { return }
                        model.send(await effect.invoke(env: env))
                    }
                }
            }
        }
        
        /// Sends the give event into the system.
        ///
        /// Once the function returns, the state has been changed according the transition function.
        func send(_ event: Event) {
            _send(self, event)
        }
    }

}

enum API {
    static func update(toggle1: Bool, toggle2: Bool) async throws -> (Bool, Bool) {
        try await Task.sleep(for: .milliseconds(1000))
        return (!toggle1, !toggle2)
    }
}


© www.soinside.com 2019 - 2024. All rights reserved.