跳转至

9.添加你自己的任务

在前两章中,你创建了一个菜单栏应用程序,使用自定义视图来显示菜单项,并添加了一个计时器来控制该应用程序。

然后,你研究了使用警报和通知与用户沟通的不同方式。

这个应用程序的最后一个阶段是让用户能够输入自己的任务,根据需要保存和重新加载。

你将学习如何管理数据存储,了解更多关于Mac沙盒的信息,并看看如何在AppKit应用程序中使用SwiftUI视图。最后,你将让用户选择在他们登录Mac时启动该应用。

储存数据

在你能让用户编辑他们的任务之前,你需要有一种方法来存储和检索他们。

打开上一章的项目,或者打开下载资料中本章的starter项目。起步项目没有额外的代码,但它的源文件在项目导航器中被组织成组。这使你更容易浏览一个大项目,因为你可以折叠你现在不工作的组。

img

打开下载材料中的assets文件夹,将DataStore.swift拖入Models组。勾选Copy items if neededTime-ato目标,然后点击Finish,将文件添加到你的项目。

这个文件包含三个方法。

  1. dataFileURL()返回一个可选的URL到数据存储文件。现在,这个方法返回nil,但你很快就会解决这个问题。
  2. readTasks()使用数据存储的URL来读取存储的JSON,并将其解码为一个Task对象的数组,如果出现问题,则返回一个空数组。
  3. save(tasks:)将提供的Task对象编码为JSON,并将其保存到数据文件。

readTasks()save(tasks:)方法与你在iOS应用程序中使用的方法相同,所以没有必要去研究细节。但是dataFileURL()将是有趣的。

找到数据文件

为了保存数据,你首先需要确定何处保存它。将dataFileURL()中的return nil替换为:

// 1
let fileManager = FileManager.default

// 2
do {
  // 3
  let docsFolder = try fileManager.url(
    for: .documentDirectory,
    in: .userDomainMask,
    appropriateFor: nil,
    create: true)
  // 4
  let dataURL = docsFolder
    .appendingPathComponent("Timeato_Tasks.json")
  return dataURL
} catch {
  // 5
  print("URL error: \(error.localizedDescription)")
  return nil
}

如果你通过第一章"设计数据模型"的工作,你会记得其中的一些内容,但踏踏实实地走下去,你会发现:

  1. 使用默认的FileManager来访问文件和文件夹。
  2. 把文件管理代码放在do块内,因为它可以抛出。
  3. 要求fileManager提供当前用户文件夹中Documents文件夹的URL。
  4. URL上附加一个文件名并返回。
  5. 打印错误,如果有问题则返回nil

现在dataFileURL()返回一个文件路径URL,你可以通过保存示例任务来测试它。

打开TaskManager.swift并添加这个属性声明:

let dataStore = DataStore()

作为一个测试,在init()中插入这句话作为第一行:

dataStore.save(tasks: tasks)

构建并运行该应用程序。不会有什么新的东西可看,但是TaskManager创建了一个DataStore并将样本任务保存到你的数据文件中。

代码特别要求FileManager提供一个通往Documents文件夹的路径。你可能认为这是个坏主意。为什么要用这样的文件弄乱你的Documents文件夹?应用程序不是应该把它们藏在某个地方吗?

去搜索你的Documents文件夹,找到一个叫Timeato_Tasks.json的文件。它不在那里! 是不是有一个保存错误?

检查Xcode控制台。你是否看到任何标有URL errorSave error的条目? 没有,所以看起来文件保存了,但它在哪里呢?

Mac沙盒

当你在macOSiOS上运行一个应用程序时,操作系统会将你的应用程序放在自己的沙盒里,以此来保护自己和其他所有的应用程序和数据。你在第2章"与Windows一起工作 "中看到了这是如何默认阻止所有下载的。现在,你遇到的是它保护你的文件的方式。

打开一个Finder窗口,然后打开FinderGo菜单。按住Option,看到Library出现在菜单中,选择它来打开你的Library文件夹。

向下滚动,直到看到Containers并打开它。Containers文件夹里有一组非常奇怪的文件夹。其中一些有应用程序的名字,如Calendar,而一些使用捆绑标识符,如com.apple.photolibraryd。最奇怪的是,似乎有多组具有相同名称的文件夹!

这里发生了什么?Finder在欺骗你,但Terminal从不说谎。打开你的Terminal程序,以便你能看到这个文件夹中的真正内容。

