肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

警告:iOS 26.6之后SwiftUI注册的后台任务有问题

SwiftUI

最近我在 SleepTap 上遇到了一个很隐蔽的问题。问题最早不是在 iPhone 上发现的,而是在 Apple Watch 上。

我在手表上点了上床按钮,手表突然让我手动输入起床时间。我一开始还觉得奇怪,仔细一看,原来手机同步给手表的起床日期已经用完了。SleepTap 会提前生成七天的起床时间,只要后台任务正常执行,这七天就会不断向后延长。现在日期彻底用完了,也就是说,至少有一个星期没有一次成功的后台刷新走到生成日期这一步。

这件事刚好发生在我把 iPhone 升级到 iOS 26.6 之后。升级之后,我也一直没有手动打开过 SleepTap。所以我的第一反应是,系统升级后没有启动过应用,后台任务是不是就不工作了?按 API 的设计,不应该这样。已经注册和提交的后台任务由 iOS 调度,又不是靠用户每天给应用上香才能继续运行。

可我手动打开一次 SleepTap,起床日期立刻同步到了手表,一直排到 8 月 10 日。这至少证明了三件事:日期生成逻辑没坏,手机和手表的同步没坏,前台刷新也没坏。坏掉的只剩后台执行这条路。

等待、重启、重装,都没用

我没有马上改代码,而是先等了一天。结果日期还是停在 8 月 10 日。接着我重启 iPhone,又等了一天,还是不执行。后来我修改了一部分代码,重新安装并运行,结果依旧没有后台记录。

这时候很容易开始怀疑玄学。比如 iOS 会根据应用过去的执行情况分配后台额度,SleepTap 会不会因为以前注册了两个 App Refresh 任务,被系统悄悄惩罚了?以前系统睁一只眼闭一只眼,现在 iOS 26.6 不提示错误,直接一个都不给执行?这听起来有些像奇谈怪论,但后台调度本来就是黑盒,不做实验,谁也不能说它一定不可能。

SleepTap 当时有两个后台刷新任务,一个更新起床日期,一个更新天气和晒太阳通知。我先把天气任务停掉,只留下日期任务,又把应用版本升了。结果还是不执行。也就是说,就算所谓的惩罚存在,至少也不是简单地删掉一个任务就会恢复。

我还担心过另一个问题:测试时 iPhone 是通过 Xcode 安装的,如果从 Mac 断开,应用会不会也跟着“断开”?其实不会。任务提交给的是 iPhone 上的 BGTaskScheduler,Mac 和数据线只负责安装、调试和读取日志。拔掉数据线会断开调试器,不会删除手机系统保存的请求。

单独写一个应用,结果它也不执行

既然 SleepTap 身上的历史包袱太多,我干脆新建了一个测试应用。它只做一件事:注册一个 BGAppRefreshTaskRequest,后台处理器被调用之后立刻发一条本地通知,并把 handler.entered、通知添加和完成状态写进本地记录。

这已经简单到不能再简单了。没有天气,没有手表,没有 SwiftData,也没有复杂的网络请求。如果它能执行,说明 SleepTap 可能真的被系统区别对待。如果它也不能执行,那就不是 SleepTap 自己的问题。

结果,这个新应用也一直没有执行。

我手动打开过一次,通知权限是 true,提交 API 也明确返回成功。请求早就超过了 earliestBeginDate,但记录里一次 handler.entered 都没有。不是通知被系统隐藏了,而是处理器根本没有被调用。

到这里,我原以为已经证明了 iOS 26.6 的后台调度整体坏了。可我又想了想,这个所谓的“独立测试应用”,其实并不独立。它和 SleepTap 使用的是同一种注册方式:

.backgroundTask(.appRefresh(identifier)) {
  await handleAppRefresh()
}

如果坏掉的是 SwiftUI 的 .backgroundTask,那么两个应用当然会一起失败。这不叫对照实验,这叫把同一份可疑代码抄了两遍。

苹果自己的示例不是这么写的

