返回博客

SwiftUI 的 View 生命周期,不是 macOS 窗口的生命周期

swiftuistate-managementappkitmacosnswindowwindow-lifecycle

AppKit 窗口生命周期与 SwiftUI view 生命周期的对比图

AppKit 从一个明确的窗口对象开始,SwiftUI 则从声明式的 scene 和 view 开始。view 可能被更换或重建,而底层的窗口——以及挂在窗口上的状态——依然存在。

SwiftUI 之前,大多数原生 macOS 应用直接建在 AppKit 上,早年用 Objective-C,后来也用 Swift。但真正要紧的区别不在 Objective-C 和 Swift 之间,而在 AppKit 明确的对象模型和 SwiftUI 声明式的对象模型之间。

传统的 AppKit 应用里,窗口是一个有稳定身份的具体对象。一个 NSWindow 通常由一个 NSWindowController 管理,多窗口应用一般每个窗口配一个 controller。关系是看得见的:这个 controller 拥有这个窗口,这个窗口持有这个状态,那个窗口关闭时跑这个回调。

这不代表 AppKit 应用就不会有生命周期 bug。复杂软件在哪个框架下都可能管错所有权。但 AppKit 让窗口本身难以被忽略。窗口级的状态自然而然地倾向于待在 window controller、document,或者另一个生命周期明确绑定到该窗口的对象里。

SwiftUI 换掉了起点。

我们不再构造一个 window controller 然后往它的窗口里装内容,而是声明一个 Scene,描述它应该包含哪些 view。一个 WindowGroup 可以从同一份声明里生成多个互相独立的窗口。什么时候创建和重建 view 由 SwiftUI 决定,而这些声明和底层 AppKit 对象之间的映射,很大一部分留在框架内部。

这是 SwiftUI 的优点之一。它省掉了大量机械的窗口和 view 管理工作。但它也让一些所有权的边界变得不那么显眼。

用 AppKit 时,我们习惯先问:「这是哪个窗口的?」

用 SwiftUI 时,我们更容易先问:「现在显示的是哪个 view?」

当一个功能出现在某个 view 里、但它其实并不属于这个 view 的时候,这个差别就要命了。

我在一次生产级 macOS 应用的多窗口重构里撞上了它。这个应用有一个长会话,可以从某个页面启动。启动之后,用户在同一窗口里切到别的页面,会话应该继续跑。只有用户明确停止它、或者关掉那个窗口时,它才该结束。

实际表现是:切换路由有时会把会话停掉。等加了多窗口支持,故障变得更严重——关掉一个窗口会影响到还在另一个窗口里跑着的会话。

最初的实现看起来并不离谱。view 创建自己的 controller,出现时开始工作,消失时清理。

struct SessionView: View {
    @State private var session = SessionController()
    var body: some View {
        SessionContent(session: session)
            .onAppear {
                session.start()
            }
            .onDisappear {
                session.stop()
            }
    }
}

如果这个会话真的属于这个页面,这段代码可能是对的。一个页面范围内的任务,随页面开始、随页面消失而结束,说得通。

但在这个产品里不是这样。

这个会话属于窗口。

SwiftUI 的 view 消失,可能是因为用户换了侧边栏的选中项、走了另一条导航路径,或者触发了某次状态更新让 SwiftUI 重建了 view 树。这些事件里没有一个意味着承载它的 macOS 窗口关掉了。

错误不在于 onDisappear 的行为难以预测。错误在于把一个 view 级别的事件当成了窗口销毁的语义。

view 消失和窗口关闭,不是同一条生命周期边界。

View 生命周期不是窗口生命周期

一次路由切换可以销毁一个 view、创建另一个 view,而属于窗口的会话继续存在。

第一张图抓住了核心关系。view 存在于窗口里,窗口存在于应用里。它们的生命周期可以重叠,但不能互相替换。

用户从 View A 走到 View B,View A 可能被销毁,View B 可能被创建。整个过程中窗口一直开着。如果会话属于那个窗口,它就必须扛过这次路由切换。

由此引出一个比「该用哪个 SwiftUI 属性包装器」重要得多的问题:

这份状态到底归谁所有?

把状态往上挪是不够的

