肇鑫的技术博客

业精于勤,荒于嬉

SwiftUI重复刷新的问题可能找到了

我:
按照SwiftUI的刷新方式,如果有两个@State,然后在一个函数中分别为这两个@State对应的属性赋值,SwiftUI对应的View是会自动刷新1次,还是两次?

Claude Sonnet 4(C):
在SwiftUI中,如果你在一个函数中同时修改两个@State属性,View只会刷新1次,而不是两次。

这是因为SwiftUI有一个重要的优化机制:批量更新(Batch Updates)。当多个状态变化发生在同一个运行循环中时,SwiftUI会将这些变化合并成一次更新。

第一轮

让我用一个例子来说明:

struct ContentView: View {
    @State private var count1 = 0
    @State private var count2 = 0
    
    var body: some View {
        VStack {
            Text("Count1: \(count1)")
            Text("Count2: \(count2)")
            
            Button("同时更新两个值") {
                // 在同一个函数中修改两个@State
                count1 += 1
                count2 += 1
                
                print("函数执行完毕") // 这会在View刷新之前打印
            }
        }
    }
}

在这个例子中,点击按钮时:

1.	count1和count2都会被修改
2.	SwiftUI会将这两个状态变化合并
3.	View只会重新渲染1次,同时反映两个值的变化

这种机制的好处是:

•	性能优化:避免不必要的多次渲染
•	视觉连贯性:用户看到的是一次性的状态更新,而不是闪烁的分步更新
•	逻辑一致性:相关的状态变化作为一个整体呈现

如果你想要强制分开更新,可以使用DispatchQueue:

Button("分开更新") {
    count1 += 1
    
    DispatchQueue.main.async {
        count2 += 1  // 这会导致第二次刷新
    }
}

但在绝大多数情况下,SwiftUI的默认批量更新机制是最佳选择。

第二轮

我:
如果是@Observable这种,也会是批量刷新吗?

C:
是的,**@Observable也会进行批量刷新!**这是SwiftUI观察系统的一个核心特性,不仅适用于@State,也适用于@Observable。

让我用例子来演示:

@Observable
class UserData {
    var name = ""
    var age = 0
    var email = ""
}

struct ContentView: View {
    @State private var userData = UserData()
    
    var body: some View {
        VStack {
            Text("姓名: \(userData.name)")
            Text("年龄: \(userData.age)")
            Text("邮箱: \(userData.email)")
            
            Button("同时更新多个属性") {
                // 在同一个函数中修改多个@Observable属性
                userData.name = "张三"
                userData.age = 25
                userData.email = "zhangsan@example.com"
                
                print("所有属性更新完毕") // 这会在View刷新之前打印
            }
        }
    }
}

在这个例子中,虽然修改了3个不同的属性,但View只会刷新1次

@Observable的批量更新机制

@Observable使用了Swift的新观察系统(Observation framework),它具有以下特点:

1.	**事务性更新:**多个属性变化会被合并成一个更新事务
2.	**智能依赖追踪:**只有被View实际使用的属性变化才会触发刷新
3.	**性能优化:**避免了不必要的多次渲染

更高级的例子

@Observable
class ShoppingCart {
    var items: [String] = []
    var totalPrice: Double = 0.0
    var discountApplied: Bool = false
    
    func addItem(_ item: String, price: Double) {
        // 这些修改会被批量处理
        items.append(item)
        totalPrice += price
        
        if totalPrice > 100 {
            discountApplied = true
            totalPrice *= 0.9 // 应用10%折扣
        }
    }
}

struct ShoppingView: View {
    @State private var cart = ShoppingCart()
    
    var body: some View {
        VStack {
            Text("商品数量: \(cart.items.count)")
            Text("总价: \(cart.totalPrice, specifier: "%.2f")")
            Text("折扣: \(cart.discountApplied ? "已应用" : "未应用")")
            
            Button("添加昂贵商品") {
                cart.addItem("iPhone", price: 120.0)
                // 即使函数内部修改了多个属性,View也只刷新1次
            }
        }
    }
}

关键要点