我接着看了苹果的官方示例 Refreshing and Maintaining Your App Using Background Tasks。这个示例是 WWDC 2019 的,使用的是古法 AppDelegate:在 application(_:didFinishLaunchingWithOptions:) 中调用 BGTaskScheduler.shared.register,任务到期时取消操作,完成时显式调用 setTaskCompleted(success:)

我原本以为这只是 UIKit 和 SwiftUI 的写法差异。SwiftUI 的 .backgroundTask 本来就是官方 API,凭什么旧写法可以,新写法不行?可苹果开发者论坛里早已有一串相关讨论。在 BGTaskScheduler crashes on iOS 18.4 这个帖子里,苹果 DTS 工程师确认 SwiftUI 的后台任务集成存在注册时序问题,并建议用 UIApplicationDelegateAdaptor 回到 AppDelegate,在 didFinishLaunchingWithOptions 中尽早注册。帖子讨论的是 iOS 18.4 上的崩溃,而我遇到的是 iOS 26.6 上悄悄不执行,表现并不完全一样。但它们指向的是同一个危险点:SwiftUI 可能太晚才替应用注册处理器,而任务没有正确注册,就不可能被执行。

还有一个很容易误判的地方。earliestBeginDate 不是预约时间,只是“不早于这个时间”。苹果的文档写得很清楚,系统不保证在指定时间启动。所以等一小时没执行,不能立刻说有 bug。可连续多天不执行、七天日期全部耗尽,再加上测试应用完全没有 handler.entered,这就不能只用“系统会择机调度”来搪塞了。

五分钟之后,通知来了

我把测试应用改成 AppDelegate 注册,保留原来的 identifier、通知和事件记录,只替换注册路径。为了不用再傻等一天,我把第一次请求的最早时间改成五分钟后。任务真正执行之后,再续订到一小时后。五分钟并不是要求系统五分钟准时执行,只是让它尽快进入可执行状态。

手机里的记录很清楚:14:00:53 提交请求,14:05:53 开始具备执行资格,14:09:09 系统调用 handler。同一秒,应用提交了下一次请求,加入本地通知,最后记录 handler.completed。整个链条走完,没有过期,也没有失败。

macOS 随后通过 iPhone Mirroring 显示了这条来自 iPhone 的通知:后台任务已执行,时间 14:09:09。当时 iPhone 并不依赖 Xcode 的调试连接。这也顺手排除了我之前关于“断开 Mac 会不会导致应用断开”的担心。

严格地说,这还不是完美的单变量实验,因为我同时改变了注册方式和第一次的等待时间。但原来的 SwiftUI 请求已经超过最早时间几个小时,甚至几天,也从未进入处理器;AppDelegate 版本第一次自然调度就完整执行。对于工程判断来说,证据已经足够了。继续等,不会让问题变得更正确。

我最后怎样修改 SleepTap

我把 SleepTap 也改成了 AppDelegate 早期注册,版本升到 1.6.3。现在应用只保留一个系统后台任务,最早时间仍然按照用户的起床时间加一小时计算。处理器进入之后先续订下一次任务,再更新睡眠通知和手表上的起床日期,最后尝试刷新天气和晒太阳通知。

天气原来有自己的 App Refresh 任务,现在不再单独注册。它仍保留原来的业务逻辑:只在 6:00 到 18:00 之间处理,检查六小时缓存和位置变化,确实需要时才请求天气。用户手动打开 SleepTap 时也会检查天气,缓存过期就刷新,缓存有效就直接重新计算晒太阳计划。

这样做不是因为两个后台任务在 API 上一定不允许,而是我已经没有理由继续让两个不确定因素同时存在。一个任务负责唤醒,里面顺序执行日期和天气逻辑,出了问题也更容易看日志。这叫减少变量,不叫架构升级。

从 7 月 27 日到 8 月 8 日