SwiftUI 里关于状态管理的讨论常常围绕机制:@State@StateObject、observation、环境注入、依赖容器。这些机制决定状态怎么创建、怎么持有、怎么传递。它们不决定这份状态在概念上应该活多久。

那个决定必须来自所有权。

一个典型应用里,状态处在几个不同的生命周期层级上。搜索框里的文字、展开的面板、临时的选中项这类呈现细节,可能属于某个具体的 view,页面销毁时跟着消失就行。

一个正在跑的会话、窗口的导航位置、为这个窗口申请来的资源,需要扛过页面切换。它们属于窗口。

数据库连接、全局设置、缓存、底层服务可能被多个窗口共用。它们属于应用。

最初的实现把长会话放得太低,塞进了页面里。显然的应对是把它往上挪,这样路由切换就不会再销毁它。

这解决了一个症状,制造了另一个。

状态被抬得太高,所有窗口开始共用同一个实例。

窗口 A 和窗口 B 看起来是独立的,实际都在操作同一个会话对象。一个窗口里的状态变化会出现在另一个窗口里。更要命的是,当一个窗口清理掉这个共享会话,另一个窗口就失去了它仍然依赖的东西。

所以「把状态往上挪」不是一条够用的设计规则。

状态不该只是挪到不被销毁的高度,它应该挪到它真正的所有者所在的层级。

在这个例子里,每个窗口需要自己的会话状态。

@MainActor
final class WindowSession {
    let task = TaskController()
    private var hasTornDown = false

    func tearDown() {
        guard !hasTornDown else { return }
        hasTornDown = true
        task.stop()
    }
}

这个类本身不是重点。重点是它代表的那句所有权声明:

一个窗口拥有一个窗口会话。

view 可以显示并操作这个会话,但不拥有它的生命周期。应用可以登记和查找它,但不应该把多个窗口折叠进同一个会话实例。

错误的所有权与正确的所有权

把状态往上挪是不够的。每个窗口需要自己的会话,而对共享资源的访问由互相独立的 lease 表示。

第二张图的左边是那个看着简单、其实误导的模型:两个窗口连到同一个共享会话。它在导航时避免了状态被重建,代价是摧毁了窗口之间的隔离。关掉任意一个窗口都会影响到另一个。

右边更准确地描述了所有权。窗口 A 拥有会话 A,窗口 B 拥有会话 B。两个会话仍然可以使用同一个底层应用服务,但没有哪个窗口直接拥有那个服务。

取而代之的是,每个会话持有一份独立的 lease。

这个区分是必要的,因为长会话会对一个共享资源施加临时约束。会话活着的时候,共享服务需要用一套更苛刻的配置运行。会话结束,这个约束就可以撤掉。

朴素的做法是在会话开始时直接改一个全局值,会话停止时改回来:

sharedService.interval = 1
// 之后
sharedService.interval = 10

一个窗口时它能用。两个窗口时就崩了。

假设窗口 A 和窗口 B 都在跑会话,都要求 interval 保持在 1。如果窗口 A 关闭时把 interval 恢复成 10,它就覆盖了一个窗口 B 仍然持有的要求。

根子还是所有权。窗口 A 不拥有共享服务的配置,它只拥有自己那份「要求更严格配置」的请求。

这个请求更适合用 lease 来表示。会话 A 取得 Lease A,会话 B 取得 Lease B。共享服务根据所有活跃 lease 算出实际生效的配置。关掉窗口 A 只释放 Lease A。只要 Lease B 还在,服务就必须继续满足窗口 B 的要求。

这还要求把「用户请求的配置」和「当前生效的配置」分开。

用户可能在还有一个或多个会话活跃时改掉期望的 interval。一份严格的 lease 可能暂时挡住新值生效,但等最后一份 lease 释放,服务应该恢复到用户最新的请求值,而不是第一个会话启动时抓到的那个陈旧值。

lease 模型解决的不只是清理问题。它表达了一个事实:一个共享资源可以同时有多个相关方,其中任何一个都无权单方面重置全局状态。

SwiftUI 的 Scene 仍然不是具体的窗口身份

状态挪到窗口层级之后,还剩一个问题:应用怎么知道到底是哪个窗口关了?