•	**批量更新是自动的:**无需特殊配置
•	**跨属性合并:**同一对象的多个属性变化会合并
•	**运行循环边界:**更新会在当前运行循环结束时统一执行
•	**性能友好:**这种机制显著提升了UI响应性能

所以无论是@State、@Observable,还是其他SwiftUI的状态管理方式,都会享受到这种批量刷新的优化机制。

第三轮

是的,@ObservedObject确实在性能上相对较差,主要原因是它基于较老的ObservableObject协议和Combine框架。关于批量刷新,情况比较复杂:

@ObservedObject的批量刷新行为

@ObservedObject理论上也支持批量刷新,但实际效果取决于具体实现:

class UserDataOld: ObservableObject {
    @Published var name = ""
    @Published var age = 0
    @Published var email = ""
}

struct ContentView: View {
    @ObservedObject var userData = UserDataOld()
    
    var body: some View {
        VStack {
            Text("姓名: \(userData.name)")
            Text("年龄: \(userData.age)")
            Text("邮箱: \(userData.email)")
            
            Button("同时更新多个属性") {
                userData.name = "李四"
                userData.age = 30
                userData.email = "lisi@example.com"
                // 这通常会触发1次刷新,但不如@Observable稳定
            }
        }
    }

@ObservedObject的性能问题

1. 过度刷新问题

class ProblematicModel: ObservableObject {
    @Published var importantData = ""
    @Published var trivialData = "" // 即使只是这个改变
    
    func updateTrivialData() {
        trivialData = "无关紧要的数据"
        // 整个View都会刷新,即使View可能不关心这个属性
    }
}

2. 无法精确追踪依赖

struct InefficientView: View {
    @ObservedObject var model = ProblematicModel()
    
    var body: some View {
        VStack {
            Text("重要数据: \(model.importantData)")
            // 即使这个View只使用importantData
            // 但model中任何@Published属性变化都会导致刷新
        }
    }
}

与@Observable的对比

// 老式方式 - @ObservedObject
class OldUserData: ObservableObject {
    @Published var profile = ""
    @Published var settings = ""
    @Published var cache = "" // 改变这个会刷新整个View
}

// 新式方式 - @Observable  
@Observable
class NewUserData {
    var profile = ""
    var settings = ""
    var cache = "" // 只有被使用的属性改变才会刷新View
}

struct ComparisonView: View {
    @ObservedObject var oldData = OldUserData()
    @State private var newData = NewUserData()
    
    var body: some View {
        VStack {
            // 使用oldData时,任何@Published属性变化都会刷新
            Text("老数据: \(oldData.profile)")
            
            // 使用newData时,只有profile变化才会刷新这个Text
            Text("新数据: \(newData.profile)")
            
            Button("更新缓存") {
                oldData.cache = "新缓存" // 会刷新整个View
                newData.cache = "新缓存" // 不会刷新View,因为没有Text使用cache
            }
        }
    }
}

性能优化建议

1. 迁移到@Observable

// 推荐的现代方式
@Observable
class ModernUserData {
    var name = ""
    var age = 0
    var email = ""
}

2. 如果必须使用@ObservedObject,手动控制更新

class OptimizedModel: ObservableObject {
    // 只对重要数据使用@Published
    @Published var criticalData = ""
    
    // 不重要的数据不使用@Published
    private var _cacheData = ""
    var cacheData: String {
        get { _cacheData }
        set { 
            _cacheData = newValue
            // 不会触发View刷新
        }
    }
}

总结

•	**@ObservedObject:**支持批量刷新,但容易过度刷新,性能较差
•	**@Observable:**精确的依赖追踪,更好的批量刷新,性能优秀
•	**建议:**新项目优先使用@Observable,老项目逐步迁移

@Observable是SwiftUI未来的方向,它解决了@ObservedObject的大部分性能问题。

第四轮

我:
我注意到,你在使用@Observable的类时,使用了@State,@State private var newData = NewUserData(),而不是let或者var,这是为什么呢?

C:
这是一个非常好的观察!你提到的这个细节很重要,让我详细解释一下:

为什么使用@State而不是let或var

1. @Observable需要被SwiftUI观察系统追踪

@Observable
class UserData {
    var name = ""
    var age = 0
}

struct ContentView: View {
    // ❌ 错误方式 - 不会触发View更新
    let userData = UserData()
    