回头看,这个问题的时间线其实很完整。不是某一天突然灵光一现找到了答案,而是每天排除一点,最后才发现,最值得怀疑的恰恰是看起来最不值得怀疑的那一层。

  • 7 月 27 日:升级 iOS 26.6。 苹果在这一天发布了 iOS 26.6,官方安全更新页面也记录了这个日期。我当天升级了系统,之后一直没有手动打开 SleepTap。当时当然没觉得有什么问题,后台任务本来就应该由系统调度,我没有理由每天打开一次应用检查它还活着没有。

  • 7 月 28 日到 8 月 2 日:一切看起来都很正常。 这几天我没有收到晒太阳通知,但也没有立刻把它和后台任务联系起来。手表里的起床时间是提前生成七天的,所以即使后台任务从升级之后一次都没成功,前几天仍然不会露馅。这个七天缓存原本是保护用户的,结果也把故障藏了七天。

  • 8 月 3 日:七天用完,问题出现。 我在 Apple Watch 上点击上床按钮,手表突然要求我手动输入起床时间。检查后发现,提前同步的起床日期已经全部耗尽。我手动打开一次 SleepTap,日期马上补齐到 8 月 10 日。于是我们先确认了日期生成、前台刷新和 Watch 同步都正常,把问题缩小到后台执行。同时也提出了第一个怀疑:会不会是升级系统之后没有再手动启动过应用,导致后台注册失效?这和 API 应有的行为不符,但和实际现象很像,所以先不急着下结论。

  • 8 月 4 日:继续等,不重启。 手表上的日期仍然只到 8 月 10 日,没有刷新出 11 日。此时有两个选择,要么重启手机,要么再保留一天自然观察。我选择先等,因为一旦重启,系统调度状态就变了。假如第二天任务自己恢复,我们还能证明它只是延迟;马上重启反而会把这个证据毁掉。

  • 8 月 5 日:等待无效,开始重启实验。 又等了一天,后台任务还是没有执行。自然恢复这条路基本可以排除了,我于是重启 iPhone,再给系统一天时间。这里仍然没有立刻改代码,因为重启和改代码一起做,即使恢复了,也不知道是谁起的作用。古法排错虽然慢,但至少不会自己骗自己。

  • 8 月 6 日:重启也没用,开始减少变量。 重启后又观察了一天,日期仍然没有向后延长。我修改代码、重新安装并运行,还升级了应用版本,避免系统如果真的针对旧版本保留了某种调度判断,新代码仍然继承旧状态。同时暂停天气后台任务,只注册起床日期这一个任务。可它还是没有执行。检查天气后又发现,晒太阳通知也已经多天没出现,这说明不是单独一条日期逻辑坏了,两个后台处理器都没有留下成功执行的迹象。也正是在这一天,我决定另建一个只有后台任务和本地通知的测试应用,和 SleepTap 并行观察。

  • 8 月 7 日:最小测试应用也沉默。 新应用没有天气、手表和数据库,处理器只要被调用,就写一条记录并发送通知。我手动打开过,也手动提交过请求,等了几个小时仍然没有通知。我一度担心从 Mac 断开 iPhone 会不会让应用也跟着断开,后来排除了:断开的只是 Xcode 调试连接,手机上的应用和已经提交给系统的请求都还在。新应用也失败,本来很像是 iOS 26.6 把整个后台调度弄坏了。可问题是,新应用照抄了 SleepTap 的 SwiftUI .backgroundTask 注册方式。两个使用同一种可疑写法的应用一起失败,根本不能证明系统不调度,只能证明这条注册路径值得怀疑。

  • 8 月 8 日:对照苹果 Demo,问题解决。 我把苹果官方示例和我们的代码逐项比较,终于注意到官方 Demo 并没有使用 SwiftUI .backgroundTask,而是在 AppDelegate 的 didFinishLaunchingWithOptions 中直接调用 BGTaskScheduler.register。再结合苹果 DTS 对 SwiftUI 注册时序问题的说明,我把测试应用改成和官方 Demo 一样的注册方式。14:00:53 提交请求,14:05:53 开始具备执行资格,14:09:09 处理器真正进入,通知也通过 iPhone Mirroring 显示在 Mac 上。证据已经足够,我随后用同样方式修改 SleepTap,把天气合并进起床日期任务,并保留用户手动打开应用时的天气刷新。

