为何在@Published属性的didSet中赋值会引发无限循环?
我用SwiftUI的Toggle控件控制服务器上的布尔值状态,在@Published属性的didSet观察器里发起网络请求持久化状态。请求成功时一切正常,但失败时把属性恢复为旧值会触发无限循环。这个问题只在属性被@Published、@State、@Binding等包装器修饰时出现。
普通属性可以通过代码控制值,设置时不会触发didSet无限调用——这本来就是didSet的设计初衷:支持验证、过滤结果并设置合法值。推测问题和属性包装器用Combine监听状态变化,持续触发观察器有关。
想问能不能阻止这种行为?如果不行,有什么处理方案?
示例代码
import SwiftUI import PlaygroundSupport class VM: ObservableObject { var loopBreaker = 0 var networkSuccess = false @Published var isOn: Bool = false { didSet { print("isOn: \(isOn), oldValue \(oldValue)") // 终止循环的测试代码 loopBreaker += 1 if loopBreaker > 4 { print("break loop!") networkSuccess.toggle() loopBreaker = 0 } /////////////////////////////////////////////// // 调用服务器存储状态 guard networkSuccess else { // 无限循环! isOn = oldValue return } } } var enabled: Bool = false { didSet { print("enabled: \(enabled), oldValue \(oldValue)") enabled = oldValue print("enabled: \(enabled), oldValue \(oldValue)") } } } struct ContentView: View { @ObservedObject var vm = VM() var body: some View { Toggle("Hello World", isOn: $vm.isOn) .onChange(of: vm.isOn) { vm.enabled = $0 } } } PlaygroundPage.current.setLiveView(ContentView())
输出结果
isOn: true, oldValue: false isOn: false, oldValue: true isOn: true, oldValue: false isOn: false, oldValue: true isOn: true, oldValue: false break loop! enabled: true, oldValue: false enabled: false, oldValue: false
简洁对比代码
class VM: ObservableObject { @Published var publishedBool: Bool = false { didSet { publishedBool = oldValue // 无限循环 - 需要注释掉才能看到nonWrappedBool的正常行为 } } var nonWrappedBool: Bool = false { didSet { nonWrappedBool = oldValue // 正常工作 - 必须注释掉publishedBool的didSet才能触发这里的逻辑 } } } struct ContentView: View { @ObservedObject var vm = VM() var body: some View { Toggle("Persist this state on server", isOn: $vm.publishedBool) .onChange(of: vm.publishedBool) { vm.nonWrappedBool = $0 // 除非注释掉publishedBool的didSet,否则不会触发这里的逻辑,因为无限循环先发生了 } } } PlaygroundPage.current.setLiveView(ContentView())
核心原因
@Published这类属性包装器的本质是在属性值变化时通过Combine发送通知,而didSet里重新赋值会再次触发属性包装器的通知逻辑,进而又调用didSet,形成循环。普通属性没有Combine的通知机制,所以不会出现这个问题。
方案1:操作底层存储变量,绕过属性包装器通知
直接操作@Published的底层存储(以下划线开头的自动生成变量),修改值时不会触发Combine通知,也就不会重复调用didSet;如果需要UI更新,手动调用objectWillChange.send()即可:
class VM: ObservableObject { // 底层存储变量,不对外暴露 private var _publishedBool: Bool = false // 对外的@Published属性 @Published var publishedBool: Bool { get { _publishedBool } set { let oldValue = _publishedBool _publishedBool = newValue print("publishedBool: \(newValue), oldValue: \(oldValue)") // 模拟网络请求失败 let networkSuccess = false guard networkSuccess else { // 直接修改底层存储,不触发@Published的自动通知 _publishedBool = oldValue // 手动发送通知,让UI更新回旧值 objectWillChange.send() return } // 请求成功时,更新服务器状态(这里省略具体逻辑) } } }
方案2:添加状态锁,避免重复触发
在didSet里加入一个标志位,标记当前是否正在执行恢复操作,防止循环调用:
class VM: ObservableObject { private var isRestoring = false @Published var isOn: Bool = false { didSet { // 如果正在恢复,执行完后重置标志位并退出 guard !isRestoring else { isRestoring = false return } print("isOn: \(isOn), oldValue: \(oldValue)") // 模拟网络请求失败 let networkSuccess = false guard networkSuccess else { // 设置标志位,避免下次didSet重复进入恢复逻辑 isRestoring = true isOn = oldValue return } // 请求成功时的逻辑 } } }
方案3:分离UI状态与服务器真实状态
维护两个独立变量:一个用于UI展示的@Published属性,另一个记录服务器的真实状态。网络请求失败时,将UI状态回滚到服务器状态,避免直接修改触发循环:
class VM: ObservableObject { // 存储服务器的真实状态 private var serverState: Bool = false // UI展示的状态 @Published var uiState: Bool = false { didSet { // 用异步任务发起网络请求,不阻塞主线程 Task { let success = await persistStateToServer(uiState) if !success { // 回滚到服务器的真实状态 uiState = serverState } else { // 请求成功,更新服务器状态记录 serverState = uiState } } } } // 模拟网络请求函数 private func persistStateToServer(_ state: Bool) async -> Bool { // 模拟请求失败,返回false try? await Task.sleep(nanoseconds: 1_000_000_000) return false } }
关于直接内存访问的说明
你提到的direct memory access其实就是直接操作属性包装器的底层存储变量。比如@Published var publishedBool: Bool会自动生成一个名为_publishedBool的底层存储变量,直接修改这个变量不会触发Combine的通知,也就不会触发didSet;如果需要UI同步更新,只需手动调用objectWillChange.send()来通知视图刷新即可。
内容的提问来源于stack exchange,提问作者Scott Wood