Terminal中,输入这个命令并按Return键:

cd ~/Library/Containers

你用cd来移动到一个不同的目录。在文件路径中,tilde(~)是获取当前用户目录的一种速记方式,所以在我的例子中,~就相当于输入/Users/sarah。然后,你要换到Library目录,最后进入Containers

接下来输入这个,然后按Return

ls -l

ls命令列出当前目录,-l参数告诉它以long格式列出,每行有一个文件或文件夹。

img

你可以看到,所有的文件夹实际上都在使用一个捆绑标识符或一个唯一的标识符。Finder正在将这些翻译成相关的应用程序名称。

现在你知道发生了什么,回到你的Finder窗口,向下滚动容器,直到你找到TIME-ATO文件夹。(终端将这个文件夹列为com.raywenderlich.Time-ato)。里面唯一的东西是一个叫Data的文件夹。打开Data,你会看到你的用户文件夹的一部分的奇怪镜像:

img

一些文件夹的图标上有一个小箭头,告诉你它们是其他文件夹的别名。如果你打开 "Desktop "的别名,你会看到你实际桌面上的所有文件。

Documents不是一个别名,如果你打开它,你将只看到一个文件。Timeato_Tasks.json。因此,即使你要求FileManager将数据文件保存在你的Documents文件夹中,它还是将其保存在沙盒中的Documents文件夹中,而没有触及你的实际Documents文件夹。

这感觉不对,但它实际上是一个伟大的系统。这意味着,你不必担心任何其他应用程序使用相同的文件或文件夹名称。你不能改写他们,他们也不能改写你。作为一个开发者,你经常想对你的应用程序做一个完全的重置来测试它,你可以通过删除它的容器来做到这一点。

检索数据

你把样本任务保存到一个文件中,并确认了它的实际位置。现在,你将在应用程序启动时使用该文件来列出任务,而不是使用样本任务。

打开TaskManager.swift并替换var tasks: [Task] = Task.sampleTasks改为:

var tasks: [Task]

接下来,为了摆脱错误,将init()中的dataStore.save(tasks: tasks)替换为:

tasks = dataStore.readTasks()

最后,为了让你能真正确定任务来自文件,打开JSON数据文件并进行修改。JSON没有格式化,但你可以看到任务的标题。至少要改变一个标题。

img

建立并运行该应用程序,在菜单中看到你编辑的任务:

img

打开沙盒

这个应用程序在沙盒内完美运行,没有任何额外的权限,但不是所有的应用程序都是一样的。如果你有一个需要更多功能的应用程序,你可以改变一些设置。

回到Xcode中,在项目导航器的顶部选择项目,然后选择TIME-ATO目标。点击顶部的Signing & Capabilities,看看App Sandbox的设置:

img

这里可以访问沙盒最常见的例外情况。

你已经在本书第1节中使用Outgoing Connections (Client)来允许下载。只有当你的应用程序要接收不是它发起的连接时,才需要Incoming Connections (Server)

HardwareApp Data的设置与iOS的类似,但对于iOS应用程序,你在Info.plist中添加隐私描述,而不是检查按钮。

File Access设置中,有一些需要注意的不同。首先,这些不是打开或关闭的设置--你可以通过每个设置旁边的弹出窗口选择NoneRead OnlyRead/Write访问。

User Selected File选项意味着,只要你显示一个文件对话框,让用户直接选择文件或文件夹,你的应用程序就可以访问Mac上的任何文件夹。如果你想让你的应用程序记住这种访问,你需要创建security-scoped bookmarks。苹果公司关于启用应用程序沙盒的文档有所有的细节。

如果你的应用程序需要访问一些文件夹或功能,但这些设置没有涵盖,你可以编辑应用程序的entitlements文件以请求临时访问。尽管名字是这样,这种访问权不会过期,但它不一定能通过App Store的审查程序。在应用审查说明中记录你需要例外的原因,以增加你获得批准的机会。

最后,如果你的应用程序真的不能在沙盒内运行,而且你不打算通过Mac App Store发布,你可以通过点击App Sandbox设置右上方的Trashcan,完全删除你的应用程序的沙盒限制。

编辑任务

你已经得到了文件处理的工作和测试。现在,是时候让你的用户编辑他们自己的任务了。要做到这一点,你将使用一个SwiftUI视图,每当用户选择Edit Tasks…菜单项时,你将在一个新窗口中显示该视图。

