肇鑫的技术博客

业精于勤,荒于嬉

SwiftUI中的fileImporter与AppKit的NSOpenPanel在使用时的区别

原本我以为二者是等价的。但是实际上,二者有一个重要区别。如果不掌握,你就没法使用fileImporter。

今天我就遇到了这个问题。当我使用fileImporter打开桌面的截图后,NSImage居然崩溃了。

func generataScreenshots(urls: [URL]) {
  var screenshots = [NSImage]()

  for url in urls {
    let image = NSImage(contentsOf: url)! // nil crash
    screenshots.append(createScreenshot(image: image))
  }

  self.screenshots = screenshots
  self.showScreenshotsPreviewView = true
}

我很疑惑。因为用户选中的文件的权限是默认开启的。难不成Xcode把这个也给搞坏了?我将用户选择文件的权限由默认的只读改成读、写。结果还是崩溃。但是当我添加了Download文件夹为读、写之后。重新选中一个Download文件夹下的图片,这时系统弹出是否允许应用访问Download文件夹,我选择允许之后,这次居然成功运行了。

看起来的确还是和权限有关系。我将NSImage的代码前面加了一行Data的代码。采用Data 读取这个图片。结果这次,Xcode的输出给出了正确的提示,说没有权限打开URL。

查看fileImporter的文档说明,注释中写到:

This dialog provides security-scoped URLs. Call the startAccessingSecurityScopedResource method to access or bookmark the URLs, and the stopAccessingSecurityScopedResource method to release the access.
这段对话提供了安全范围的URL。调用startAccessingSecurityScopedResource方法来访问或将URL添加到书签,以及调用stopAccessingSecurityScopedResource方法来释放访问权限。

修改代码

func generataScreenshots(urls: [URL]) {
  var screenshots = [NSImage]()

  for url in urls {
    if url.startAccessingSecurityScopedResource() {
      let image = NSImage(contentsOf: url)!
      screenshots.append(createScreenshot(image: image))

      url.stopAccessingSecurityScopedResource()
    }
  }

  self.screenshots = screenshots
  self.showScreenshotsPreviewView = true
}

这回就可以了。

结论

fileImporter获取的URL必须使用startAccessingSecurityScopedResource()进行处理,不然是不能直接读取的。除非你已经plist中明确了该文件夹的权限,类似Download文件夹这种Xcode可以添加的才行。

而NSOpenPanel,打开同样的图片,直接使用就可以正常运行。

func openPanel() {
  let panel = NSOpenPanel()
  panel.allowedContentTypes = allowedContentTypes
  panel.allowsMultipleSelection = true

  let response = panel.runModal()
  var screenshots = [GeneralImage]()

  if response == .OK {
    for url in panel.urls {
      let image = NSImage(contentsOf: url)!
      screenshots.append(createScreenshot(image: image))
    }
  }

  self.screenshots = screenshots
  self.showScreenshotsPreviewView = true
}

接力(Handoff)在SwiftUI下的实现

传统上,接力在UIKit和AppKit上的实现有两种。一种是基于UIDocument/NSDocument的,一种是基于NSResponder/UIResponder的。苹果有文档分别说明了两种情况下,要如何实现接力功能。

Handoff Programming Guide
Implementing Handoff in Your App

但是,针对SwiftUI,苹果并没有专门进行说明要如何实现接力。苹果网站上的确有一篇文档,同时用到SwiftUI和NSUserActivity,但是那篇文档偏重的是如何恢复应用的状态,并不是如何使用接力。

Increasing App Usage with Suggestions Based on User Activities

本文的结果,是我通过试验摸索出来的。适用于苹果的iOS 16和macOS 13。鉴于目前版本的苹果实现过于简陋。大概率苹果今后会做出较大的修改。这点希望阅读者注意。

接力的传统实现方法

类似接力这种预制的功能,最复杂的地方是,苹果本身设定了它预想的功能方式,这就像是填空题,而不是简答题,你需要在留给你的空位填入自己的内容,而不能天马行空,自己设计一套。

使得接力更为复杂的,是苹果还设置了几种完全不同的方式。

步骤1,设置Info.plist。

如果你的应用是文档应用,即基于UIDocumentNSDocument或者DocumentGroup,那么应该把接力的类型写在文档类型里。

其它方式,则应该把接力的类型写在顶层。

