肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

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

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里还不能这么做

SwiftUI

今天在调试一个应用崩溃的问题的时候,发现有个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问题的解决

SwiftUI

今天我在开发应用的时候遇到了一个奇怪的问题。当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都会保持在用户上次选择的标签页。

Runloop的各种模式的区别

Swift

薅到AI的羊毛了。👀今天AI在编写Timer相关的代码时,手动添加了loop,让我看不懂,于是我询问AI,它的解释让我学到了。

能否解释一下“确保计时器在RunLoop中运行”的必要性这断代码的必要性。

关于RunLoop中运行Timer的必要性

在Swift中,关于这段代码的必要性:

if let timer = autoCheckTimer {
  RunLoop.main.add(timer, forMode: .common)
}

这段代码确实非常重要,让我解释一下为什么:

为什么需要将Timer添加到RunLoop中

当你使用Timer.scheduledTimer创建计时器时,它会自动添加到当前线程的RunLoop中,但仅使用默认模式(.default)。这在大多数情况下已经足够,但在某些特定情况下可能会导致计时器无法按预期工作:

  1. 滚动期间计时器暂停:当用户在UI上执行滚动操作时,RunLoop会切换到.tracking模式,这时默认模式下的计时器会暂时停止触发。

  2. 模态操作期间计时器暂停:某些模态操作(如拖拽)也会导致RunLoop切换模式,影响计时器的正常运行。

  3. 确保可靠性:显式添加计时器到RunLoop可以确保在不同的应用状态下计时器都能正常工作。

通过将计时器添加到.common模式,我们确保它在多种常见的RunLoop模式下都能正常触发,提高了计时器的可靠性和稳定性。

最佳实践

尽管scheduledTimer方法会自动将计时器添加到当前RunLoop,但明确地将计时器添加到RunLoop的.common模式是一种最佳实践,可以避免在复杂应用场景下出现计时器不按预期工作的问题。

特别是对于像自动检查更新这样需要在后台可靠运行的功能,确保计时器在各种应用状态下都能正常触发是非常重要的。

SwiftData初探

SwiftData

没想到我还是入了SwiftData的坑。没想到坑还挺深。没想到苹果连最基本的Xcode的模版应用都没有做好。没想到连苹果的文档说明都不全。

最近我开了一个新坑,是一个背单词的小应用。因为是新应用,而且需要转换词库文件从json到数据库,于是选择了SwiftData作为中介。问题由此而来。

按照惯例,我本以为新应用使用SwiftData会很简单。只需要在应用创建时,选择SwiftData,然后选中使用iCloud同步,最后选择创建应用就可以了。

结果是不行。

这么创建的其实只包括本地的部分以及使用iCloud的部分配置。效果是并没能真正开启iCloud同步。

苹果做了什么

苹果创建了模型,但是模型不符合CloudKit的规范

众所周知,苹果程序模版创建的模型是一个叫Item的类。在Core Data时,它创建的与iCloud同步的模版应用是可用的。但是在SwiftData时,模版创建的应用实际上iCloud的同步不可用。

https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices

如同上面的链接所说,iCloud因为不定期同步的关系。它必须要求所有属性要么有默认值,要么是Optional。(这一点与Core Data一致)

但是苹果模版程序创建的模版Item的属性却是Date,而非Date?

修改了这个问题之后,我们再根据Xcode的提示修改相关的几处代码,直到Xcode可以编译通过。

苹果创建了部分CloudKit同步的设置,但是设置不全

这里的问题就更大了。因为文档说得也不够全面。根据上面链接的文档,SwiftData同步需要3步:

  1. 获取iCloud权限,激活CloudKit,创建一个或选择一个已有的Container。
  2. 设置后台运行模式,激活远程通知选项。
  3. 修改ModelConfiguration()设置。

下面我来依次说说这三点里哪些苹果没做到,哪些苹果太啰嗦,哪些又是苹果所遗漏的。

苹果没做到

第一条:获取iCloud权限,激活CloudKit,创建一个或选择一个已有的Container。
苹果做了前两项,然后没做第三项。我们需要自己创建或者选择一个已有的容器。

苹果太啰嗦

第三条:修改ModelConfiguration()设置。
苹果针对这条的讲解没有错。但是太过啰嗦。苹果的示例是手工添加一个私有的数据库用于同步。但是其实如果你只使用一个数据库,那么就没必要这么做。因为SwiftData默认就是使用你选择的那个唯一的数据库。

苹果所遗漏的

做完了以上的那些。你会发先你的数据库内容仍旧没有同步。这是为什么呢?这就是苹果所遗漏的。同步数据库我们需要什么?需要上网啊。因此,你必须还要打开网络功能的Client权限。所谓Client权限就是你的应用访问外部的权限。如果你的应用还提供服务供其他设备访问,你还需要打开Server权限。

好了。经过以上的补救措施。苹果的模版程序算是完整了。可以正常的运行并同步了。

使用CloudKit时,SwiftData会有哪些改变?

还是上面的那个链接。那个链接的内容非常重要,一定要看。