首先,在你的项目的Views组中添加一个新的SwiftUI View文件。将其称为EditTasksView.swift

EditTasksView添加这两个属性:

@State private var dataStore = DataStore()
@State private var tasks: [Task] = []

像这样的编辑器必须有一个选项,可以在不改变任何东西的情况下取消。因此,它得到了自己的DataStore,并读取自己的Task对象列表。如果用户保存更改,那么DataStore可以将编辑的任务保存到数据文件中。

接下来,为了设置这个视图的用户界面,将标准的Text替换为:

// 1
VStack {
  // 2
  ForEach($tasks) { $task in
    HStack(spacing: 20) {
      // 3
      TextField(
        "",
        text: $task.title,
        prompt: Text("Task title"))
        .textFieldStyle(.squareBorder)

      // 4
      Image(systemName: task.status == .complete
        ? "checkmark.square"
        : "square")
        .font(.title2)

      // 5
      Button {
        // delete task here
      } label: {
        Image(systemName: "trash")
      }
    }
  }
  // 6
  .padding(.top, 12)
  .padding(.horizontal)

  // buttons go here
}
// 7
.frame(minWidth: 400, minHeight: 430)

这个SwiftUI代码在做什么?

  1. 将整个视图包裹在一个VStack中,以便在窗口下显示。
  2. 使用一个绑定属性作为ForEach的数据源。这允许每一行的数据变化流回@State属性。
  3. 使用一个TextField作为每个任务的标题的编辑器,用一个方形的边框对其进行造型。TextField没有标签,但有一些占位符文本。
  4. 为每一行添加一个Image,表示任务是否完成。
  5. 设置一个Button来删除每个Task
  6. 应用一些填充物,使其看起来更好。
  7. 为该视图选择最小尺寸。

显示数据

现在,这个视图没有什么可显示的,所以要给EditTasksView添加这些方法:

func getTaskList() {
  // 1
  tasks = dataStore.readTasks()
  // 2
  addEmptyTasks()
}

func addEmptyTasks() {
  // 3
  while tasks.count < 10 {
    tasks.append(Task(id: UUID(), title: ""))
  }
}

他们是做什么的?

  1. 使用dataStore读入存储的任务列表。
  2. 调用addEmptyTasks()
  3. 添加一个新的任务,标题为空白,直到列表中出现10个任务。

Pomodoro技术建议每天设置10个任务,所以编辑器将显示10个编辑栏。如果tasks少于10个元素,addEmptyTasks()会增加额外的元素来修复它。这保证了ForEach循环总是有10个条目可以显示,即使其中有些是空白的。

你几乎已经准备好看到这个样子了,但首先你需要在VStack上添加这个修改器,就在你设置frame的地方:

.onAppear {
  getTaskList()
}

这使得它在视图出现时从数据文件加载任务。

在画布预览中点击Resume或按Command-Option-P刷新预览:

img

删除任务

到目前为止,一切都很好。新的视图看起来不错。现在,你必须使删除按钮工作。在其他方法下面添加这个新方法:

func deleteTask(id: UUID) {
  // 1
  let taskIndex = tasks.firstIndex {
    $0.id == id
  }

  // 2
  if let taskIndex = taskIndex {
    // 3
    tasks.remove(at: taskIndex)
    // 4
    addEmptyTasks()
  }
}

这个方法在做什么?

  1. tasks中寻找id与参数匹配的Task的索引。
  2. 检查这个方法是否返回一个索引。
  3. 删除数组中这个索引的任务。
  4. 确保视图中仍有10个任务。

滚动到文件的布局部分,将// delete task here替换为:

deleteTask(id: task.id)

先不要建立和运行,因为你没有办法显示这个视图。相反,打开Live Preview。点击Bring Forward来查看Xcode预览窗口并测试该视图。

编辑一些标题并删除一些任务:

img

删除和编辑都在按预期进行。

添加按钮

接下来,你需要添加三个控制按钮:

  • Cancel关闭窗口而不保存。
  • Mark All Incomplete重置所有的任务。如果你每天都使用一些相同的任务,不想删除它们,但需要将它们的状态设为notStarted,这就很方便了。
  • Save来存储你所有的改变,并关闭窗口。

还是在EditTasksView.swift中,把// buttons go here替换为:

Spacer()

HStack {
}

Spacer用于将按钮推至窗口底部,而HStack则用于容纳它们。但是这一大块SwiftUI代码已经够长了,所以你要把按钮分离到它们自己的视图中。