SwiftUI 提供了 Scene 层面的结构,但在 macOS 上,激活、聚焦、关闭最终都发生在真实的 NSWindow 实例上。当确切的窗口身份要紧时,靠一个「当前窗口」的全局引用是不够的。

设想一个简单的序列。窗口 A 先注册,于是全局窗口引用指向 A。窗口 B 后打开,替换掉这个引用。然后窗口 A 关闭。如果清理逻辑用的是那个全局的「当前窗口」,应用可能去清理 B,可能找不到 A,也可能释放错的状态。

解法不是再写一个存放「唯一活跃窗口」的全局协调器。每个真实的窗口需要自己的登记记录。

这份记录把 NSWindow 身份、对应的 Scene 身份、窗口拥有的状态、为该窗口安装的通知观察者,以及激活和清理所需的回调绑在一起。AppKit 通知到达时,应用通过产生这个事件的真实 NSWindow 去找到它。

这让窗口关闭变得精确。窗口 A 的通知只能找到窗口 A 的登记记录。它的观察者、状态和资源 lease 可以被移除,而不碰到窗口 B。

登记过程本身又带来一个更隐蔽的生命周期问题。

等到 SwiftUI 把底层的 NSWindow 暴露出来、应用装上自己的生命周期观察者时,这个窗口可能已经变成 key 或 main 了。如果激活事件发生在观察者装好之前,而之后又没有新的激活事件,应用就可能永远错过这次状态变化。

当应用需要把一条待处理的导航请求或用户操作投递给「接下来激活的那个窗口」时,这一点尤其明显。目标窗口可能在登记流程走完之前就已经激活了,而应该接收这条请求的状态可能还不存在。

修法是:把登记当成一次两阶段的状态迁移,而不是一次赋值。

第一步,生命周期协调器接管这个真实窗口并确立它的登记身份。它装上必要的观察,并记录这个窗口是否已经处于激活状态。然后应用再完成 Scene、窗口状态、路由和资源回调的连接。只有这些关系都就绪之后,才提交这次登记。

如果激活发生在登记完成之前,协调器在提交之后补放那次激活。

这不是在伪造一个 AppKit 通知,而是在保全一个在消费者就绪之前到达的事件。

更普遍的教训是:生命周期登记本身可以和生命周期赛跑。即使代码跑在 main actor 上,框架事件也可能在应用层的关系还没组装完的时候发生。

关闭窗口应该走真正的生命周期路径

所有权明确之后,窗口清理就变成一条链条,而不是挂在某个顺手 view 上的回调。

用户关掉一个具体的 NSWindow。AppKit 为这个窗口发出关闭事件。生命周期协调器找到对应的登记记录。窗口注册表移除该窗口拥有的状态。会话停止自己的工作并释放自己的 lease。最后,共享服务根据剩下的 lease 重新计算生效配置。

窗口关闭时发生什么

窗口关闭沿着生产环境的生命周期路径传递。每个窗口只释放自己的状态和 lease,重复的关闭事件保持安全。

第三张图说明了这条链条为什么重要。

窗口 A 关闭时,只有会话 A 被拆掉,只有 Lease A 被释放。会话 B 仍然活跃,共享资源继续在 Lease B 的约束下运行。

如果窗口 A 的关闭事件又被处理一次,第二次会变成空操作。它的状态已经移除,lease 已经释放。

只有等窗口 B 后来关闭、释放掉最后一份 lease,共享资源才能回到用户请求的正常配置。

这让幂等成为生命周期清理的基本要求。

窗口关闭、Scene 销毁、应用退出可以互相重叠,也可以通过不同路径抵达清理逻辑。一个假设「拆解方法只会被调用一次」的设计,依赖的是一个脆弱的条件。清理逻辑应该在被请求一次或多次时都安全。

同一条所有权链条也改变了这个功能该怎么做测试。

一个直接的单元测试能证明调用 tearDown() 会停掉任务:

func testTearDownStopsTask() {
    let session = WindowSession()
    session.task.start()
    session.tearDown()
    XCTAssertFalse(session.task.isRunning)
}

这个测试有用,但它证明不了应用生命周期是通的。