当开启了iCloud同步之后,SwiftData会失去以下功能。

  • @Attribute,失去unique的属性。苹果说这是因为CloudKit的同步原理本身造成了它没法保证属性unique不重复。
    • 因此,我们在开发时,需要保证数据的unique性。即不能依赖unique性来添加数据,而应该先查询,没有再添加。
  • @Relationship,这里有3点
    • Optional。上面提到了
    • SwiftData automatically sets the inverse of a relationship if it can reliably infer that inverse from your schema.这里之所以把苹果的原文放上来,是因为这句话具有迷惑性。初看这句话的时候,我认为苹果说的意思是,只要两个属性彼此拥有对方,SwiftData就会自动隐藏添加inverse的关系。比如像这样
@Model
final class Owner {
  var name: String?
  var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

但是在之前的使用中,我发现这样的代码,pet添加了owner属性之后,owner里仍然是看不到pet的。

我让AI根据出错信息,修改代码,AI给出的如下:

@Model
final class Owner {
  var name: String?
  @Relationship(deleteRule: .cascade, inverse:\Pet.owner) var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  @Relationship(deleteRule: .cascade, inverse: \Owner.pets) var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

然后Xcode提示,这么写也是错的。因为双方都使用inverse,会导致循环引用。

那么怎么做才是对的呢?经过摸索,我发现是这样。

@Model
final class Owner {
  var name: String?
  @Relationship(deleteRule: .cascade) var pets: [Pet]? = []

  init(name: String) {
    self.name = name
  }
}

@Model
final class Pet {
  var name: String?
  var species: String?
  @Relationship(deleteRule: .cascade, inverse: \Owner.pets) var owner: Owner?

  init(name: String, species: String) {
    self.name = name
    self.species = species
  }
}

为什么呢?因为“SwiftData automatically sets the inverse of a relationship if it can reliably infer that inverse from your schema.” 这句话,直译就是“SwiftData会自动设置关系的逆数,如果它可以从您的模式中可靠地推断出该逆数。”看明白了吗?“SwiftData会自动设置”的前提是“它可以从你的模式中可靠地推断出来该逆数”。因此,一个inverse不写,SwiftData就不会尝试推断。两个都写,就会导致循环引用。所以,唯一正确的方式就是只写一边。这对于从Core Data过来的我十分不适应。因为Core Data是自动两边的。如果你没做到,它虽然允许,但是会用黄色的感叹号提示你。

这里充分证明了,学不好英语,理解不了字里行间的意思,那么写代码就会遇到瓶颈。

  • 差点忘记了。还有第三条呢。Relationship的第三条是说,同样是因为受到同步的限制,CloudKit不支持Schema.Relationship.DeleteRule.deny的关系。
    • 这一条也就是说,我们在删除之前,需要自己检测,而不能依赖这一条轻易删除。

JSON转Model的一些技巧

Swift

当JSON文件存在字典时,需要为字典的value创建单独的容器。比如:

{
  "pets": {
    "dog": {
      "name": "Bark",
      "color": "Black"
    },
    "cat": {
      "name": "Miao",
      "color": "White",
      "habit": "play papers"
    }
  }
}

对应的Model应该是

struct Model: Codable {
  var pets: [String: PetContainer]
}

struct PetContainer: Codable {
  var name: String
  var color: String
  var habit: String?
}

增加一层

{
  "animals": {
    "pets": {
      "dog": {
        "name": "Bark",
        "color": "Black"
      },
      "cat": {
        "name": "Miao",
        "color": "White",
        "habit": "play papers"
      }
    }
  }
}

对应model:

struct Animal: Codable {
  var animals: [String: [String: PetContainer]]
}

再增加一层

{
  "object": {
    "animals": {
      "pets": {
        "dog": {
          "name": "Bark",
          "color": "Black"
        },
        "cat": {
          "name": "Miao",
          "color": "White",
          "habit": "play papers"
        }
      }
    }
  }
}

对应model:

struct MyObject: Codable {
  var object: [String: PetsContainer]
}

struct PetsContainer: Codable {
  var pets: [String: PetContainer]
}

Codable和@Observable/ObservableObject同时使用时的问题

Swift

我们在通过struct使用Codable的时候,是有默认实现的。但是如果class,就会麻烦一些。需要创建init函数。如果还要使用@Observable/ObservableObject,那就会更麻烦了。因为@Observable/ObservableObject会自动生成带_(下划线)的内部属性。这在解码的时候会报错。

此时就只能完全手写Codable才可以了。

List更改选中状态的背景色正确办法

SwiftUI

macOS中,默认List选中之后的背景是深蓝色。这会导致我的按钮变得不明显,我希望修改这个背景色。

询问AI,AI给我的反馈是通过background修改。但是通过background修改的背景色,会比系统自带的小一圈,十分丑陋。我再次询问AI,如果要修改系统的默认选中背景,要如何做。AI回答说,要修改那个很复杂。然后给了我一个错误的解法。

我只要换个AI来问。第二个AI给出的也是通过background修改。不过它在说思路的时候,应该是通过listRowBackground修改。于是我自己尝试用listRowBackground来修改。这次成功了。

.listRowBackground(selectedLanguage == locale ? Color.gray.opacity(0.15) : Color.clear)

当使用listRowBackground,List的选中就不要绑定selectedLanguage了。因为那样会导致.listRowBackground和高亮色被同时使用。

高亮色就是List选中的背景色的正式说法。

因为上面说的原因,我们需要手动指令selectedLanguage,可以通过点击或者onHover。看你的界面的需要。