    // ❌ 错误方式 - 也不会触发View更新  
    var userData = UserData()
    
    // ✅ 正确方式 - 会触发View更新
    @State private var userData = UserData()
    
    var body: some View {
        VStack {
            Text("姓名: \(userData.name)")
            
            Button("更新姓名") {
                userData.name = "张三"
                // 只有使用@State时,这个变化才会触发View刷新
            }
        }
    }
}

2. SwiftUI的观察机制需要包装器

@Observable虽然让类具备了被观察的能力,但在SwiftUI中,View仍然需要通过属性包装器来建立观察关系:

@Observable
class Counter {
    var value = 0
    
    func increment() {
        value += 1
    }
}

struct CounterView: View {
    // @State告诉SwiftUI:"请观察这个对象的变化"
    @State private var counter = Counter()
    
    var body: some View {
        VStack {
            Text("计数: \(counter.value)")
            
            Button("增加") {
                counter.increment()
                // 因为用了@State,View会自动刷新
            }
        }
    }
}

3. 不同场景下的用法对比

@Observable
class AppState {
    var isLoggedIn = false
    var username = ""
}

struct LoginView: View {
    // 场景1: View拥有并管理对象的生命周期
    @State private var appState = AppState()
    
    var body: some View {
        VStack {
            if appState.isLoggedIn {
                Text("欢迎, \(appState.username)")
            } else {
                loginForm
            }
        }
    }
    
    var loginForm: some View {
        Button("登录") {
            appState.username = "用户"
            appState.isLoggedIn = true
        }
    }
}

struct SharedDataView: View {
    // 场景2: 对象从外部传入
    let sharedAppState: AppState
    
    var body: some View {
        VStack {
            Text("共享状态: \(sharedAppState.username)")
            // 注意:这里不会自动刷新,因为没有使用属性包装器
        }
    }
}

4. 正确的共享数据方式

如果需要在多个View之间共享@Observable对象:

@Observable
class SharedAppState {
    var theme = "light"
    var language = "zh"
}

struct RootView: View {
    @State private var appState = SharedAppState()
    
    var body: some View {
        NavigationView {
            VStack {
                SettingsView(appState: appState)
                ProfileView(appState: appState)
            }
        }
    }
}

struct SettingsView: View {
    @Bindable var appState: SharedAppState  // 用于接收外部传入的@Observable对象
    
    var body: some View {
        VStack {
            Text("当前主题: \(appState.theme)")
            Button("切换主题") {
                appState.theme = appState.theme == "light" ? "dark" : "light"
            }
        }
    }
}

总结

•	**@State:**告诉SwiftUI”我拥有这个对象,请观察它的变化”
•	**@Bindable:**告诉SwiftUI”这个对象是从外部传入的,请观察它的变化”
•	**let/var:**普通属性,SwiftUI不会观察其变化

使用@State是为了让SwiftUI知道需要观察这个@Observable对象,从而在对象属性发生变化时自动更新View。这是SwiftUI观察系统的要求,而不是@Observable本身的限制。

第五轮

我:
你上面的说法有一部分并不准确。在苹果的官方文档中,https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro/,苹果用来替代@StateObject的是@State,用来替代@ObservedObject的,就是var。

C:
你说得完全正确!我之前的解释确实有不准确的地方。感谢你提供的官方文档链接,让我重新准确地解释:

正确的迁移对应关系

根据苹果官方文档,从ObservableObject迁移到@Observable的对应关系是:

// 旧的ObservableObject方式
class OldDataModel: ObservableObject {
    @Published var name = ""
}

struct OldView: View {
    @StateObject private var model = OldDataModel()  // 创建并拥有
    @ObservedObject var shared: OldDataModel        // 接收外部传入
}

// 新的@Observable方式  
@Observable
class NewDataModel {
    var name = ""
}

struct NewView: View {
    @State private var model = NewDataModel()  // ✅ 替代@StateObject
    var shared: NewDataModel                   // ✅ 替代@ObservedObject (普通var!)
}

关键区别解释

1. @State替代@StateObject

struct OwnerView: View {
    // 创建并拥有对象的生命周期
    @State private var userData = UserData()
    
