Task取消的两种方式及区别
错误写法
传统写法
现代写法
错误写法
传统写法
现代写法
关于测试时遇到的一些问题
签名与不签名的问题
自动化测试的问题
今天在编写项目截图的时候,遇到了好几个XCUITest的问题。解决之后,感觉这些问题应该算是蛮经典的,于是把它们记录下来,方便以后查阅。
最先遇到的是测试无法运行成功,提示有两个,一个是“Undefined symbols”,一个是“Linker command failed with exit code 1 (use -v to see invocation)”。
经过查看详细日志,发现是SPM(Swift Package Manager)的问题。当为应用目标时,SPM引入的第三方框架,如果该框架对于其它框架有依赖,那么SPM会自动导入该依赖的框架。但是在XCUITest的时候,或许是没有用到SPM,被依赖的框架并不会自动引用。因此,需要手动添加第三方框架之外,还需要手动再添加第三方框架依赖的框架。具体要添加多少,就要看错误日志提示的是哪个框架了。
解决了这个问题之后,测试终于可以通过。但是同时,又发生了一个新的问题。就是虽然Xcode显示Test成功了。但是却一直显示Testing,长久也没有测试完成。
经过查询,发现这也是Xcode一个bug。当使用XCUITest测试时,需要将并行测试的选项关闭,否则就会一直显示Testing。
还有一个注意事项,就是XCUITest测试时,必须将项目的目标设定为XCUITest这个目标,这个目标默认是隐藏的,必须手动添加出来。如果把应用作为目标,然后运行XCUITest,还是可能会出现一直显示Testing的问题。
最后,如果你需要检测文本,需要注意文本语言问题,将翻译的strings文件添加到XCUITest的目标,并不能自动调用并使用NSLocalizedString宏,因此,需要用或进行检测,像这样:
XCTAssertTrue(app.staticTexts["使用云端服务"].exists || app.staticTexts["Try Cloud"].exists)
这个问题我已经向苹果报告了。FB13171112
具体的内容可以看我的这篇推文。
临时的解决办法,就是不直接使用nil,而是把它封装起来。
// 之前
var foo:Int? = nil
// 现在
struct Bar:Codable {
var foo:Int? = nil
}
这样就能避免直接使用nil了。缺点就是需要将它转换成Data再同步,多了几步。不过如果使用第三方框架的话,步骤其实不用多,第三方框架已经写好了。
有关local store和cloud store的补充说明。
苹果文档讲述了Core Data融合的基本的方法。不过,它讲述的比较简略。代码好多部分都是占位符,需要自己填写。此外,苹果的融合,默认的单机模式,也就是没有使用CloudKit的同步的情况。如果你使用CloudKit,就会遇到苹果没有提到的情况。
融合升级的基本步骤:
不过我更建议:
下面的文章基于后者。
private func canMigration() -> Bool {
let sourceURL = Bundle.main.url(forResource: "Model", withExtension: "mom", subdirectory: "Model.momd")
let destinationURL = Bundle.main.url(forResource: "Model 2", withExtension: "mom", subdirectory: "Model.momd")
let sourceModel = NSManagedObjectModel(contentsOf: sourceURL!)
let destinationModel = NSManagedObjectModel(contentsOf: destinationURL!)
if let _ = try? NSMappingModel.inferredMappingModel(forSourceModel: sourceModel!, destinationModel: destinationModel!) {
return true
}
return false
}
注意:在Xcode中,创建模型的文件扩展名分别是“xcdatamodeld”和“xcdatamodel”,但是生成应用之后,它们的扩展名变成了“momd”和“mom”,并且前者变成了独立的文件夹。

