肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

解决Xcode Cloud无法enable Swift Package包中的宏的问题

Swift Packages

最近我在做应用适配iOS/macOS 26的特性。今天在Xcode Cloud打包的时候遇到打包失败的错误。

Macro “DefaultsMacrosDeclarations” from package “Defaults” must be enabled before it can be used.

这个问题是我应用所使用的第三方的库 “Defaults”在其内部使用了宏。这个宏在Xcode本地编译时,需要用户手动点击确认才能继续。但是Xcode Cloud中,没有点击确认的位置。因此,就无法完成打包应用的过程。

解决办法

通过运行脚本的方式,在克隆完文件夹之后,运行脚本,规避掉对于宏的验证。

#!/bin/sh 
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES

必须在Xcode中的根部位置,创建一个新组,命名为ci_scripts,然后在这个组中创建ci_post_clone.sh,内容是上面的内容。

必须在Xcode的根部位置创建组,并且命名也不能错。

小插曲

我其实最开始是像GPT 4.1提出了这个问题。GPT 4.1的解答只对了一半。它提出了创建文件夹和脚本,文件夹是正确的,脚本名字是错误的。并且它也没有告诉需要在Xcode中创建组,而只是说在项目的根目录创建就可以。最后,它创建的脚本内容不完全正确。

之后我使用了Google搜索。Google搜索默认的AI总结的是正确的,但应该就是从stackoverflow里的答案总结的。我最后是看的SO里的回答,进行的总结。

另外,我建议你完整阅读下面的第一个引用。我使用了里面最为简便的方案。而非最安全的。也许你看了之后,会选择一条不同的手段。

引用文献

How do I trust a swift macro target for Xcode Cloud builds?

Writing custom build scripts

什么?AccentColor又闹幺蛾子了?

SwiftUI

一年以前,我就踩过一次AccentColor的坑。没想到,一年之后我又掉进来了。

SwiftUI下,TextField诡异失去Focus下样式的问题

事情的起因是这样的。因为苹果的新系统发布了嘛。我不能免俗的也要改进我之前的应用,添加对于新系统特性的一些支持之类的。

但是我在修改代码后测试时发现,当使用ZStack模拟弹窗之后,弹窗后面的视图的颜色会出错。我一开始以为是ZStack的问题,于是将模拟弹窗改成了.fullScreenCover的方式。这个问题在当时看起来时解决了。但是今天我在使用中发现,这个问题又重新出现了。

于是我在Google上搜索了一下,没想到这还是一个SwiftUI长期存在的一个问题。

How do I stop the AccentColor from turning Gray when a sheet is being presented?

原来在SwiftUI中实际使用时。原本应该一致的Color.accentColor和Color("AccentColor")在实际使用中是不一致的。说得更具体些,就是Color.accentColor会使用应用设置和用户的系统设置。而Color("AccentColor")则是将AccentColor作为颜色资源从Asset文件夹直接读取。因此,虽然它的名字也叫"AccentColor",但是实际上它只是名字叫"AccentColor"的一个颜色,你改成别的名字,比如"MyAppColor"也是一样的。虽然这样会失去Color.accentColor一些独特的个性,但是能保证颜色的一致,即颜色不会莫名其妙的改变。

系统弹sheet的时候,Color.accentColor会改变的问题,应该就是sheet本身可能存在某种机制,将应用内设置的Color.accentColor从Asset文件夹设置的内容,改成了系统默认设置的内容。比如下图红色圈起来的部分,就是系统允许用户自定义用户AccentColor偏好的地方。

accent_color

大模型又暴露了……

大模型

这真的不是危言耸听,这是真的骇人听闻。盘点一下我遇到大模型的智障行为,本文不定期更新。

大模型至今不了解SwiftUI的更新机制,必须要严格约束,小心使用

今天遇到了一个sheet弹窗之后,视图更新不同步的问题。由于我已经在提示词中,告诉了我倾向使用@Observable,而不是旧版的ObservableObject,所以一开始大模型创建了正确的@Observable class,然后在视图中使用@State创建了这个model的实例。

但是为什么还是出错了呢?那就要看使用时更具体的实现了。在开启sheet之前,大模型调用了一个准备函数,在这个函数中,对于model进行了一系列的初始化。具体的步骤是这样的,先新建一个model,然后修改它,然后用这个修改完成的model,替换为系统@State里的那个model。