Command-click你刚刚添加的HStack并选择Extract Subview

img

这将用ExtractedSubview()替换HStack,并在文件的末尾添加一个新的视图,名称为EditButtonsView

在两个地方将其重命名为EditButtonsView:在原视图和新视图定义中。

Note

Xcode曾经在提取一个子视图后自动选择这两个子视图进行一步编辑。你可以通过右击ExtractedSubview()并选择Refactor ▸ Rename...来达到同样的效果。

现在你已经有了EditButtonsView,把它的body的内容替换为:

// 1
HStack {
  // 2
  Button("Cancel", role: .cancel) {
    // close window
  }
  // 3
  .keyboardShortcut(.cancelAction)

  // 4
  Spacer()

  // 5
  Button("Mark All Incomplete") {
    // mark tasks as incomplete
  }

  Spacer()

  Button("Save") {
    // save tasks & close window
  }
}
// 6
.padding(12)

踏过这些线路,你:

  1. 用这个替换空的HStack
  2. 添加一个Cancel按钮,设置其角色。
  3. 给它一个cancelAction的键盘快捷键,以便按Escape来触发它。
  4. 加入一个Spacer,将三个按钮分布在视图的底部。
  5. 再创建两个按钮,用另一个Spacer隔开。
  6. 应用一些padding,使它们不会太靠近视图的边缘。

重新开始预览,看看这看起来如何:

img

你的预览现在太宽了,不容易看到,所以在预览中添加这个frame修改器,将其设置为视图的最小尺寸:

.frame(width: 400, height: 430)

编码的按钮

这个视图的最后一个任务是让这些新按钮完成它们的工作。

为了处理Cancel按钮,首先在EditButtonsView中添加这个方法:

func closeWindow() {
  NSApp.keyWindow?.close()
}

这个方法使用NSApplication的共享实例来关闭key或最前面的窗口,也就是用户最后交互的窗口。

要调用这个方法,请将Cancel按钮的动作中的// close window替换为:

closeWindow()

另外两个方法需要访问tasksdataStore,所以你要把这些传给EditButtonsView

EditButtonsView的顶部,在body之前插入这些声明:

@Binding var tasks: [Task]
let dataStore: DataStore

通过在tasks上使用@Binding,你已经确保了任何变化都会流回父视图。

向上滚动到EditTasksView,可以看到这引起的错误。将显示错误的那一行替换为:

EditButtonsView(tasks: $tasks, dataStore: dataStore)

现在,EditTasksView正在向EditButtonsView发送任务数据和数据存储。

有了这些,给EditButtonsView提供它需要的其余两个方法:

func saveTasks() {
  // 1
  tasks = tasks.filter {
    !$0.title.isEmpty
  }
  // 2
  dataStore.save(tasks: tasks)
  // 3
  closeWindow()
}

func markAllTasksIncomplete() {
  // 4
  for index in 0 ..< tasks.count {
    tasks[index].reset()
  }
}

这些方法:

  1. 摆脱任何带有空标题的任务。
  2. 使用dataStore来保存编辑的数据。
  3. 关闭该窗口。
  4. 循环浏览所有的任务,并将它们重置为notStarted

最后,设置你的按钮来调用这些方法。

// mark tasks as incomplete替换为:

markAllTasksIncomplete()

并将// save tasks & close window替换为:

saveTasks()

你的编辑界面已经全部到位,你已经把它和代码连接起来了,所以现在是时候让它出现在你的应用程序中了。

显示编辑窗口

打开AppDelegate.swift,找到@IBAction,称为showEditTasksWindow(_:)。你已经把它连接到Edit Tasks...菜单项。

把这段代码放在该方法中:

// 1
let hostingController = NSHostingController(
  rootView: EditTasksView())

// 2
let window = NSWindow(contentViewController: hostingController)
window.title = "Edit Tasks"
// 3
let controller = NSWindowController(window: window)

// 4
NSApp.activate(ignoringOtherApps: true)
// 5
controller.showWindow(nil)

然后,在文件的顶部添加这个,以消除错误:

import SwiftUI