    var body: some View {
        UserDetailView(user: userData)
    }
}

2. 普通var替代@ObservedObject

struct UserDetailView: View {
    // 接收外部传入的@Observable对象 - 就是普通var!
    var user: UserData
    
    var body: some View {
        VStack {
            Text(user.name)
            Button("更新") {
                user.name = "新名字"  // 这会自动触发View更新!
            }
        }
    }
}

为什么普通var就能工作?

这是因为@Observable宏为类生成了观察机制,SwiftUI会自动检测到对@Observable对象的访问和修改:

@Observable
class UserData {
    var name = ""
    var age = 0
}

struct DetailView: View {
    var userData: UserData  // 普通属性,无需@ObservedObject
    
    var body: some View {
        VStack {
            Text(userData.name)     // SwiftUI自动追踪这个访问
            Text("\(userData.age)")  // SwiftUI自动追踪这个访问
            
            Button("更新") {
                userData.name = "新名字"  // 自动触发刷新
                userData.age += 1        // 自动触发刷新
            }
        }
    }
}

完整的迁移示例

// === 旧方式 ===
class OldShoppingCart: ObservableObject {
    @Published var items: [String] = []
    @Published var total: Double = 0
}

struct OldShoppingView: View {
    @StateObject private var cart = OldShoppingCart()
    
    var body: some View {
        OldCartDetailView(cart: cart)
    }
}

struct OldCartDetailView: View {
    @ObservedObject var cart: OldShoppingCart  // 需要@ObservedObject
    
    var body: some View {
        VStack {
            Text("商品数: \(cart.items.count)")
            Text("总价: \(cart.total)")
        }
    }
}

// === 新方式 ===
@Observable
class ShoppingCart {
    var items: [String] = []
    var total: Double = 0
}

struct ShoppingView: View {
    @State private var cart = ShoppingCart()  // @State替代@StateObject
    
    var body: some View {
        CartDetailView(cart: cart)
    }
}

struct CartDetailView: View {
    var cart: ShoppingCart  // 普通var替代@ObservedObject!
    
    var body: some View {
        VStack {
            Text("商品数: \(cart.items.count)")
            Text("总价: \(cart.total)")
            
            Button("添加商品") {
                cart.items.append("新商品")  // 自动触发更新
                cart.total += 10.0          // 自动触发更新
            }
        }
    }
}

感谢你的纠正!@Observable的强大之处就在于,接收外部传入的对象时只需要普通的var属性,SwiftUI会自动处理观察关系。

第六轮

我:
我有一些疑惑,@StateObject和@ObservedObject似乎没有什么区别啊。

C:
你的疑惑很正常!从表面上看,@StateObject和@ObservedObject确实很相似,但它们有一个关键的区别:对象的生命周期管理

核心区别:谁拥有对象?

@StateObject - “我创建并拥有这个对象”

struct ParentView: View {
    @State private var counter = 0
    
    var body: some View {
        VStack {
            Text("父视图计数: \(counter)")
            
            // 每次counter变化,ChildView都会重新创建
            ChildView(parentCounter: counter)
            
            Button("增加父计数") {
                counter += 1  // 这会导致ChildView重建!
            }
        }
    }
}

class DataModel: ObservableObject {
    @Published var value = 0
    
    init() {
        print("DataModel创建了!")
    }
    
    deinit {
        print("DataModel销毁了!")
    }
}

struct ChildView: View {
    let parentCounter: Int
    
    // ❌ 错误用法 - 每次父视图刷新都会重新创建DataModel
    @ObservedObject private var wrongModel = DataModel()
    
    // ✅ 正确用法 - 无论父视图如何刷新,DataModel只创建一次
    @StateObject private var correctModel = DataModel()
    