需要注意两点:

  1. 注册在Info.plist的目的是标记应用支持“接收”何种类型。即,发送本身是不需要注册的。
  2. 在Xcode 14中,默认是不存在Info.plist这个文件的,这个由Xcode生成项目时根据项目目标的Info选项卡自动生成。并且,在Info选项卡添加某些内容的时候会自动消失部分内容。
    不过,在添加文件类型的时候我们会发现,系统会自动添加一个“项目目标-Info.plist”的补充文件。这就是我们可以用来编辑的Info.plist文件,在Info选项卡中会自动消失的属性,我们都可以添加到这里。

步骤2,创建NSUserActivity。

这一步比较简单,只需要创建NSUserActivity的实例,设置属性,然后设置为becomeCurrent(),然后将实例赋值给相应的NSResponder

传递的数据只能是文档中指定的那些类型。

步骤3,修改NSUserActivity

复杂的来了,根据不同的场景,苹果共设置了三处,可以后续修改NSUserActivity的方法。

NSUserActivityDelegate

我们可以NSUserActivityDelegateuserActivityWillSave(NSUserActivity)方法来修改NSUserActivity的内容。

NSResponder

我们也可以通过NSResponderupdateUserActivityState(_:)方法来修改。

NSApplicationDelegate

最后,我们可以通过NSApplicationDelegateapplication(_:didUpdate:)方法完成对于NSUserActivity的最终修改。

感兴趣的读者可以同时使用三者,以了解者三者的调用顺序。

步骤4,接收NSUserActivity

接收是在NSApplicationDelegateapplication(_:continue:restorationHandler:)方法。此外,NSApplicationDelegate还有其它的方法用于快速响应接力已经处理出错的问题。

另外,NSUserActivityDelegate还包含一个userActivity(_:didReceive:outputStream:)方法,可以处理数据在设备之间的双向传送。

接力在SwiftUI下如何实现

根据苹果的文档,接力在SwiftUI创建应用使用userActivity(_:element:_:)userActivity(_:isActive:_:)来创建,然后使用onContinueUserActivity(_:perform:)来接收。

遇到的困境

不过在实际使用中,经常遇到SwiftUI创建NSUserActivity不及时,以及接收不到的问题。

在经过我的反复试验之后,我得出了如下结论。

创建NSUserActivity

要创建NSUserActivity,必须满足以下条件。

  1. NSUserActivity实例必须持续存在。因此,不能使用临时变量创建,必须将其赋值给视图的属性。
  2. 必须有对应的类型。
  3. 设置需要的内容。
  4. 必须使用becomeCurrent()

你也可以尝试SwiftUI原生提供的方式,我遇到的问题是应用启动后能成功,但是后续更新容易出现问题。
如果使用原生方式,那么就不需要becomeCurrent()。因为系统会自动根据预设条件自动判断是否becomeCurrent()

获取NSUserActivity

要获取NSUserActivity,则需要满足以下条件:

  1. 在Info.plist中,注册要接收的NSUserActivity的类型。
  2. 这里,最麻烦的点来了。根据我的测试,如果想要接收NSUserActivity,在不同的系统中,机制居然是不一样的。
    1. 如果是macOS,那么请使用NSApplicationDelegate来接收NSUserActivity,SwiftUI自带的onContinueUserActivity(_:perform:)在macOS中没有正常调用。
    2. 如果是iOS,刚好反过来。必须使用SwiftUI自带的onContinueUserActivity(_:perform:)来接收NSUserActivityUIApplicationDelegate对应的方法反而会毫无响应。

结合以上知识点,一个能够同时支持macOS和iOS的接力的SwiftUI应用是这样的:

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    @State private var userActivity:NSUserActivity?
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onChange(of: counter) { newValue in
            let userActivity = NSUserActivity(activityType: "com.parussoft.Handoff-Test.editing")
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
            userActivity.becomeCurrent()

            self.userActivity = userActivity
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

或者

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .userActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
        }
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

我更推荐第一种方式。

最后,附上Info.plist。Xcode 14中,它的文件名为“Handoff-Test-Info.plist”。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDocumentTypes</key>
	<array/>
	<key>NSUserActivityTypes</key>
	<array>
		<string>com.parussoft.Handoff-Test.editing</string>
	</array>
</dict>
</plist>

补充说明

如果还是没有接力的提示,在项目中,打开iCloud的key-value权限。