You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何在@Published属性的didSet中赋值会引发无限循环?

问题:@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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 13:09:55