肇鑫的技术博客

业精于勤,荒于嬉

接力(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权限。

完美解决Xcode在预览iOS应用SwiftUI View时,占用内存过多的问题

我的Mac配置是M1,16GB内存,512GB固态硬盘。原以为足够使用了,顶多是偶尔内存不足,用磁盘虚拟一些就足够了。可没成想,这两天调试iOS应用,动不动内存压力就黄了。

虽然我没有感知到系统变慢,但是看着内存压力变黄就是不爽。于是想追究一下具体的原因。结果让我大吃一惊!

为什么Xcode在iOS应用的预览时,会占用额外的内存?

所谓占用额外的内存,是相比较同样的代码,macOS版本应用预览时占用的内存。

macOS应用在预览时,因为Xcode本身就是在macOS运行的,所以无需模拟系统,只需要运行程序就可以了。所以相比于模拟iOS应用,少了模拟了iOS系统,节省了大量的内存。

根据上面的原因,我首先想到的是可以使用模拟较低版本的iOS系统。因为众所周知,版本越新的系统,可能消耗的资源就越大。目前Xcode自带的模拟器是iOS 16.4,由于我的新应用最低支持到iOS 16,所以我选择额外下载iOS 16.0的模拟器。

经过测试我发现,使用iOS 16.0模拟器,相对于iOS 16.4的模拟器,可以节省约5%的内存。16GB*5%=0.8GB。

此外,我之前默认都是使用iPhone SE 3进行模拟,因为这个也是我目前在使用的手机。不过iPhone SE 3本身是4GB内存,所以模拟也需要4GB内存,而iOS 16支持的手机中,内存最小的是iPhone 8,于是我将iPhone SE 3换成iPhone 8,结果发现内存的占用又降低了一些。

将模拟器从iPhone SE 3换成iPhone 8之后,可以再多节省约2%的内存。两次节省合计16GB*(2%+5%)=1.12GB,节省内存超过1GB。

大功告成了吗?

原来只需要换掉模拟器,就可以节省超过1GB的内存。事情真的只有这么简单吗?并没有!

平常我们在调试应用时,都是用Xcode写好界面和功能,在预览里看着差不多了,然后上真机上跑的。

xcode_preview

如图,我是在图中2所示的那样,通过制定模拟器型号来进行模拟的。但是我发现,最终占用的内存大小,要比我预期的大。

进一步分析,我发现,原来图中1部分的内容,会对于内存占用产生巨大的影响。

Xcode在预览时存在bug,导致模拟器内存占用翻倍

我们先做一番测试,然后你就知道问题出在哪里了。

  1. 首先我们维持2不变,然后将1的内容也改成iPhone 8。然后退出Xcode,重新开。(退出Xcode并重新开的目的是恢复真机模拟所占用的缓存)
  2. 点击预览,当预览呈现时,我们查看内存占用。此时内存的占用符合我们的预期。
  3. 将1的内容改成真机iPhone SE 3。因为我们模拟是指定的模拟器,此时3的内容应该不变。
    1. 但实际上,你会发现3位置的标签在转圈,这意味着模拟器在刷新。而这本来是不应该发生的。
    2. 转圈结束后,我们再次查看内存,发现内存占用大大增加。

这说明,Xcode出了bug,在我们指定模拟器的情况下,1的内容是否改变,应该不影响2、3的模拟器的,但实际上,虽然3表面上还是2的模拟器的最终结果,但是Xcode还额外开了1的模拟器,这导致模拟器打开的数量翻倍,内存占用大大增加。

验证上面的提到的bug

我们可以通过如下的步骤验证上面提到的bug:

  1. 将2中指定模拟器的代码注释掉。这样3中的模拟器,就会伴随1的变化而变化。
  2. 关闭Xcode,并重开,然后选择预览。
  3. 将1中的iPhone SE 3,换成iPhone 8,然后重新回到步骤2。

我们发现,无论是从1到2,还是从3到2,最终占用的内存都大大低于上面的情况。换句话说,也就是当2不设置时,因为1与3是相同的,每次都会只打开1个模拟器,因此内存占用的区别仅仅是iOS版本和iPhone版本区别,而不涉及到重复打开模拟器的问题。

不完美的解决方案

那么,我们为了保证同时还要调节真机的需要,是不是就只能妥协。不指定模拟器的型号,而选择与真机一致这种次优的解决方案了呢?

完美解决方案

并不是!我最终找到了完美的解决方案。

我们知道,Xcode之所以能调用多个模拟器,是因为系统中存在多个模拟器。那么如果我们通过手段,删除掉多余的模拟器,那是不是Xcode就不会调用了呢?抱着试试看的想法,我删除了所有模拟器,仅保留iPhone 8 iOS 16.0这一个。然后再次进行测试。

结果符合预期!因为系统中不再包含iPhone SE 3的模拟器,所以即便1切换为iPhone SE 3,3中的标签也不会有任何变化。因为iPhone 8是唯一的模拟器,所以任何时候,都只会用iPhone 8进行模拟。2中的指定模拟器也是没有必要的了。

至此,Xcode在模拟iOS应用的SwiftUI View预览时,占用内存过高的问题完美解决了。

实际使用中,我的内存剩余,从不足40%,恢复到接近70%。压力也不再变黄了。
测试时使用内存压力的手段是在终端中输入memory_pressure命令,查看最后一行的剩余内存。

其它节省内存的技巧

我发现Xcode有一个问题,正常退出Xcode之后,有时SourceKitService并没有伴随退出,而是继续留驻在内存中。它会占据1GB左右的空间。解决办法是在任务管理中,手动终止这个进程。

为了解决这个问题,我手动写了一个工具。会在在Xcode关闭之后,查看是否有SourceKitService遗留,有则自动结束,减少内存占用。如果你也需要,可以尝试使用。

SwiftUI view recreated may cause bugs what are hard to debug

Today, I encountered an issue that VideoPlayer turned to blank when iPhone screen was rotated.

The VideoPlayer was in a modal view that created with fullScreenCover. The sample code was like this:

import SwiftUI
import AVKit

struct TopView: View {
    private let player = AVQueuePlayer(playerItem: nil)
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Using Publisher for UIDevice.orientationDidChangeNotification

At first, I thought I should recreate the player when the screen was rotated.

import SwiftUI
import AVKit

struct TopView: View {
    @State private var playerItem:AVPlayerItem?
    
    private let player = AVQueuePlayer(playerItem: nil)
    private let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
    
    var body: some View {
        if playerItem != nil {
            VideoPlayer(player: player)
                .onReceive(publisher, perform: { _ in
                    self.player.pause()
                    self.playerItem = nil
                })
        } else {
            ProgressView()
                .onAppear(perform: setPlayer)
        }
    }
    
    private func setPlayer() {
        if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
            self.playerItem = AVPlayerItem(url: videoURL)
            player.replaceCurrentItem(with: playerItem)
            
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                if player.status == .readyToPlay {
                    player.play()
                    
                    if player.timeControlStatus == .playing {
                        timer.invalidate()
                    }
                }
            }
        }
    }
}

However, the new code didn't work. I added more debug point and finally found that TopView was recreated when the screen was rotated. Since the view was recreated, the player in onReceive was newly created, it couldn't stop the playing of the previously played item.

The issue was because, unlike other structs and objects, which could automatically released when container view was released. AVPlayer hold its owned reference when playing. This behavior caused the SwiftUI view was not released properly.

Use Binding from parent view

The solution was easy. Since I wanted the player to be constant, I should set it up in the parent view.

import SwiftUI
import AVKit

struct TopView: View {
    @Binding var player:AVQueuePlayer
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Now everything worked fine.

Final Thoughts

Some objects hold their own references as strong. Those objects may keep view from release and cause bugs. We should using Binding to create those objects in a higher view which is not recreated. Then the bugs are fixed.