肇鑫的技术博客

业精于勤,荒于嬉

SwiftUI读取、保存,界面的选择

在macOS系统中,我们可以使用NSOpenPanelNSSavePanel来实现文件的读取和写入。但是iOS并没有对应的类,这是因为虽然都是支持沙盒系统。macOS的沙盒和iOS的沙盒还是存在差异的。

macOS和iOS在沙盒部分的差异

macOS的沙盒是可选项,应用可以选择使用沙盒,也可以选择不使用。不过如果你想在苹果商店上架,就必须使用。不使用沙盒的应用,只能使用其它的渠道进行分发。但是仍旧可以使用Xcode进行打包,然后通过苹果的验证即可。

iOS的沙盒是必选项。应用必须使用沙盒。

macOS的应用使用NSOpenpanel打开用户选中的文件时,会自动获得选中文件的读取/写入权限。这被苹果称为所在位置读取。LSSupportsOpeningDocumentsInPlace

iOS的应用读取外部的文件的时候,不能自动获取相应的权限。因此,需要使用URLstartAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()函数来获取临时的权限。

由于iOS的沙盒隔离的更加彻底。要选择读取文件,有两种方式,一种是类似打开窗口的UIDocumentPickerViewController,一种是用于文档类型应用的UIDocumentBrowserViewController。二者最大的区别,是后者支持创建空白文件。

SwiftUI

到了SwiftUI的时代,苹果同样提供了我们两种方式,分别对应iOS的UIDocumentPickerViewControllerUIDocumentBrowserViewController

函数fileImporter(isPresented:allowedContentTypes:onCompletion:)

这个函数对应的是UIDocumentPickerViewController。它的缺点是相比它对应的类,它缺少该类的一些特性。比如,不能在打开时预设文件夹。

DocumentGroup

这个结构体,对应类UIDocumentBrowserViewController。一旦你使用了它,你的应用就自动获得了UIDocumentBrowserViewController的功能。

如何选择

上面的分类谈完之后,下面谈谈使用时要如何选择。

首先,如果你的应用符合文件类型应用的特征,那么可以优先选择DocumentGroup。因为它可以是你的应用获得更多的预制特性,这样应用编写起来会更加简单。

不过,DocumentGroup的使用过程,我也遇到了一些无法克服的困难。如果你的应用有如下的需求,那么就别使用DocumentGroup了。

  1. 打开的文件类型是package类型的子类,或者是package类型子类内部的一部分。
  2. 不需要创建空文件的功能。

有些文件本身是文件夹,但是在Finder中显示为单独的文件。这类文件都属于package类型的子类。
我遇到的问题是,DocumentGroup无法打开这个类型文件,虽然类型注册是成功的。
有时我们的应用虽然符合文件类型应用的范围,但是我们本身并不创建空文件,而是使用其它应用创建的文件。
比如翻译xliff文件的应用。本身不需要创建看的xliff文件。而是使用其它应用,如Xcode,导出的xliff文件。

其它的一些使用技巧

需要注意的是,在使用URLstartAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()函数的时候,这个URL应该是沙盒传递过来的那个最原始的URL。如果你经过计算,使用了它内部的其它URL,那么在写入操作的时候,也必须调用原始的URL,而不能用计算的URL。如果你使用了后者,则操作不会成功。

为什么使用SwiftUI常常会遇到莫名其妙的问题

在谈这个问题之前,我想先谈谈我所理解的苹果开发框架。会看这篇文章的人,应该绝大多书都是搞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成。这与之前讨论的情形类似。

结论:越是高级层次的框架,使用起来越简单。但是同时,支持的功能也相对越少。我们需要的功能越多,越细,就越需要调用更深层次的框架。

双击打开文件SwiftUI版本的实现(进阶篇)

阅读此篇之前,建议先阅读基础篇。双击打开文件SwiftUI版本的实现(基础篇)

本篇,我们要实现类似“Welcome to Xcode”这样的欢迎界面。当用户直接打开应用时,显示欢迎界面,而如果用户双击指定类型的文件,则不显示这个欢迎界面,而直接显示应用的主界面。

welcome

问题分析

首先想到的就是,开机直接显示欢迎界面,然后在其中处理文件的打开。测试显示,此路不通。

规则1: openURL的调用会自动选择接受该参数的WindowGroup

Window("WelcomeViewWindow", id: "WelcomeViewWindow") {
    WelcomeView(showFileImporter: $showFileImporter)
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)

WindowGroup(for: URL.self) { $fileURL in
    MainView(fileURL: $fileURL)
        .environment(\.managedObjectContext, persistenceController.container.viewContext)
}

虽然两个View都支持openURL,但是之后第二组的openURL会被调用,因为传递来的URL参数,只有第二组支持。

同时,也出现了一个问题。就是虽然只有第二组的openURL会被调用,但是欢迎窗口总是打开。

规则2: 位于首位的WindowGroup总是被打开

这是因为规则2的存在。解决办法也很简单,将两个WindowsGroup掉个个即可。同时,欢迎界面的openURL也可以删掉了。因为根本不会调用到。

遇到苹果API的bug

这样,打开文件的问题就解决了。不过,我们最初希望的是用户直接打开应用的时候,显示欢迎界面,而现在显示的是空白的主界面。这不是我们想要的。

解决办法也很简单,我们在主界面的onAppear阶段,检测fileURL,如果它是nil,就证明是直接打开的应用,我们就调用欢迎界面,然后通过dismiss关闭主界面。

.onAppear {
    if fileURL == nil {
        openWindow(id: "WelcomeViewWindow")
        dismiss()
    } 
}

可问题是,实际测试时,主界面每次都不能成功关闭。通过阅读dismiss的文档,我发现,dismiss能关闭窗口的前提是,ScenePhase必须是.active的状态。经过检测发现,主界面当前的ScenePhase却是.background的状态。

DismissAction

这明显是苹果API出了bug,于是我和苹果提交了反馈。有bug,生活还得继续。既然dismiss关闭不了,我们可以直接关闭窗口。我的解决方案是绑定Window,然后直接关闭这个Window。

private func quit() {
    if let window {
        window.close()
    } else {
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100), execute: DispatchWorkItem(block: quit))
    }
}

SwiftUIWindowBinder

解决openURL和onAppear调用顺序的问题

需要注意的事,SwiftUI中,二者是先onAppear,然后才调用openURL。所以,如果我们需要改变这个顺序,就需要将onAppear运行的代码,加入到DispatchQueue.main.async中,然后推后一个周期。