我首先将问题描述给大模型,sheet首次打开的时候,显示的界面并不符合预期,应该有值的地方,实际上为空。但是如果关掉,再次打开,就又正确了。

大模型思考了一番,说这可能是sheet的机制造成,sheet有时会提前锁定一些值,这会造成打开视图时的不同步。它的建议是,在sheet打开的视图内部的onAppear中新增一个fallback的调用,重新检查并赋值。然后还说,如果这样还不行,可以考虑使用GCD延迟的方式,也就是使用DispatchQueme.main.async调用来实现改动。

试了,onAppear的确不行。但是我没有继续使用它建议的GCD延迟的方式。我问它是否还有其它的办法。

大模型想了想,说可以考虑将@Observable的class改成纯粹的struct的方式。然后就其次咔嚓地修改起来,我一看改动后的代码,妈呀!连mutating都搞出来了。SwiftUI的代码哪有这么写的啊?我果断点了Undo,取消了这次修改。

正确的方式

我再次看了一下代码,突然我发现了问题的所在。前面提到,在准备函数中,大模型的改动方式是“先新建一个model,然后修改它,然后用这个修改完成model,替换为系统@State里的那个model。”这个方式在SwiftUI中显然是错误的。SwfitUI中@State的class类型,你不能替换它,因为一替换之前的跟踪就被中断了。

所以我和大模型说,我们不应该“新建model,修改,然后替换@State”,而是应该直接修改@State中的变量。

大模型想了想,觉得我说得有道理,就修改了。然后还跟我说,如果我确定这个好用,那么onAppear那里的代码,就可以删掉了。

于是我先注释掉onAppear那里的代码,然后运行应用,果然一切都正常了。

小结

大模型并没有真正了解SwiftUI的更新机制,这部分的代码写的时候,它更像是一个拙略的模仿者。虽然这部分,我之前的领悟也不怎么深刻,但是我随着持续不断地学习和使用SwiftUI,现在在这方面,我有信心可以说,我可比大模型强多了。

大模型不擅长重构

这个说法听起来是反直觉的。大模型相比于人类,应该更擅长重构吧。为什么说它不擅长呢?

事实如此。昨天我想重构一个900多行的SwiftUI的文件,里面包含一个主视图,主视图下的视图组件,以及一个弹出视图。如果是开发者来重构这个文件,之需要新建文件,然后复制,粘贴,再删掉旧的就可以了。可以很快就完成。

但是我用大模型来重构。却接连几次失败。过程一般是这样。一开始大模型计划的挺好。

  • 我将会将文件拆分为以下几个文件。
  • 然后开始拆分,但是执行到5、6个的时候。
  • 弹出出错提示,上下文已经耗尽了。

我为了节省上下文,又单独增加了对于上下文节省的办法。每次重构一个文件,复制、粘贴、删除完了,验证有没有错误。有错误修复,没有再继续。并且可以抛弃掉使用过的上下文。

再次运行,稍微好了些,多实现几个文件,但是最后还是因为上下文不够而失败了。

小结

大模型不擅长重构,不是因为它无法规划重构。而是因为大模型重构时需要考虑大量的上下文信息,最终会因为上下文耗尽而无法继续。

苹果表无法解锁macOS问题的一个奇葩的解决办法

macOS

我实在无法找到更适当的词,只能用奇葩来形容这个解决办法。

我开始使用macOS 26 beta系列也有一段时间了,目前在使用的最新版本是beta 7。长久以来,我一直遇到一个有些奇怪的问题,就是每次系统睡眠之后唤醒,苹果表的解锁总是失败。但是进入系统之后,在需要苹果表解锁的其它情况下,比如打开密码应用或者钥匙串,双击苹果表侧键解锁的这个功能却总是能成功。

不过苹果表解锁也不总是失败。我有两台Mac mini,一台是M1,一台是M4,M1的解锁就总是成功,而M4这台就总是失败。

尝试解决这个问题

今天我突发奇想,想要在AI的辅助下解决这个问题。我首先怀疑的时候VPN软件。因为更新到最新的macOS 26 beta之后,原来使用Clash X Pro不好用了,我不得不换成了Clash Verge rev。我问AI有没有可能是VPN软件造成的。AI说有可能。让我关了它再试。我试了。还是一样。