    var body: some View {
        VStack {
            Text("父传入的值: \(parentCounter)")
            Text("子视图数据: \(correctModel.value)")
            
            Button("增加子数据") {
                correctModel.value += 1
            }
        }
    }
}

实际问题演示

运行上面的代码,你会发现:

使用@ObservedObject的问题:

struct ProblematicChildView: View {
    let parentCounter: Int
    @ObservedObject private var model = DataModel()  // ❌ 问题代码
    
    var body: some View {
        VStack {
            Text("数据: \(model.value)")
            Button("增加") {
                model.value += 1
            }
        }
    }
}

**问题:**每当父视图刷新时,DataModel()会被重新执行,创建新对象,之前的数据丢失!

使用@StateObject的解决方案:

struct CorrectChildView: View {
    let parentCounter: Int
    @StateObject private var model = DataModel()  // ✅ 正确代码
    
    var body: some View {
        VStack {
            Text("数据: \(model.value)")
            Button("增加") {
                model.value += 1  // 数据会被保持,不会因父视图刷新而丢失
            }
        }
    }
}

完整的对比示例

class Timer: ObservableObject {
    @Published var count = 0
    private var timer: Foundation.Timer?
    
    init() {
        print("⚠️ Timer对象创建")
        startTimer()
    }
    
    deinit {
        print("💥 Timer对象销毁")
        timer?.invalidate()
    }
    
    private func startTimer() {
        timer = Foundation.Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.count += 1
        }
    }
}

struct MainView: View {
    @State private var refreshTrigger = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("主视图刷新触发器: \(refreshTrigger)")
            
            // 每次点击都会导致子视图重建
            BadTimerView(trigger: refreshTrigger)
            GoodTimerView(trigger: refreshTrigger)
            
            Button("触发父视图刷新") {
                refreshTrigger += 1
            }
        }
    }
}

struct BadTimerView: View {
    let trigger: Int
    @ObservedObject private var timer = Timer()  // ❌ 每次父视图刷新都重新创建
    
    var body: some View {
        VStack {
            Text("错误方式 - 计时: \(timer.count)")
            Text("触发值: \(trigger)")
        }
        .border(Color.red)
    }
}

struct GoodTimerView: View {
    let trigger: Int
    @StateObject private var timer = Timer()  // ✅ 只创建一次,状态保持
    
    var body: some View {
        VStack {
            Text("正确方式 - 计时: \(timer.count)")
            Text("触发值: \(trigger)")
        }
        .border(Color.green)
    }
}

使用规则总结

struct MyView: View {
    // ✅ 我创建并拥有这个对象
    @StateObject private var myData = MyDataModel()
    
    // ✅ 这个对象是从外部传入的
    @ObservedObject var sharedData: SharedDataModel
    
    var body: some View {
        ChildView(data: myData)  // 传递给子视图
    }
}

struct ChildView: View {
    // ✅ 接收从父视图传来的对象
    @ObservedObject var data: MyDataModel
    
    var body: some View {
        Text("\(data.value)")
    }
}

简单记忆法:

•	**@StateObject:**我创建 = 我负责
•	**@ObservedObject:**别人创建 = 我只观察

这就是为什么在迁移到@Observable时,@StateObject对应@State(创建并拥有),@ObservedObject对应普通var(接收外部对象)。

原来Swift的Sheet里还不能这么做

今天在调试一个应用崩溃的问题的时候,发现有个sheet弹出后,被反复执行了多次。于是询问AI怎么回事。

原始的代码是这样的

.sheet(isPresented: $showAITranslationGuideView) {
  let xtTransUnits: [XTTransUnit] = {
    let xtTransUnits = Xliff_Tool.getXTTransUnits(from: trees)
    return xtTransUnits.filter { $0.target == nil || $0.target?.isEmpty == true }
  }()

  AITranslationView(xtTransUnits: xtTransUnits)
}

询问AI啥原因,AI是这么回答的。

.sheet(isPresented: $showAITranslationGuideView)为什么会多次执行?