它证明不了窗口观察者装对了。它证明不了来自窗口 A 的事件会落到窗口 A 的状态上。它发现不了两个窗口意外共用了同一个会话。它证明不了释放一份 lease 之后另一个窗口的 lease 还完好。它也没法揭示重复的关闭事件会不会触发重复清理。

有意义的回归场景必须走生产路径。

创建两个互相独立的窗口。各自拥有自己的会话,两个会话使用同一个共享服务。在任何一个窗口关闭之前,两个会话都已启动。

关掉第一个窗口时,只能停掉它自己的会话。第二个会话必须保持活跃,共享服务必须保留剩下的那份 lease。把第一个窗口的关闭事件再处理一次,不能产生额外的释放。只有第二个窗口也关闭之后,共享服务才能回到用户请求的配置。

那个测试验证的不是一个方法,而是一条所有权链条:

真实窗口事件 → 窗口登记 → 窗口拥有的状态 → 会话拆解 → lease 释放 → 共享资源重算

这个区分要紧,因为生命周期 bug 常常能躲过局部单元测试。每个内部方法都可能表现正确,而生产环境的事件却走错了对象身份,或者从错误的架构层级进来。

生命周期 bug 往往是所有权的伪装

最初的症状很简单:切换页面会把一个任务停掉。

顺藤摸下去,暴露出的是更大的一堆问题。

为什么会话创建在页面里?为什么两个窗口共用同一个状态对象?为什么一个窗口有权恢复一份应用级配置?为什么窗口可能在登记就绪之前就已经激活?为什么直接的拆解测试通过了,真实的关闭路径却仍然不安全?

这些问题最后都指向同一个结论:

生命周期问题,往往是所有权问题的伪装。

一个对象在哪里被创建,不自动决定它归谁所有。某个回调在某个时刻触发,不代表它处在正确的生命周期边界上。

所有权必须在选择存储机制或清理回调之前定下来。

该随页面消失的状态属于 View。该扛过导航但隔离在单个窗口里的状态属于 Window Scene。被多个窗口共用的服务属于应用。一个窗口可以释放自己的会话和自己的 lease,但它不能直接重置其他窗口还在使用的资源。

SwiftUI 鼓励我们从 View 出发向外组织应用。这通常有效,但它不意味着每一份状态都属于某个 View,也不意味着都该跟着 onAppearonDisappear 走。

在小型应用里,View、Window、App 三者的生命周期可能重叠得如此紧密,以至于一个错误的所有权模型看上去能跑。复杂的导航、多窗口、长会话和共享资源最终会把这几个生命周期分开,让不匹配暴露出来。

这次重构背后的一部分实现和评审工作借助了 AI。它在生成代码、扩充测试、以及挑战单个设计决策上很有用。但核心问题仍然需要一个明确的工程模型:状态归谁所有,它该活多久,哪个系统事件是权威的,以及什么样的证据才足以证明整条生命周期路径是通的。

这次重构最重要的产出,不是某个特定的 SwiftUI 属性包装器、AppKit API 或协调器类型,而是一条更普遍的设计原则:

生命周期不由代码写在哪里决定,而由这份状态或资源应该保持有效多久决定。

一旦把 View、Window、App 三个生命周期当成互相独立的边界,那些时有时无的行为就变得容易解释了。路由切换不再意味着会话终止。关掉一个窗口不再影响到另一个。共享资源不再被「谁先松手」的用户控制。

这种分离不只是针对 SwiftUI 的一个绕行方案。

它是一个稳定的多窗口应用所需要的所有权模型。

常见问题

SwiftUI 的 view 生命周期和窗口生命周期是一回事吗?

不是。AppKit 从一个明确的窗口对象开始,而 SwiftUI 从声明式的 scene 和 view 开始,它们可能被更换或重建。view 和窗口可以有各自不同的生命周期。

为什么把状态往上挪解决不了 SwiftUI 的生命周期问题?

往上挪仍然绕不开一个事实:SwiftUI 的 Scene 不是具体的窗口身份。底层窗口还在的时候,view 可能已经被重建,所以所有权和生命周期必须分开处理。

SwiftUI 的生命周期 bug 通常是什么原因?

多数是所有权 bug:把声明式 view 的生命周期当成了它拥有某个具体资源(比如一个窗口)。关闭窗口应该走真正的应用生命周期路径。