从升级到发现问题,刚好七天。从发现问题到真正解决,又用了五天。期间等待过、重启过、重装过、升级过版本、减少过任务,也怀疑过系统惩罚和 Xcode 连接。结果真正的答案不在这些地方,而在苹果自己的 Demo 里:SwiftUI 提供了更漂亮的注册方式,但至少在我的 iOS 26.6 设备上,真正可靠的仍然是 AppDelegate 里的古法注册。

总结

我现在能确认的是:在我的 iPhone SE 3、iOS 26.6 上,两个使用 SwiftUI .backgroundTask(.appRefresh(...)) 注册的应用都没有自然进入处理器;换成 AppDelegate 中直接调用 BGTaskScheduler.register 后,测试应用第一次就成功执行。

我不能据此宣布所有 iOS 26.6 设备都会中招,也不能证明它和 iOS 18.4 的论坛问题是同一个 bug。但如果你的后台任务在升级之后突然消失,提交又不报错,别只盯着 earliestBeginDate,也别急着怪系统在“惩罚”应用。先记录 handler.entered,再写一个最小测试应用,最后把 SwiftUI 注册换成 AppDelegate 注册做对照。

结论:iOS 26.6 之后,SwiftUI 注册的 App Refresh 后台任务不值得信任。至少在苹果修复并解释这个问题之前,我会使用 AppDelegate 的古法注册。

第三方应用使用DocumentGroup时,模仿Pages的工具栏

SwiftUI

最近,我在开发iOS的Markdown应用的时候遇到一个问题,应用是使用DocumentGroup开发的,但是默认会显示标题,从而导致留给工具栏的空间太小,工具栏的图标经常被折叠起来。

IMG_1181

我一开始只是想把后退去掉,换成三个点的菜单,然后去掉标题。结果发现后退去不掉。于是我就想看看苹果自己是如何做的。于是我打开了苹果自己的Notes和Numbers。不过Notes其实是有内部库的,于是主要参考的Numbers。

IMG_1184

我把Numbers截图给AI,说想要弄一个这个风格的。结果AI搞不定。它虽然设置了标题为空,但是标题始终显示。于是我有把这个描述给Grok,Grok说,这是因为DocumentGroup自动包含了一层Navigation,你需要将它禁用。结果我禁用了,还是不行。又去问Grok,它又说,这是因为iOS的某些版本,有bug,不执行这个操作。但是网友总结了三种办法,1,2,3,然后挨个试。结果第一种就可以了。

这个是我应用最终的效果。

IMG_1205

最核心的代码只需要如下的部分。

截屏2026-04-12 10.50.33

NavigationSplitView的一些奇技淫巧

SwiftUI

Sidebar按钮隐藏起来的正确做法

使用

.toolbar(removing: .sidebarToggle)

可以隐藏侧边栏切换按钮。但是注意,这个不是放在作用在NavigationSplitView上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用:

NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

隐藏Sidebar的正确做法

NavigationSplitView是三栏的,但是如果你要使用两栏,则有两种方式。

使用sidebar

使用sidebar的好处是,sidebar可以隐藏,也可以显示。
使用sidebar+detail的方式。

不使用sidebar

如果你希望永远显示为两栏,不希望隐藏任何一个栏隐藏,那么使用此方式。

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

这个的重点在于,通过sidebar设置隐藏了切换sidebar按钮。同时,选择两栏模式,默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。

配合Settings使用

在使用Settings时候,包含sidebar的NavigationSplitView的sidebar按钮会导致出现问题。因此,必须使用上面的第二种方式,隐藏sidebar,并且使用一直存在的两栏。

什么?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重复刷新的问题可能找到了

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都会保持在用户上次选择的标签页。

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。看你的界面的需要。