这一步的作用是,提前知晓能否转换。如果这一步都不成,就不用执行下一步了。如果直接执行下一步,那么两部的错误可能会同时呈现,增加调试的难度。
private func migragtionFromModel2Model2() {
let container = NSPersistentContainer(name: "Model")
let moc = container.viewContext
let applicationSupportFolderURL = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
#if targetEnvironment(macCatalyst)
let storeURL = URL(fileURLWithPath: "Photo Organizer/Model.sqlite", isDirectory: false, relativeTo: applicationSupportFolderURL)
#else
let storeURL = URL(fileURLWithPath: "Model.sqlite", isDirectory: false, relativeTo: applicationSupportFolderURL)
#endif
let options:Dictionary<AnyHashable, Any>? = [
NSMigratePersistentStoresAutomaticallyOption : NSNumber(value: true),
NSInferMappingModelAutomaticallyOption: NSNumber(value: true),
NSPersistentHistoryTrackingKey: NSNumber(value: true)
]
if let _ = try? moc.persistentStoreCoordinator!.addPersistentStore(type: NSPersistentStore.StoreType.sqlite, configuration: "Local", at: storeURL, options: options) {
print("success")
} else {
print("failed")
}
}
此处需要注意两点:
- macCatalyst下的sqlite位置与iOS不同,前面多了一个应用名。(行11到15)
- 如果你之前使用过CloudKit同步,那么还需要多一个“ NSPersistentHistoryTrackingKey: NSNumber(value: true)”。(行20)否则数据库就会以只读方式打开,无法同步。
这个就是融合升级之前打开的方式。值得一提的是,如果你融合升级之后,还想要对于数据库的内容进行额外的处理。那么应该在这一步完成之后,进行处理。
传统上,接力在UIKit和AppKit上的实现有两种。一种是基于UIDocument/NSDocument的,一种是基于NSResponder/UIResponder的。苹果有文档分别说明了两种情况下,要如何实现接力功能。
但是,针对SwiftUI,苹果并没有专门进行说明要如何实现接力。苹果网站上的确有一篇文档,同时用到SwiftUI和NSUserActivity,但是那篇文档偏重的是如何恢复应用的状态,并不是如何使用接力。
Increasing App Usage with Suggestions Based on User Activities
本文的结果,是我通过试验摸索出来的。适用于苹果的iOS 16和macOS 13。鉴于目前版本的苹果实现过于简陋。大概率苹果今后会做出较大的修改。这点希望阅读者注意。
类似接力这种预制的功能,最复杂的地方是,苹果本身设定了它预想的功能方式,这就像是填空题,而不是简答题,你需要在留给你的空位填入自己的内容,而不能天马行空,自己设计一套。
使得接力更为复杂的,是苹果还设置了几种完全不同的方式。
如果你的应用是文档应用,即基于UIDocument,NSDocument或者DocumentGroup,那么应该把接力的类型写在文档类型里。
其它方式,则应该把接力的类型写在顶层。
需要注意两点:
- 注册在Info.plist的目的是标记应用支持“接收”何种类型。即,发送本身是不需要注册的。
- 在Xcode 14中,默认是不存在Info.plist这个文件的,这个由Xcode生成项目时根据项目目标的Info选项卡自动生成。并且,在Info选项卡添加某些内容的时候会自动消失部分内容。
不过,在添加文件类型的时候我们会发现,系统会自动添加一个“项目目标-Info.plist”的补充文件。这就是我们可以用来编辑的Info.plist文件,在Info选项卡中会自动消失的属性,我们都可以添加到这里。
这一步比较简单,只需要创建NSUserActivity的实例,设置属性,然后设置为becomeCurrent(),然后将实例赋值给相应的NSResponder。
传递的数据只能是文档中指定的那些类型。
复杂的来了,根据不同的场景,苹果共设置了三处,可以后续修改NSUserActivity的方法。
我们可以NSUserActivityDelegate的userActivityWillSave(NSUserActivity)方法来修改NSUserActivity的内容。
我们也可以通过NSResponder的updateUserActivityState(_:)方法来修改。
最后,我们可以通过NSApplicationDelegate的application(_:didUpdate:)方法完成对于NSUserActivity的最终修改。
感兴趣的读者可以同时使用三者,以了解者三者的调用顺序。
接收是在NSApplicationDelegate的application(_:continue:restorationHandler:)方法。此外,NSApplicationDelegate还有其它的方法用于快速响应接力已经处理出错的问题。
另外,
NSUserActivityDelegate还包含一个userActivity(_:didReceive:outputStream:)方法,可以处理数据在设备之间的双向传送。
根据苹果的文档,接力在SwiftUI创建应用使用userActivity(_:element:_:)和userActivity(_:isActive:_:)来创建,然后使用onContinueUserActivity(_:perform:)来接收。
不过在实际使用中,经常遇到SwiftUI创建NSUserActivity不及时,以及接收不到的问题。
在经过我的反复试验之后,我得出了如下结论。
要创建NSUserActivity,必须满足以下条件。
NSUserActivity实例必须持续存在。因此,不能使用临时变量创建,必须将其赋值给视图的属性。becomeCurrent()。你也可以尝试SwiftUI原生提供的方式,我遇到的问题是应用启动后能成功,但是后续更新容易出现问题。
如果使用原生方式,那么就不需要becomeCurrent()。因为系统会自动根据预设条件自动判断是否becomeCurrent()。
要获取NSUserActivity,则需要满足以下条件:
NSUserActivity的类型。NSUserActivity,在不同的系统中,机制居然是不一样的。
NSApplicationDelegate来接收NSUserActivity,SwiftUI自带的onContinueUserActivity(_:perform:)在macOS中没有正常调用。onContinueUserActivity(_:perform:)来接收NSUserActivity,UIApplicationDelegate对应的方法反而会毫无响应。结合以上知识点,一个能够同时支持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权限。
在macOS系统中,我们可以使用NSOpenPanel和NSSavePanel来实现文件的读取和写入。但是iOS并没有对应的类,这是因为虽然都是支持沙盒系统。macOS的沙盒和iOS的沙盒还是存在差异的。
macOS的沙盒是可选项,应用可以选择使用沙盒,也可以选择不使用。不过如果你想在苹果商店上架,就必须使用。不使用沙盒的应用,只能使用其它的渠道进行分发。但是仍旧可以使用Xcode进行打包,然后通过苹果的验证即可。
iOS的沙盒是必选项。应用必须使用沙盒。
macOS的应用使用NSOpenpanel打开用户选中的文件时,会自动获得选中文件的读取/写入权限。这被苹果称为所在位置读取。LSSupportsOpeningDocumentsInPlace
iOS的应用读取外部的文件的时候,不能自动获取相应的权限。因此,需要使用URL的startAccessingSecurityScopedResource()和stopAccessingSecurityScopedResource()函数来获取临时的权限。
由于iOS的沙盒隔离的更加彻底。要选择读取文件,有两种方式,一种是类似打开窗口的UIDocumentPickerViewController,一种是用于文档类型应用的UIDocumentBrowserViewController。二者最大的区别,是后者支持创建空白文件。
到了SwiftUI的时代,苹果同样提供了我们两种方式,分别对应iOS的UIDocumentPickerViewController和UIDocumentBrowserViewController。
这个函数对应的是UIDocumentPickerViewController。它的缺点是相比它对应的类,它缺少该类的一些特性。比如,不能在打开时预设文件夹。
这个结构体,对应类UIDocumentBrowserViewController。一旦你使用了它,你的应用就自动获得了UIDocumentBrowserViewController的功能。
上面的分类谈完之后,下面谈谈使用时要如何选择。
首先,如果你的应用符合文件类型应用的特征,那么可以优先选择DocumentGroup。因为它可以是你的应用获得更多的预制特性,这样应用编写起来会更加简单。
不过,DocumentGroup的使用过程,我也遇到了一些无法克服的困难。如果你的应用有如下的需求,那么就别使用DocumentGroup了。
有些文件本身是文件夹,但是在Finder中显示为单独的文件。这类文件都属于package类型的子类。
我遇到的问题是,DocumentGroup无法打开这个类型文件,虽然类型注册是成功的。
有时我们的应用虽然符合文件类型应用的范围,但是我们本身并不创建空文件,而是使用其它应用创建的文件。
比如翻译xliff文件的应用。本身不需要创建看的xliff文件。而是使用其它应用,如Xcode,导出的xliff文件。
需要注意的是,在使用URL的startAccessingSecurityScopedResource()和stopAccessingSecurityScopedResource()函数的时候,这个URL应该是沙盒传递过来的那个最原始的URL。如果你经过计算,使用了它内部的其它URL,那么在写入操作的时候,也必须调用原始的URL,而不能用计算的URL。如果你使用了后者,则操作不会成功。
在谈这个问题之前,我想先谈谈我所理解的苹果开发框架。会看这篇文章的人,应该绝大多书都是搞iOS开发的,那么我们就从iOS开发的角度来谈,捎带macOS开发的角度。
在没有SwiftUI之前,iOS开发都是使用UIKit,相应的macOS的开发使用AppKit。它们也被称为Cocoa Touch和Cocoa。而实际上,虽然UIKit能够实现开发者想要实现的大部分功能。但是某些涉及到硬件或者网络底层功能的时候,开发者还是需要使用另外的框架。
如果从开发语言的角度看,iOS/macOS的最底层的系统,是使用C语言和C++来开发的。被称作Core Foundation。而上一层,则是使用Objective-C语言开发的Foundation。和Foundation同层次的,是同样用Objective-C开发的UIKit和AppKit。
有了Swift之后,苹果在用Swift重写Foundation这一层,最近这几年的WWDC,几乎年年都有Foundation用Swift重写部分功能之后,性能提升的消息。但其实,这所谓的性能提升,本质上讲就是用静态类型的语言,替换动态类型语言换来的。也许有人会问,为啥苹果不用Swift重写更底层的Core Foundation呢?答案很简单,因为C和C++也是静态类型的语言,用Swift重写,也不会有额外的性能提升。
说了半天,下面言归正传,回归到SwiftUI的问题。SwiftUI是基于UIKit/AppKit的。所以如果说后者能做的事是100,那么SwiftUI只能做到其中的7到8成。这与之前讨论的情形类似。
结论:越是高级层次的框架,使用起来越简单。但是同时,支持的功能也相对越少。我们需要的功能越多,越细,就越需要调用更深层次的框架。