这提供了AppKitSwiftUI之间的桥梁,所以有几件事需要注意:

  1. 要在AppKit中显示一个SwiftUI视图,需要设置一个NSHostingController,并将SwiftUI视图指定为其rootView
  2. 一旦你得到了托管控制器(它是NSViewController的子类),创建一个NSWindow并将托管控制器设置为其contentViewController。你也可以在这里配置该窗口,所以改变它的标题。
  3. AppKit应用程序中,每个窗口都需要一个NSWindowController,所以下一步要为窗口配置一个控制器。这样你就有了完整的链条:NSWindowController - NSWindow - NSHostingController - EditTasksView
  4. 就像你在前一章显示警报时做的那样,确保该应用程序是活动的应用程序。
  5. 告诉窗口控制器显示其窗口。

现在你准备好了,可以试一试了。构建并运行该应用,从菜单中选择Edit Tasks…,这就是你的窗口,在一个AppKit应用中显示一个SwiftUI视图:

img

保存和重新加载

你的界面看起来不错,所以现在是时候运行一些测试,检查一切是否按预期工作。打开Edit Tasks窗口,然后按Escape。窗口如期关闭。

使用菜单控制来启动第一个任务,然后标记为完成。再次打开Edit Tasks窗口。这很奇怪。为什么第一个任务没有打上复选标记?

还记得EditTasksView是如何从存储空间加载任务的吗?如果应用程序的主要部分没有保存任何变化,这不会显示它们。每当有任何变化时,应用程序都需要保存,这样任务和它们的属性就会在应用程序启动时持续存在。这样做的最佳时间是在任何任务开始后和任何任务结束后。

打开TaskManager.swift,在startNextTask()的末尾添加这一行:

dataStore.save(tasks: tasks)

并在stopRunningTask(at:)的末尾添加同样的一行。

再次运行你的测试。建立并运行应用程序,启动并完成第一个任务,然后打开Edit Tasks窗口:

img

成功了! 现在只要有任何变化,你的任务就会被保存。但这意味着当应用程序启动并加载数据时,你要做一些内务工作。如果有一个任务正在进行中怎么办?

还是在TaskManager.swift中,在init()中插入这几行,在读取任务后,但在启动定时器前:

// 1
let activeTaskIndex = tasks.firstIndex {
  $0.status == .inProgress
}
if let activeTaskIndex = activeTaskIndex {
  // 2
  timerState = .runningTask(taskIndex: activeTaskIndex)
}

这是在做什么?

  1. 寻找第一个状态为inProgress的任务的索引。
  2. 如果有个任务正在进行中,将timerState设置为runningTask,与任务的索引相关。

有了这个设置,你就可以在任务运行时启动和停止应用程序,它就会捕捉到时间并继续运行。

是时候进行另一次测试了。再次构建和运行。你的第一个任务仍然在菜单中被标记为完成。打开Edit Tasks窗口,做两个改动。

  • 点击Mark All Incomplete,删除已完成任务旁边的复选标记。
  • 编辑任何任务的标题。

img

点击Save来保存你的修改,当窗口关闭时,打开菜单。你的编辑没有显示出来!

退出并重新启动应用程序,现在你的编辑内容出现在菜单中:

img

发生了什么事?Edit Tasks窗口保存了编辑过的任务,但是当窗口关闭时,TaskManager不知道要重新加载它们,所以它显示旧版本的tasks,直到应用程序重新启动。

使用通知中心

为了解决这个问题,每当你保存数据时,你就会发送一个通知。这并不是像你在上一章中使用的可见通知。相反,你将使用NotificationCenter来发布一个NSNotification。任何对象都可以注册一个observer来监听这个通知并对其做出反应。

每个NSNotification都必须有一个名字。这个名字不仅仅是一个字符串,它是一个Notification.Name。系统发送的通知有标准的名称--对于iOS应用程序,你可能已经使用了其中的一些来检测键盘的变化。但在这种情况下,你要定义你自己的名字来发送一个自定义的通知。

你可以在项目的任何地方定义这个名字,但由于它与存储的数据有关,请将这个扩展添加到DataStore.swift,在DataStore结构之外:

extension Notification.Name {
  static let dataRefreshNeeded =
    Notification.Name("dataRefreshNeeded")
}

这创建了一个你可以用来指代你的通知的名字,而不需要使用字符串,因为字符串有可能出错,而且不能自动完成。

使用通知有两个部分。第一部分是post

打开EditTasksView.swift,在saveTasks()的末尾添加这个:

NotificationCenter.default.post(
  name: .dataRefreshNeeded,
  object: nil)

这使用默认的NotificationCenter,并使用你刚刚创建的Notification.Name发布通知。它发送通知,但它不知道也不关心是否有人收到这个消息。

