我们知道SwiftUI应用本身没有应用窗口的概念,它是使用WindowGroup来自动管理窗口的。这在用户手动启动应用的时候没有问题。但是如果你设置了应用伴随系统登录后自动启动后,SwiftUI的应用会存在一些问题。
这是因为这种方式启动应用后,SwiftUI的应用不会主动创建窗口,视图会在用户手动点击应用之后才创建。而这可能不是我们所需要的。因为如果我们有使用onAppear来执行一些代码。我们实际上是希望代码可以在应用启动时就运行,而这个机制会导致执行会推迟到用户点击时。
因此,我们需要保证即便SwiftUI没有生成Window,也要自己主动来生成Window。
问题分析
要解决这个问题,我们首先要了解这个启动的整个过程,然后才能知道如何来改进。具体调试的过程我就不讲了,最终我确认启动的过程是这样的:
- 用户登录。
- 应用启动。
- 应用在后台启动,但是没有主窗口,因此无法自动切换到前台。
我们可以使用NSApplication.shared.windows.count来判断。如果是用户手动打开的应用,SwiftUI会创建SwiftUI.AppKitWindow的窗口。而如果是跟随系统登录后后台启动,则不会有这个窗口。
我使用的判断函数是这个:
func isLaunchedAtLogin() -> Bool {
NSApplication.shared.windows.count < 2
}
之所以用2,而不是1判断。是因为我还使用了菜单栏图标,菜单栏图标使用的是NSStatusItem,因此还会包含一个叫NSStatusWindow的窗口。
解决方案
func applicationDidFinishLaunching(_ notification: Notification) {
createContentViewWindow()
}
/// 用户启动时SwiftUI的窗口会先创建,因此window不会为nil,但是有可能存在延迟,因为是通过.updateWindow还获取的。
/// 所以这里使用其他的方式进行判断,而不是使用window是否为nil
private func createContentViewWindow() {
/// 若是用户点击,会有两个窗口,一个是SwiftUI创建的主窗口,一个status窗口。后者应该是对应菜单栏图标的。
/// 如果是伴随系统启动,则SwiftUI创建的主窗口不存在,只有status的窗口。
func isLaunchedAtLogin() -> Bool {
NSApplication.shared.windows.count < 2
}
if isLaunchedAtLogin() {
let contentView = ContentView()
.environment(\.managedObjectContext, ModelProvider.shared.container.viewContext)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 240),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.center()
window.makeKeyAndOrderFront(nil)
self.window = window
}
}
小结
传统上,如果我们使用loginItem来实现应用伴随系统启动,那么为了区分是用户手动启动,还是伴随系统启动,需要使用传递参数的方式。但是传递参数,就需要使用额外的launcher辅助应用。
设置辅助应用的步骤是很复杂的。因此,我们现在大多都是直接使用下面的代码来直接使用自动伴随系统登录启动。
try SMAppService.mainApp.register()
这个办法虽然大大简化了设置系统启动后启动应用的步骤。但是这么做之后,由于没有辅助应用,也就没法使用传递参数的办法了。
本文给出的使用NSApplication.shared.windows.count,用窗口数量来间接判断的方式,利用了SwiftUI在后台启动后,不会主动创建窗口的特性,解决了这个问题。