之后我又和AI一起开启了macOS控制台应用,想尝试通过读取日志来找到问题的解决方案。最后发现了一个loginwindow的一条故障日志,故障(红色)是比错误(黄色)等级更高的错误。它说在创建main文件夹的某个临时文件夹时出错,可能是沙盒的问题。我当时也信了,因为我的M4是256G的,为了节省磁盘空间,我将home文件夹设置到了外置的SSD上。我想这种比较罕见的设置,可能是苹果没有考虑过。我甚至还差一点儿就去跟苹果反馈这个问题。但是后来我放弃了。因为我觉得每次输入密码也还好,不算很麻烦,就没有反馈。同时我在想,以后我再买新电脑,一定要多花些钱,买个大一些的硬盘。

山重水复疑无路

的确。我没能解决这个问题。我只是放下了它。然后,我开始着手解决我的另外一个开源应用的小问题。

App Helper,应用助手。是我开源在GitHub上的一个macOS的助手应用。它的其中一个功能,是一键切换HDR模式,即在开启和关闭之间切换。不过,我发现,我的显示器,虽然支持HDR,但是使用不同的连接方式它对于HDR的支持不同。比如用USB-C线直接连接,就不支持HDR,用HDMI或者DisplayPort线连接,就支持。

所以,我的目标是通过系统API检测,在不支持HDR的时候,隐藏这个一键切换的按钮。因为我当前是USB-C的连接,不支持HDR,所以我首先完成了这部分的代码。

因为我还需要测试支持HDR下的部分。于是我同时使用HDMI进行连接。macOS有一个问题,就是你用两根不同的线连接同一个显示器,但是在macOS看来你就是在使用双显示器。它的显示器设置中,同时显示出两台显示器,并且不能设置禁用其中的一个。这样就比较麻烦了。因为我虽然可以通过显示器上的信号源菜单来切换到不同的接口,但是存在一个主副窗口的问题。

最终没办法,我只能将Mac mini上的USB-C连接显示器的那根拔了下来。将HDMI的连接作为唯一的连接,这样可以方便我进行调试。

柳暗花明又一村

这次HDR的选项还是不能显示。经过调试我发现,必须显示器先打开HDR模式,苹果的API才能检测出显示器支持HDR,如果没有开启,那就检测不出来。

那也无妨,大不了我就跟原来一样,不检测了。我又测试一键切换的功能。结果这个功能也不好用。这就比较郁闷了。因为苹果本身并没有提供切换的HDR切换的API,我使用的是脚本调用控件的方式,这个方法生效的前提是控件的位置必须固定。我管它叫数格子,脚本的写法类似,找到xx组的xx格子,然后把它上面的开关打开/关闭。现在系统升级了,位置变了。苹果💊。

算了,心累。我打算彻底去掉这个功能。毕竟,苹果动动手指,我就得重新数格子,并且还需要考虑不同版本macOS的兼容性,实在得不偿失。

休息,休息一会儿

休息结束之后,重新唤醒已经睡眠的Mac,手表传来熟悉的解锁声。我居然成功解锁了这台M4。又试了几次,无论是睡眠之后立即解锁,还是睡眠了几小时之后再解锁,都是次次成功。

“这是为什么呢?”(蔡明)如是说。“排除了一切不可能,那么剩下的那个无论多么不合理,就是唯一的可能。”——福尔摩斯。

我做的最大改动只有一点,使用HDMI连接显示器,并且拿掉了USB-C的连线。所以,这个问题的原因就是使用USB-C连接显示器,会导致苹果表无法解锁macOS。这谁能想到啊,你说是不是奇葩的解决办法?

特别说明,这个USB-C连接显示器,就是两边都是USB-C接口。而HDMI连接,则是用Mac mini独有的HDMI接口连接显示器的HDMI接口。

理论上,这个USB-C除了传递数据,还能同时获得显示器传来最大90瓦的电量。不过由于Mac mini的USB-C不支持反向供电,所以没啥用。如果比MacBook,是可以同时供电的。但是不知道是不是这一点影响了苹果表的解锁。我不是硬件工程师,不敢妄言。但是觉得这个值得提一下。

macOS 26 Beta版至今发现的兼容性问题及解决方案

macOS

我是从beta 4开始当成主力机使用的,因此从beta 4开始记录。之后会不定期更新。

Beta 4

命令system_profiler的参数发生改变

之前要查看USB设备,需要使用

system_profiler SPUSBDataType