第二部分是观察这个通知,这就是TaskManager的工作。

打开TaskManager.swift并定义这个新属性:

var refreshNeededSub: AnyCancellable?

Timer一样,你将使用Combine为这个通知名称订阅一个NotificationCenter发布者。这个属性持有对订阅的引用,以便在TaskManager存在时保持活动,并在TaskManager被解除初始化时取消自己。

接下来,在init()的末尾添加这个:

// 1
refreshNeededSub = NotificationCenter.default
  // 2
  .publisher(for: .dataRefreshNeeded)
  // 3
  .sink { _ in
    // 4
    self.tasks = self.dataStore.readTasks()
  }

这里发生了什么?

  1. 使用默认的NotificationCenter
  2. 为你之前设置的Notification.Name创建一个发布者。
  3. 使用sink订阅发布者。
  4. 每当有通知到达时,从存储文件中刷新数据。

构建并运行应用程序。编辑一个任务,保存你的编辑并检查菜单。你的修改马上就会出现:

img

所以这就是了。你的应用程序现在功能齐全。你可以开始和完成任务。计时器会计算出你要走多长时间。菜单显示计时器和进度条。最后,你可以编辑你的任务,你有持久的数据存储。

只有件事是好的......

登录时启动

像这样的实用程序是人们希望一直运行的那种程序,这意味着你需要添加一种方法,使该程序在用户登录时启动。

你的应用程序必须not自动应用这个设置。你必须把它作为一个选项提供给用户,让他们选择启用。苹果的应用商店审查指南包含这个相关部分。

未经同意,它们(应用程序)不得自动启动或在启动或登录时自动运行其他代码。

设置沙盒应用程序在登录时启动的过程是一个复杂的过程,需要创建一个辅助应用程序,并以特定方式配置它和父应用程序。助手应用程序的唯一作用是启动主应用程序。

方便的是,有一个名为LaunchAtLogin的Swift包可以完成所有的艰巨工作。

添加一个Swift

你将使用Swift Package Manager在你的应用程序中包含这个包。如果你在iOS应用中使用过SwiftPM,那么这就是一个熟悉的过程。

首先在项目导航器的顶部选择项目,然后选择TIME-ATO项目,而不是目标。点击顶部的Package Dependencies,然后点击+按钮,添加一个新的依赖关系。

在搜索栏中输入这个URL

https://github.com/sindresorhus/LaunchAtLogin

这可以找到你要安装的软件包:

img

LaunchAtLogin包出现时,点击Add PackageXcode会下载这个包,然后要求确认。勾选LaunchAtLogin Library并再次点击Add Package

img

项目导航器现在包括一个Package Dependencies部分,其中列出了LaunchAtLogin库:

img

在你开始使用该软件包之前,还有一个配置步骤。

选择Time-ato target并点击Build Phases

接下来,点击左上方的+,选择New Run Script Phase

img

展开新添加的Run Script,并将脚本字段中的注释替换为:

"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh"

这是从软件包的Usage说明中直接复制的。你的新构建阶段现在看起来像这样:

img

这就是你在开始使用该库之前需要做的所有设置。如果你想知道这为你节省了多少工作,可以看看库的GitHub上的Before and after 描述。

使用新库

为了实现这个功能,你需要能够知道用户是否启用了登录时启动,这样你就可以在其菜单项中显示一个复选标记。而且,该菜单项必须能够切换该设置。

从导入库开始。打开AppDelegate.swift,在顶部的其他import语句中添加这一行:

import LaunchAtLogin

现在已经到位了,你可以使用它了。向下滚动到updateMenuItemTitles(taskIsRunning:)并在该方法的末尾添加这段代码:

launchOnLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off

这取决于用户是否启用了启动功能,显示或隐藏了复选标记。你已经为这个菜单项在故事板和AppDelegate之间建立了@IBOutlet的连接,所以你可以参考它,不需要再做任何设置。

最后,你可以添加代码来切换这个设置。你已经为这个菜单项设置了一个@IBAction,所以找到toggleLaunchOnLogin(_:)并插入这一行:

LaunchAtLogin.isEnabled.toggle()

是时候测试一下了。建立并运行该应用程序。打开菜单,选择Launch on Login,然后再次打开菜单,看到它现在已经被选中:

img

为了进行真正的测试,你需要注销你的用户账户并重新登录(或者你可以重新启动整个电脑)。等待一切重启,在你的菜单栏中就会出现这个应用程序:

img

故障排除

如果应用程序在登录时没有启动,这可能是由于在您的硬盘上有太多的旧版本的应用程序。删除Xcode的衍生数据很可能会解决这个问题。

打开Terminal并输入这个命令:

rm -rf ~/Library/Developer/Xcode/DerivedData

这也删除了LaunchAtLogin包的下载版本。在Xcode中,选择File ▸ Packages ▸ Resolve Package Versions来重新获取它:

img

Shift-Command-K清理你的构建文件夹并再次运行该应用程序。

在退出和再次登录之前,确认Launch on Login仍然被选中。如果你仍然有问题,在软件包ReadMeFAQ部分有更多建议。

使用该应用程序

你现在可能正在考虑在你的日常工作中使用这个应用程序。在本书的最后一节中,你将学习如何发布你的应用程序,但现在,你要把它放到你的Applications文件夹中,这样你就可以更方便地运行它。

为了测试的目的,该应用程序使用缩短的时间来完成任务和休息。你仍然要运行该应用程序的调试构建,所以你需要手动改变这些时间。

打开TaskTimes.swift。你不想删除所有的调试时间,因为你可能想在以后回来对应用程序进行改进,所以用这个来替换枚举:

enum TaskTimes {
  // #if DEBUG
  //  // in debug mode, shorten all the times to make testing faster
  //  static let taskTime: TimeInterval = 2 * 60
  //  static let shortBreakTime: TimeInterval = 1 * 60
  //  static let longBreakTime: TimeInterval = 3 * 60
  // #else
  static let taskTime: TimeInterval = 25 * 60
  static let shortBreakTime: TimeInterval = 5 * 60
  static let longBreakTime: TimeInterval = 30 * 60
  // #endif
}

这允许你在任何时候在应用程序上工作时换回调试模式。

接下来,你将在你的Applications文件夹中安装该应用程序,但你如何才能做到这一点?应用程序在哪里?当一个应用程序在Dock中运行时,你可以右键单击以在Finder中显示它,但你不能对这个应用程序这样做。

解决办法是询问应用程序本身。

打开AppDelegate.swift,在applicationDidFinishLaunching(_:)的末尾添加这行:

print(Bundle.main.bundlePath)

运行,然后退出应用程序并在Xcode的控制台中检查:

img

复制这个奇怪的路径,除了最后的TIME-ATO.APP。在Finder中,选择Go ▸ Go to Folder...并粘贴你复制的路径。按Return键,打开包含该应用程序的文件夹。现在你可以把它拖入你的Applications文件夹。

挑战

挑战:关于方框

如果你从菜单中选择About Time-ato,关于框就会打开,但它是在后台,所以你可能看不到它。在这个应用程序的其他部分,你已经看到了如何在显示警报或打开新窗口之前把应用程序带到前面。

打开关于框的方法是:

NSApp.orderFrontStandardAboutPanel(nil)

About Time-ato菜单项正在直接调用这个方法。你能让它调用一个新的@IBAction,把应用程序带到前面,然后使用这个方法吗?

自己试试吧,如果你需要任何提示,请查看本章的challenge项目。

关键点

  • macOS应用程序在沙盒中运行。这使得他们的数据和设置被保存在一个容器文件夹里。
  • 从文件中存储和检索数据时使用这个容器,而不是你的主要用户文件夹。
  • 如果你的应用程序需要,有办法打开沙盒,如果你不打算通过App Store发布,你也可以禁用它。
  • AppKit应用程序可以包含SwiftUI视图。
  • NotificationCenter提供了一种在整个应用中发布信息的机制。
  • 在登录时启动Mac应用程序可能很棘手,特别是对于沙盒应用程序。
  • Swift包管理器在Mac应用中的作用与在iOS应用中的作用完全相同。

接下来去哪里?

你已经走到了本节和这个应用程序的尽头。你可能会想出很多改进的办法,那么就去做吧。让它成为you想使用的应用程序。

在本节中,你涵盖了两个主要概念。构建一个AppKit应用程序和构建一个菜单栏应用程序。在这一过程中,你学到了更多关于Mac沙盒的知识,你发现了如何将SwiftUI集成到AppKit应用中,你还在Mac应用中使用了Swift包管理器。

在下一节中,你将启动一个新的应用程序。你将重新使用SwiftUI,但在一个完全不同类型的应用程序中。