错误写法
传统写法
现代写法
接力(Handoff)在SwiftUI下的实现
传统上,接力在UIKit和AppKit上的实现有两种。一种是基于UIDocument
/NSDocument
的,一种是基于NSResponder
/UIResponder
的。苹果有文档分别说明了两种情况下,要如何实现接力功能。
但是,针对SwiftUI,苹果并没有专门进行说明要如何实现接力。苹果网站上的确有一篇文档,同时用到SwiftUI和NSUserActivity
,但是那篇文档偏重的是如何恢复应用的状态,并不是如何使用接力。
Increasing App Usage with Suggestions Based on User Activities
本文的结果,是我通过试验摸索出来的。适用于苹果的iOS 16和macOS 13。鉴于目前版本的苹果实现过于简陋。大概率苹果今后会做出较大的修改。这点希望阅读者注意。
接力的传统实现方法
类似接力这种预制的功能,最复杂的地方是,苹果本身设定了它预想的功能方式,这就像是填空题,而不是简答题,你需要在留给你的空位填入自己的内容,而不能天马行空,自己设计一套。
使得接力更为复杂的,是苹果还设置了几种完全不同的方式。
步骤1,设置Info.plist。
如果你的应用是文档应用,即基于UIDocument
,NSDocument
或者DocumentGroup
,那么应该把接力的类型写在文档类型里。
其它方式,则应该把接力的类型写在顶层。
需要注意两点:
- 注册在Info.plist的目的是标记应用支持“接收”何种类型。即,发送本身是不需要注册的。
- 在Xcode 14中,默认是不存在Info.plist这个文件的,这个由Xcode生成项目时根据项目目标的Info选项卡自动生成。并且,在Info选项卡添加某些内容的时候会自动消失部分内容。
不过,在添加文件类型的时候我们会发现,系统会自动添加一个“项目目标-Info.plist”的补充文件。这就是我们可以用来编辑的Info.plist文件,在Info选项卡中会自动消失的属性,我们都可以添加到这里。
步骤2,创建NSUserActivity。
这一步比较简单,只需要创建NSUserActivity
的实例,设置属性,然后设置为becomeCurrent()
,然后将实例赋值给相应的NSResponder
。
传递的数据只能是文档中指定的那些类型。
步骤3,修改NSUserActivity
复杂的来了,根据不同的场景,苹果共设置了三处,可以后续修改NSUserActivity
的方法。
NSUserActivityDelegate
我们可以NSUserActivityDelegate
的userActivityWillSave(NSUserActivity)
方法来修改NSUserActivity
的内容。
NSResponder
我们也可以通过NSResponder
的updateUserActivityState(_:)
方法来修改。
NSApplicationDelegate
最后,我们可以通过NSApplicationDelegate
的application(_:didUpdate:)
方法完成对于NSUserActivity
的最终修改。
感兴趣的读者可以同时使用三者,以了解者三者的调用顺序。
步骤4,接收NSUserActivity
接收是在NSApplicationDelegate
的application(_:continue:restorationHandler:)
方法。此外,NSApplicationDelegate
还有其它的方法用于快速响应接力已经处理出错的问题。
另外,
NSUserActivityDelegate
还包含一个userActivity(_:didReceive:outputStream:)
方法,可以处理数据在设备之间的双向传送。
接力在SwiftUI下如何实现
根据苹果的文档,接力在SwiftUI创建应用使用userActivity(_:element:_:)
和userActivity(_:isActive:_:)
来创建,然后使用onContinueUserActivity(_:perform:)
来接收。
遇到的困境
不过在实际使用中,经常遇到SwiftUI创建NSUserActivity
不及时,以及接收不到的问题。
在经过我的反复试验之后,我得出了如下结论。
创建NSUserActivity
要创建NSUserActivity
,必须满足以下条件。
NSUserActivity
实例必须持续存在。因此,不能使用临时变量创建,必须将其赋值给视图的属性。- 必须有对应的类型。
- 设置需要的内容。
- 必须使用
becomeCurrent()
。
你也可以尝试SwiftUI原生提供的方式,我遇到的问题是应用启动后能成功,但是后续更新容易出现问题。
如果使用原生方式,那么就不需要becomeCurrent()
。因为系统会自动根据预设条件自动判断是否becomeCurrent()
。
获取NSUserActivity
要获取NSUserActivity
,则需要满足以下条件:
- 在Info.plist中,注册要接收的
NSUserActivity
的类型。 - 这里,最麻烦的点来了。根据我的测试,如果想要接收
NSUserActivity
,在不同的系统中,机制居然是不一样的。- 如果是macOS,那么请使用
NSApplicationDelegate
来接收NSUserActivity
,SwiftUI自带的onContinueUserActivity(_:perform:)
在macOS中没有正常调用。 - 如果是iOS,刚好反过来。必须使用SwiftUI自带的
onContinueUserActivity(_:perform:)
来接收NSUserActivity
,UIApplicationDelegate
对应的方法反而会毫无响应。
- 如果是macOS,那么请使用
结合以上知识点,一个能够同时支持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权限。
OptionSet与NSPredicate
在Swift中,我们习惯了使用contains来比较OptionSet,这个方法使用起来十分简单,就不赘述了。某些情况下,我们必须使用NSPredicate来进行比较OptionSet,由于Objective-C不支持contains,所以比较的方法有所不同。特别的,我们有时需要考虑组合后的特性,因为不是数学简单的相等关系,有时候理解起来存在一定的困难,容易出错。
照片库搜索遇到的问题
我打算搜索照片库,需要排除掉实况照片和截图,我的代码一开始是这么写的:
let notLivePhotoPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])
这段代码运行之后,结果为空。我不是很理解,但是我还是想办法更改了代码:
let livePhotoPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let notLivePhotoPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: livePhotoPredicate)
let notScreenshotPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: screenshotPredicate)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])
这段代码运行的结果倒是符合预期。这是为什么呢?难道这两段代码不应该是等价的吗?
于是我重新查看苹果的文档,结果我发现,PHAssetMediaSubtype
不是enum,而是OptionSet。在NSPredicate中,比较OptionSet不能用相等,而应该使用:
let livePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
上面的代码运行同样符合预期。那如果将代码改成如下呢?
let notLivePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])
又不工作了。这是?
这是因为类型是OptionSet,比较不是简单的是否相等的算术关系。比如不工作的最后那段代码,如果是Set,那么结果永远是空集。
最终我的代码:
let combinedTypes:PHAssetMediaSubtype = [.photoLive, .photoScreenshot]
let combinedTypesPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", combinedTypes.rawValue)
let notCombinedTypesPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: combinedTypesPredicate)