在 .sheet(isPresented: $showAITranslationGuideView) 代码块中,每次 showAITranslationGuideView 状态变化时都会重新执行闭包内的代码。这里的问题是 xtTransUnits 的计算被放在了视图构建的闭包中,而不是在实际需要时才执行。

建议将这些逻辑移到 AITranslationView 的初始化或 onAppear 中。以下是修改建议:

.sheet(isPresented: $showAITranslationGuideView) {
    AITranslationView(trees: trees)
}

然后在 AITranslationView 中处理数据:

struct AITranslationView: View {
    let trees: [Tree<IDTree>]
    @State private var xtTransUnits: [XTTransUnit] = []
    
    var body: some View {
        // 你的视图内容
        .onAppear {
            xtTransUnits = getXTTransUnits(from: trees)
                .filter { $0.target == nil || $0.target?.isEmpty == true }
        }
    }
}

这样可以确保计算只在实际需要时执行一次。

需要注意的是,AI的解释是对的,但是不是真正的原因。但是解法又同样是对的。只要移出这个初始化的代码之后,的确就不会重复计算了。

我认为真正原因是这样的。像.sheet(isPresented: $showAITranslationGuideView)的sheet,是在View初始化时就会被生成的。它是不变的。这就是为什么还有一个同名的但是使用Optional的sheet,那个才是动态的。但是我这里面的代码,在sheet使用前重新进行了计算。这导致原本应该只能是静态的sheet,变成了动态的,从而导致它从新被加载,而这种情形,其实是为定义的,因为这里不应该使用动态的。这个才是出错的真正原因。

TabView自动跳转Tab问题的解决

今天我在开发应用的时候遇到了一个奇怪的问题。当TabView的最后一个tab被拖动图片之后,应用会自动跳到第一个tab去。我很奇怪为什么有这个行为,于是拿来问AI。没想到很快就解决了。

提示词:阅读代码,尝试解决问题。当最后一个tab被拖动图片后,它会自动跳到第一个tab,我不明白这是什么原因造成的。

AI回复

问题分析

我已经查看了您的代码,找到了为什么当最后一个watchOSTabView被拖动图片后会自动跳回第一个标签页的问题。

这是由于在TabView中没有设置选中的标签页状态,所以当watchOSTabView中的State变量更新时,视图会重新绘制,导致TabView回到默认的第一个标签页。

解决方案

我们需要在MainView中添加一个State变量来跟踪当前选中的标签页,并将其绑定到TabView。这样,即使视图重新绘制,也会保持在当前选中的标签页上。

//
//  MainView.swift
//  My Icon Gear
//
//  Created by zhaoxin on 2025/3/5.
//

import SwiftUI

struct MainView: View {
  // 添加一个状态变量来跟踪选中的标签页
  @State private var selectedTab = 0
  private let platforms = ["iOS", "macOS", "watchOS"]

  var body: some View {
    TabView(selection: $selectedTab) {
      macOSTabView()
        .tag(0)
      iOSTabView()
        .tag(1)
      watchOSTabView()
        .tag(2)
    }
    .toolbar(content: {
      Button {

      } label: {
        Image(systemName: "square.and.arrow.up")
        Text("Export to Folder")
      }
      .bold()
    })
    .padding()
  }
}

#Preview {
  MainView()
}

解决方案解释

我对MainView.swift文件进行了以下修改:

  1. 添加了一个@State private var selectedTab = 0状态变量来跟踪当前选中的标签页。

  2. 修改了TabView,使用TabView(selection: $selectedTab)将选中状态绑定到TabView。

  3. 为每个TabView的子视图添加了.tag()修饰符,分别标记为0、1、2,这样系统就能知道哪个标签页被选中。

这样修改后,当您在watchOSTabView(最后一个标签页)上拖放图片时,即使视图状态发生变化并重新绘制,TabView也会保持在当前选中的标签页(即tag为2的watchOSTabView),而不会跳回第一个标签页。

这是因为现在TabView的选择状态由selectedTab变量控制,而不是由系统自动决定。无论是哪个子视图更新状态并触发重绘,TabView都会保持在用户上次选择的标签页。