肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

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

SwiftUI

原本我以为二者是等价的。但是实际上,二者有一个重要区别。如果不掌握,你就没法使用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
}

Xcode在开发SwiftUI项目时,持续CPU占用过高问题的解决

Xcode

问题的发现

在使用Xcode开发SwiftUI项目时,经常会遇到Xcode持续高CPU占用的问题。以往我没有重视这个问题,经常是很久之后才发现。此时,原本冰冷的Mac mini摸起来已经温热了。为此,我特意开发了一个小工具,提醒我关于这个问题。

App Helper

问题的解决

最初,我发现这个问题出现的几率,和我打开的SwiftUI的文件的数量相关。打开的SwiftUI的文件越多,越有可能遇到这个问题。

就在我以为这就是真正的原因,并发文之后,我在仅打开2-3个SwiftUI文件的时候又遇到了这个问题。这次,我终于找到了是哪个文件导致的这个问题了。是MainView。应该是文件功能太多导致的。我的MainView,是一个接近2000行的文件。它包含多项功能:

  1. 主视图布局,侧栏视图实现
  2. 工具栏实现
  3. 各种错误弹窗处理
  4. 文件处理(打开、解析、保存)
  5. 用户订阅状态管理等。

我尝试将代码分离按照功能为多个小文件。但是问题仍旧存在。最后发现,解决的办法就是注释掉预览的代码。

关于这个问题的一些补充

  1. CPU占用过高是因为MainView的功能太多导致的。
  2. 将MainView的代码拆分到多个文件不能解决这个问题。
  3. 即便MainView没有在预览,即预览视图显示为刷新按钮的状态。也还是会有这个问题。也就是说,只要预览视图开着,不管有没有要求运行预览,都会导致Xcode的CPU高占用。
  4. 此时,就算关闭了预览也没有用。所以,后台应该是某种卡死的状态。
  5. 解决办法只有
    1. 直接关闭预览。
    2. 一开始就不开预览。

使用@Observable替代ObservableObject,解决NavigationView、NavigationSplitView在@ObservedObject下界面回退的问题

长久以来,SwiftUI下的其中的一个坑就是NavigationView,它在使用@ObservedObject时,会出现界面回退的问题。

SwiftUI NavigationView pops back when updating observableObject

我原本以为随着NavigationView被反对,替代为NavigationSplitView和NavigationStack之后,这个问题也就消失了。但是没相当,这个问题在NavigationSplitView中仍旧存在。

实在没办法,我想到是否可以使用@Observable来替代ObservableObject。采用这个办法的主要缺点是前者只有最新一代的系统iOS 17、macOS 14才支持。但是好在我这个涉及到的应用是伴随Xcode发布的。因此,使用我的应用的用户应该主要是使用最新的系统。

从ObservableObject迁移到@Observable还是比较简单的。只需要跟随官方的文档一步一步操作即可。

Migrating from the Observable Object protocol to the Observable macro

不过,这里我还要额外再说两点:

  1. 苹果说@ObservedObject可以直接拿掉。因为Swift会自动管理。但是实际使用中,必须得加上@State,状态才能正常改变。
    1. 也就是说,你可以先拿掉看看,如果不正常,就在前面加个@State。这个是我自己发现的。
  2. 苹果说@Observable的class可以使用@StateObject,这是为了逐步迁移,但是实际上@StateObject并不支持@Observable的class,提示必须得是ObservableObject才行。

不使用第三方插件情况下,iCloud同步键值的方法

我们已经很习惯使用第三方的库来调用UserDefaults了。但是有时我们也需要只使用苹果官方的框架来实现相同的功能。

  1. 创建Key
  2. 注册iCloud的变化
  3. 更新userdefaults
  4. 注册Key的变化
  5. 更新iCloud

创建Key

extension UserDefaults {
  static let text = "text"
}

注册iCloud的变化

class AppDelegate: NSObject, NSApplicationDelegate {
  @AppStorage(UserDefaults.text) private var text: String = "Hello, Zhao!"

  func applicationDidFinishLaunching(_ notification: Notification) {
    registerKeyValueSyncing()
  }
}

extension AppDelegate {
  private func registerKeyValueSyncing() {
    NotificationCenter.default.addObserver(forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: nil) { [self] notification in
      guard let userInfo = notification.userInfo else { return }
      guard let reasonForChange = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { return }
      guard let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { return }
      guard keys.contains(UserDefaults.text) else { return }

      if reasonForChange == NSUbiquitousKeyValueStoreAccountChange {
        text = "Hello, Zhao!"
        return
      }

      if let newText = NSUbiquitousKeyValueStore.default.string(forKey: UserDefaults.text),
         newText != text {
        text = newText
        print("update text to \(text)")
      }
    }

    UserDefaults.standard.addObserver(self, forKeyPath: UserDefaults.text, options: [.new, .initial], context: nil)
  }

  override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard let keyPath, keyPath == UserDefaults.text else { return }

    if let change {
      print("[debug] change is \(change)")

      if let newText = change[.newKey] as? String {
        NSUbiquitousKeyValueStore.default.set(newText, forKey: keyPath)
      }
    }
  }
}

主程序

@main
struct KeyValueSyncingApp: App {
  #if os(macOS)
  @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
  #else
  @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
  #endif

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

有关XCUITest的一些补充(一)

XCTest

今天在编写项目截图的时候,遇到了好几个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)

参考

iCloud Key-Value同步nil值后,再次同步会出错问题的绕过

这个问题我已经向苹果报告了。FB13171112

具体的内容可以看我的这篇推文

临时的解决办法,就是不直接使用nil,而是把它封装起来。

// 之前
var foo:Int? = nil
// 现在
struct Bar:Codable {
    var foo:Int? = nil
}

这样就能避免直接使用nil了。缺点就是需要将它转换成Data再同步,多了几步。不过如果使用第三方框架的话,步骤其实不用多,第三方框架已经写好了。