我的 SwiftUI 视图有以下要求:
这是我的代码(在可观察对象中):
@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
您可以通过避免双向绑定并使用单向模式(如 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)
}
}