但是新系统中,参数改变了,变成了SPUSBHostDataType。并且输出的结果中的属性也有了变化,需要进行对应的修改。

应用伴随系统启动的方式,不同的方法在设置中显示不同

如果要应用伴随系统启动,现在有两种方式:

  1. 使用Login Item应用,注册一个Login Item辅助程序,系统启动它之后,由它来调用主应用。
  2. 直接在主应用中调用SMAppService.mainApp.register()。

方法1是很早就有的方式。方法2是后来新增的方式。不过在新系统中,如果你使用的是方法1,那么在设置的启动项中,只有后台有主应用的名字,系统自动其中中不会有。如果是使用的方法2,那么启动项和后台中都会有主应用的名字。

两种方式都可以成功伴随系统启动。

系统常驻菜单栏图标消失问题的解决(下方2025年7月29日有更新)

新系统中,我发现有的应用的菜单栏常驻图标会消失。进一步调查我发现,如果你的菜单栏常驻图标中使用attributedTitle,并且重复设置了相同的值,常驻图标就会消失。

临时解决方案是,每次更改之前比较值是否发生了改变,变了再重新设置。

Beta 4 2025年7月29日更新

系统常驻菜单栏图标消失问题的解决(更新版)

上面的临时解决方案虽然在系统运行时可以解决问题,但是一旦系统睡眠,唤醒之后还是可能出现同样的问题。经过进一步研究,我发现了更好的解决方案:

假设你的代码是类似这种

class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    self.statusItem = statusItem

    guard let button = statusItem.button else {
      fatalError()
    }
    
    // 其他代码
  }
}

那么将代码改成

class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    if self.statusItem == nil {
      self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    }

    guard let button = self.statusItem?.button else {
      fatalError()
    }
    
     // 其他代码
  }
}

我想问题应该是出在NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)这个函数。它可能没有正确的释放。导致同时存在多个实例了。

Beta 5 2025年8月7日更新

Beta 5修正了MacCatalyst下.fullScreenCover的显示问题。上一版,会透出后面视图的内容。

不要使用NSDecimalNumber.intValue

Swift

今天遇到了一件怪事。函数返回值始终是0,但是中间计算过程又没啥问题。

最终判定,就是在计算百分比时将Decimal计算的百分比,通过(decimal as NSDecimalNumber).intValue进行输出时出错的。

深入分析具体的原因,是因为Int的精度不足以转换NSDecimalNumber,所以就自动设置为0了。这个对于普通人来说,可能很难理解。毕竟结果也才46,怎么说Int的精度不足呢?但是事实就是如此,你可以简单地认为就是46后面的小数点之后的位数过多了。

解决的办法有有两个,一种是直接使用.doubleValue,然后取整。

let intValue = Int(nsDecimalNumber.doubleValue)

另一种则比较复杂,使用了专门的rounding函数。

let rounded = nsDecimalNumber.rounding(accordingToBehavior: NSDecimalNumberHandler(
    roundingMode: .plain,
    scale: 0,
    raiseOnExactness: false,
    raiseOnOverflow: false,
    raiseOnUnderflow: false,
    raiseOnDivideByZero: true
))
let intValue = rounded.intValue

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(接收外部对象)。

@State在初始化函数中的特殊性

原来Swift的Sheet里还不能这么做中,提到也可以使用初始化函数。但是我在修改使用为初始化函数之后,代码运行却不符合预期。于是我询问AI:

使用初始化函数的方式之后,始终显示的是xtTransUnits.isEmpty == true的分支,这是为什么呢?

#AI回答
问题出在初始化器的实现方式上。当前的实现使用了立即执行的闭包来设置 xtTransUnits,但因为 @State 属性在初始化器中的特殊性,这种方式并不会正确地设置初始值。

修改建议如下:

struct AITranslationView: View {
    // ... 其他属性 ...
    
    init(trees: [Tree<IDTree>]) {
        self.trees = trees
        let allUnits = Xliff_Tool.getXTTransUnits(from: trees)
        _xtTransUnits = State(initialValue: allUnits.filter { $0.target == nil || $0.target?.isEmpty == true })
    }
}

关键修改点:

  1. 使用 _xtTransUnits = State(initialValue: ...) 来正确初始化 @State 属性
  2. 移除了立即执行闭包,改为直接赋值
  3. 保持过滤逻辑不变

这样修改后,xtTransUnits 将被正确初始化,视图也会显示正确的分支。