欢迎回到 macOS 开发教程初学者系列 3 部分中的第 3 部分,也是最后一个部分!
在第 1 部分中,学习了如何安装 Xcode 以及创建简单的 app。在 第 2 部分 中,为更复杂的 app 创建了用户界面,但还不能正常工作,因为没有写任何代码。在这部分,会添加 Swift 代码,以使 app 正常工作!
开始
如果尚未完成第 2 部分或希望用干净的模板开始,可 下载项目文件,带有布局好的 UI,就和第 2 部分结尾的时候一样。打开此项目或你自己在第 2 部分里的项目,运行一下确定 UI 已全部就位。同样也把 Preferences 打开检查一下。
沙盒
在你深入代码之前,花一点时间学习沙盒(sandboxing)。如果你是一个 iOS 程序员,你已经熟悉这个概念——否则就请继续阅读。
沙盒 app 有自己的空间,可以使用单独的文件存储区域,无法访问其他 app 创建的文件,具有有限的访问权限。对于 iOS app,这是唯一的选择。对于 macOS app,这是可选的;但是,如果要通过 Mac App Store 分发 app,则必须将其沙盒化。一般情况下,都应将 app 沙盒化,因为这使 app 减少潜在问题。
要为 Egg Timer app 启用沙盒,请在 Project Navigator 中选择项目——顶部带有蓝色图标的那个。在 Targets 中选择 EggTimer(只列出了一个 target),然后单击顶部选项卡中的 Capabilities。单击开关以启用 App Sandbox。屏幕会展开,以显示现在 app 可以请求的各种权限。这个 app 什么都不需要,所以不要勾选它们。
组织文件
看看 Project Navigator。列出了所有文件,但毫无纪律。这个 app 不会有很多文件,但把类似的文件分组在一起是好的做法,可以更有效的导航,特别是对于较大的项目来说。
选择两个视图控制器文件,方法是单击一个,然后按住 Shift 键单击下一个。右键单击并从弹出菜单中选择 New Group from Selection。将新组命名为 View Controllers。
该项目马上会有一些模型文件,因此选择顶部 EggTimer 组,右键单击并选择 New Group。取名为 Model。
最后,选择 Info.plist 和 EggTimer.entitlements,并将它们放入名为 Supporting Files 的组。
拖动组和文件,直到 Project Navigator 看起来像这样:
MVC
这个 app 使用 MVC 模式:Model View Controller。
app 的主要模型将是一个名为 EggTimer
的类。这个类将具有定时器的开始时间、所请求的持续时间和已经过去的时间等属性。它还会有一个 Timer
对象,每秒触发、自我更新。EggTimer
对象还会有 start,stop,resume 和 reset 方法。
EggTimer
模型类保存数据并执行操作,但不了解如何显示它们。 Controller(在这种情况下是 ViewController
)了解 EggTimer
类(Model),并且有一个 View
可以用来显示数据。
为了与 ViewController
通信,EggTimer
使用委托协议。当某事发生变化时,EggTimer
向其 delegate
发送一条消息。ViewController
将自身分配为 EggTimer
的 delegate
,所以由它来接收消息,然后它可以在自己的 View 中显示新的数据。
编写 EggTimer
在 Project Navigator 里选择 Model 组,然后选择 File/New/File…,选择 macOS/Swift File 然后点击 Next。将文件命名为 EggTimer.swift,然后点击 Create 以保存它。
添加如下代码:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // default = 6 minutes
var elapsedTime: TimeInterval = 0
}
这样就设置了 EggTimer
类及其属性。 TimeInterval
实际上是 Double
,意思为秒数。
接下来要在类中添加两个计算属性,就在前面那些属性之后:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
这是用于快速确定 EggTimer
状态的方式。
将 delegate 协议的定义插入 EggTimer.swift 文件,但在 EggTimer
类的外面——我喜欢将协议定义放在文件的顶部,import 的后面。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
协议规定了一个契约,任何符合 EggTimerProtocol
的对象必须提供这两个函数。
现在你已经定义了一个协议,EggTimer
需要一个可选的 delegate 属性,该属性设置为符合此协议的任何对象。EggTimer
不知道或不关心 delegate 是什么类型的对象,因为它只要确定 delegate 有这两个函数就行了。
将此行添加到 EggTimer
类中的现有属性中:
var delegate: EggTimerProtocol?
启动 EggTimer
的 timer 对象将每秒触发一次函数调用。插入此代码,定义了将由定时器调用的函数。必须要有关键字 dynamic
,以便 Timer
能够找到它。
dynamic func timerAction() {
// 1
guard let startTime = startTime else {
return
}
// 2
elapsedTime = -startTime.timeIntervalSinceNow
// 3
let secondsRemaining = (duration - elapsedTime).rounded()
// 4
if secondsRemaining <= 0 {
resetTimer()
delegate?.timerHasFinished(self)
} else {
delegate?.timeRemainingOnTimer(self, timeRemaining: secondsRemaining)
}
}
会发生什么?
-
startTime
是一个Optional Date
——如果是nil
,timer 就无法运行,所以什么都不会发生。 - 重新计算
elapsedTime
属性。startTime
早于当前,因此timeIntervalSinceNow
会生成负数。用减号使得 elapsedTime 是正数。 - 计算 timer 的剩余秒数,四舍五入以给出整数秒。
- 如果 timer 已经完成,重置它并告诉 delegate 它已经完成。否则,告诉 delegate 剩余的秒数。由于
delegate
是可选属性,? 号用于执行可选链。如果 delegate 没有设置,这些方法将不会被调用,也就不会出现意外情况了。
添加 EggTimer 类所需的最后一点代码的时候,你会看到一个错误:timer 的 starting, stopping, resuming 和 resetting 方法。
// 1
func startTimer() {
startTime = Date()
elapsedTime = 0
timer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(timerAction),
userInfo: nil,
repeats: true)
timerAction()
}
// 2
func resumeTimer() {
startTime = Date(timeIntervalSinceNow: -elapsedTime)
timer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(timerAction),
userInfo: nil,
repeats: true)
timerAction()
}
// 3
func stopTimer() {
// really just pauses the timer
timer?.invalidate()
timer = nil
timerAction()
}
// 4
func resetTimer() {
// stop the timer & reset back to start
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
这些函数做了什么?
-
startTimer
使用Date()
将启动时间设置为现在、设置了重复的Timer
。 -
resumeTimer
是 timer 已暂停并正在重新启动时调用的内容。基于已过去的时间重新计算开始时间。 -
stopTimer
停止了重复的 timer。 -
resetTimer
停止了重复的 timer 并将属性恢复为默认值。
这些函数还全部调用了 timerAction
,以便屏幕可以立即刷新。
ViewController
现在 EggTimer
对象已经正常工作了,现在回到 ViewController.swift 让屏幕改变以反映这一点。
ViewController
已经有 @IBOutlet
属性了,现在给它一个 EggTimer
属性:
var eggTimer = EggTimer()
将下面这行驾到 viewDidLoad
中,替换掉注视行:
eggTimer.delegate = self
这将导致一个错误,因为 ViewController
不符合 EggTimerProtocol
。当符合协议时,为协议创建单独的扩展,会使代码更干净。在 ViewController
类定义下面添加这段代码:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
错误消失了,因为 ViewController
现在有 EggTimerProtocol
所需的两个函数。但是这两个函数都调用了还不存在的 updateDisplay
。
这是 ViewController
的另一个扩展,包含了用于显示的函数:
extension ViewController {
// MARK: - Display
func updateDisplay(for timeRemaining: TimeInterval) {
timeLeftField.stringValue = textToDisplay(for: timeRemaining)
eggImageView.image = imageToDisplay(for: timeRemaining)
}
private func textToDisplay(for timeRemaining: TimeInterval) -> String {
if timeRemaining == 0 {
return "Done!"
}
let minutesRemaining = floor(timeRemaining / 60)
let secondsRemaining = timeRemaining - (minutesRemaining * 60)
let secondsDisplay = String(format: "%02d", Int(secondsRemaining))
let timeRemainingDisplay = "\(Int(minutesRemaining)):\(secondsDisplay)"
return timeRemainingDisplay
}
private func imageToDisplay(for timeRemaining: TimeInterval) -> NSImage? {
let percentageComplete = 100 - (timeRemaining / 360 * 100)
if eggTimer.isStopped {
let stoppedImageName = (timeRemaining == 0) ? "100" : "stopped"
return NSImage(named: stoppedImageName)
}
let imageName: String
switch percentageComplete {
case 0 ..< 25:
imageName = "0"
case 25 ..< 50:
imageName = "25"
case 50 ..< 75:
imageName = "50"
case 75 ..< 100:
imageName = "75"
default:
imageName = "100"
}
return NSImage(named: imageName)
}
}
updateDisplay
使用私有函数获取剩余时间的文本和图像,并在 text field 和 image view 中显示它们。
textToDisplay
将剩余秒数转换为 M:SS 格式。 imageToDisplay
计算煮蛋程度的百分比,并选择匹配的图像。
所以 ViewController
有了一个 EggTimer
对象,它也有从 EggTimer
接收数据并显示结果的函数,但按钮还没有编码。在第 2 部分中,已经为按钮设置了 @IBActions
。
这里是这些 action 函数的代码,把它们替换掉:
@IBAction func startButtonClicked(_ sender: Any) {
if eggTimer.isPaused {
eggTimer.resumeTimer()
} else {
eggTimer.duration = 360
eggTimer.startTimer()
}
}
@IBAction func stopButtonClicked(_ sender: Any) {
eggTimer.stopTimer()
}
@IBAction func resetButtonClicked(_ sender: Any) {
eggTimer.resetTimer()
updateDisplay(for: 360)
}
这3个 action 调用之前添加的 EggTimer
方法。
现在构建并运行 app,然后单击.Start 按钮。
还少几个功能:Stop 和 Reset 按钮总是在禁用状态,以及只能煮一个 6 分钟的蛋。可以使用 Timer 菜单来控制 app; 尝试使用菜单和键盘快捷键来停止,启动和重置。
如果足够有耐心,你会看到煮的时候鸡蛋变了颜色,最后在煮好时显示了 “DONE!”。
根据 timer 状态,按钮应该启用或禁用,并且 Timer 菜单项应该与之匹配。
将这个函数添加到 ViewController,放在用与显示的 extension 里面:
func configureButtonsAndMenus() {
let enableStart: Bool
let enableStop: Bool
let enableReset: Bool
if eggTimer.isStopped {
enableStart = true
enableStop = false
enableReset = false
} else if eggTimer.isPaused {
enableStart = true
enableStop = false
enableReset = true
} else {
enableStart = false
enableStop = true
enableReset = false
}
startButton.isEnabled = enableStart
stopButton.isEnabled = enableStop
resetButton.isEnabled = enableReset
if let appDel = NSApplication.shared().delegate as? AppDelegate {
appDel.enableMenus(start: enableStart, stop: enableStop, reset: enableReset)
}
}
此函数使用 EggTimer
状态(还记得添加到 EggTimer
的那几个计算变量吗)来确定应启用哪些按钮。
在第 2 部分中,你把 Timer 菜单项设置为 AppDelegate
的属性,因此AppDelegate
是配置它们的地方。
切换到 AppDelegate.swift 添加如下函数:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
为了在首次启动 app 时正确配置菜单,请将此行添加到 applicationDidFinishLaunching
方法中:
enableMenus(start: true, stop: false, reset: false)
每当按钮或菜单项动作改变 EggTimer
的状态时,就需要改变按钮和菜单。切换回 ViewController.swift 并将此行添加到 3 个按钮 action 函数中每一个的末尾:
configureButtonsAndMenus()
再次构建并运行 app,可以看到按钮按预期启用和禁用。检查一下菜单项;它们应该会反映按钮的状态。
偏好设置
这个 app 还有一个大问题——如果你不想把鸡蛋煮 6 分钟怎么办?
在第 2 部分中,我们设计了 Preferences 窗口以允许选择不同的时间。此窗口由 PrefsViewController
控制,但它需要一个模型对象来处理数据存储以及检索。
将使用 UserDefaults
存储 Preferences,UserDefaults
是在 app 容器中用键值对存储小数据到 Preferences 文件夹中的方式。
右击 Project Navigator 中的 Model 组,然后选择 New File… 选择 macOS/Swift File ,然后单击 Next。将文件命名为 Preferences.swift ,然后单击 Create。将此代码添加到 Preferences.swift 文件:
struct Preferences {
// 1
var selectedTime: TimeInterval {
get {
// 2
let savedTime = UserDefaults.standard.double(forKey: "selectedTime")
if savedTime > 0 {
return savedTime
}
// 3
return 360
}
set {
// 4
UserDefaults.standard.set(newValue, forKey: "selectedTime")
}
}
}
这段代码可以做什么?
- 叫做
selectedTime
的计算变量定义为TimeInterval
。 - 请求变量的值时,
UserDefaults
单例取出分配给键 “selectedTime” 的Double
值。如果值未定义,UserDefaults
将返回零,但如果值大于 0,则将其作为selectedTime
的值返回。 - 如果
selectedTime
没有被定义,使用默认值 360(6 分钟)。 -
selectedTime
被改变的时候,将新值写入UserDefaults
的键 “selectedTime”。
因此,通过使用带有 getter 和 setter 的计算变量,UserDefaults
的数据存储将被自动处理。
现在切换到 PrefsViewController.swift,第一件事是更新显示以反映现有偏好设置或默认值。
首先,在 outlets 下面添加此属性:
var prefs = Preferences()
在这里,你创建了一个 Preferences
实例,以便访问 selectedTime
计算变量。
然后,添加这些方法:
func showExistingPrefs() {
// 1
let selectedTimeInMinutes = Int(prefs.selectedTime) / 60
// 2
presetsPopup.selectItem(withTitle: "Custom")
customSlider.isEnabled = true
// 3
for item in presetsPopup.itemArray {
if item.tag == selectedTimeInMinutes {
presetsPopup.select(item)
customSlider.isEnabled = false
break
}
}
// 4
customSlider.integerValue = selectedTimeInMinutes
showSliderValueAsText()
}
// 5
func showSliderValueAsText() {
let newTimerDuration = customSlider.integerValue
let minutesDescription = (newTimerDuration == 1) ? "minute" : "minutes"
customTextField.stringValue = "\(newTimerDuration) \(minutesDescription)"
}
看上去有很多代码,一步步看一遍:
- 请求 prefs 对象的
selectedTime
,并将其从秒数转换为整数分钟。 - 如果找不到匹配的预设值,请将默认值设置为 “Custom”。
- 遍历循环
presetsPopup
中的菜单项检查他们的 tag。还记得在第 2 部分中如何将 tag 设置为每个选项的分钟数吗?如果找到匹配,启用该项目并退出循环。 - 设置滑块的值并调用
showSliderValueAsText
。 - showSliderValueAsText 为数字添加 “minute” 或 “minutes”,并在 text field中显示。
现在,把这个添加到 viewDidLoad
中:
showExistingPrefs()
当视图加载后,调用显示偏好设置的方法。记住,使用 MVC 模式,Preferences
模型对象不知道如何或何时被显示——这由 PrefsViewController
管理。
所以现在有显示设置的时间的能力了,但改变弹出窗口中的时间并不做任何事情。我们需要一个保存新数据的方法,并告知有兴趣的对象数据已更改。
在 EggTimer
对象中,使用.delegate 模式传递需要的数据。这一次(只是为了有点区别),你要在数据变化时广播一个 Notification
。可以选择任何对象来接收此通知,并在收到通知时进行操作。
把下面的方法添加到 PrefsViewController
中:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
它会从自定义滑块获取数据(稍后以内你会看到任何更改都反映在那里)。设置 selectedTime
属性后将自动将新数据保存到 UserDefaults
。然后,名为 “PrefsChanged” 的通知将发布到 NotificationCenter
。
稍后,你会看到如何将 ViewController
设置为监听此通知并对其作出反应。
编写 PrefsViewController
的最后一步是设置在第2部分中添加的 @IBActions
的代码:
// 1
@IBAction func popupValueChanged(_ sender: NSPopUpButton) {
if sender.selectedItem?.title == "Custom" {
customSlider.isEnabled = true
return
}
let newTimerDuration = sender.selectedTag()
customSlider.integerValue = newTimerDuration
showSliderValueAsText()
customSlider.isEnabled = false
}
// 2
@IBAction func sliderValueChanged(_ sender: NSSlider) {
showSliderValueAsText()
}
// 3
@IBAction func cancelButtonClicked(_ sender: Any) {
view.window?.close()
}
// 4
@IBAction func okButtonClicked(_ sender: Any) {
saveNewPrefs()
view.window?.close()
}
- 从弹出窗口中选择一个新项目时,检查它是否是自定义菜单项。如果是,启用滑块并退出。如果没有,使用 tag 获取分钟数,使用它们来设置滑块值和文本,并禁用滑块。
- 滑块变动时更新文字。
- 点击 Cancel 会关闭窗口,且不保存改变。
- 点击 OK 会先调用
saveNewPrefs
然后关闭窗口。
现在构建并运行 app,然后转到 Preferences。尝试在弹出窗口中选择不同的选项——注意滑块和文本如何更改以匹配。选择 Custom 并选择自己的时间。单击确定,然后返回 Preferences 并确认仍然显示你选择的时间。
现在尝试退出 app 并重新启动。返回 Preferences,可以看到它已储存你的设定。
实现已选择的偏好设置
Preferences 窗口看起来不错——按预期保存和还原了所选时间。但是当你回到主窗口,仍然显示一个6分钟的蛋! :[
因此,需要编辑 ViewController.swift 以使用存储的值进行计时,并监听更改通知,以便可以更改或重置计时器。
将此扩展添加到 ViewController.swift,添加在任何现有类定义或扩展之外——它将所有 preferences 相关功能分组到一个单独的包中以使代码更加整洁:
extension ViewController {
// MARK: - Preferences
func setupPrefs() {
updateDisplay(for: prefs.selectedTime)
let notificationName = Notification.Name(rawValue: "PrefsChanged")
NotificationCenter.default.addObserver(forName: notificationName,
object: nil, queue: nil) {
(notification) in
self.updateFromPrefs()
}
}
func updateFromPrefs() {
self.eggTimer.duration = self.prefs.selectedTime
self.resetButtonClicked(self)
}
}
这会导致错误,因为 ViewController
没有叫做 prefs
的对象。在 ViewController
类的主定义中,添加这行来定义 eggTimer
属性:
var prefs = Preferences()
现在 PrefsViewController
有一个 prefs
对象,ViewController
也有一个——这是一个错误吗?不,有几个原因。
-
Preferences
是一个结构体,因此它是基于值的,不是基于引用的。每个View Controller
都有自己的副本。 -
Preferences
结构体通过单例与UserDefaults
交互,因此两个副本都使用相同的UserDefaults
并获取相同的数据。
在 ViewController viewDidLoad
函数的末尾,添加此调用用语设置Preferences
连接:
setupPrefs()
还最后一组编辑。之前是使用硬编码值进行计时——360 秒或 6 分钟。现在ViewController
有权访问 Preferences
,要把这些硬编码的 360 秒的更改为 prefs.selectedTime
。
在 ViewController.swift 里搜索 360 然后把给一个都改成 prefs.selectedTime
——应该能找到 3 个。
构建并运行 app。如果你之前更改了偏好的煮鸡蛋时间,剩余时间将显示你选择的那个时间。打开 Preferences,选择另一个时间,然后单击确定——你的新时间将立即显示出来,因为 ViewController
接收了通知。
启动计时器,然后打开 Preferences。倒计时在后面那个窗口继续。更改鸡蛋计时,然后单击确定。定时器应用了新的时间,但停止并复位了计数器。其实这样也可以,但如果 app 警告一下就会更好了。如何添加一个对话框,询问这是否真的是你想做的吗?
在ViewController 处理 Preferences 的 extension 中,添加此函数:
func checkForResetAfterPrefsChange() {
if eggTimer.isStopped || eggTimer.isPaused {
// 1
updateFromPrefs()
} else {
// 2
let alert = NSAlert()
alert.messageText = "Reset timer with the new settings?"
alert.informativeText = "This will stop your current timer!"
alert.alertStyle = .warning
// 3
alert.addButton(withTitle: "Reset")
alert.addButton(withTitle: "Cancel")
// 4
let response = alert.runModal()
if response == NSAlertFirstButtonReturn {
self.updateFromPrefs()
}
}
}
上面发生了什么?
- 如果 timer 停止或暂停了,不用询问直接弄。
- 创建一个
NSAlert
,它是显示对话框的类。配置其文本和样式。 - 添加2个按钮:Reset 和 Cancel。它们将按照从右到左的顺序显示,第一个将是默认选项。
- 将 alert 显示为模态对话框,然后等待答复。检查用户是否点击第一个按钮(复位),如果是这样,重置定时器。
在 setupPrefs
方法中,将 self.updateFromPrefs()
行更改为:
self.checkForResetAfterPrefsChange()
构建并运行 app,启动计时器,打开 Preferences,更改时间,然后单击确定。你会看到对话框,询问是否重置。
声音
这个 app 目前为止唯一尚未涉及的就是声音了。煮蛋器如果不能叮叮叮叮叮叮就不是煮蛋器了!
在第 2 部分中,已下载了 app 的资源文件夹。大多数是图像,已经用上了,但也有一个声音文件:ding.mp3。如果你需要再次下载,这里是一个只有 声音文件 的链接。
将 ding.mp3 文件拖动到 Project Navigator 中 EggTimer 组内——就在 Main.storyboard 下面,这似乎是一个合乎逻辑的地方。确保勾选 Copy items if needed ,并选中了 EggTimer target。然后单击完成。
要播放声音,需要使用 AVFoundation
库。当 EggTimer
告诉它的 delegate 计时器已经完成时,ViewController
将播放声音,所以打开 ViewController.swift。你会看到 Cocoa
库在顶部被 import 了。
就在那行下面,添加这行:
import AVFoundation
ViewController 需要一个播放器来播放声音文件,所以将它添加到属性重:
var soundPlayer: AVAudioPlayer?
使 ViewController
有单独的扩展来保存声音相关的功能好像是个好主意,所以添加如下代码添加到 ViewController.swift
,在任何现有的定义或 extension 之外:
extension ViewController {
// MARK: - Sound
func prepareSound() {
guard let audioFileUrl = Bundle.main.url(forResource: "ding",
withExtension: "mp3") else {
return
}
do {
soundPlayer = try AVAudioPlayer(contentsOf: audioFileUrl)
soundPlayer?.prepareToPlay()
} catch {
print("Sound player not available: \(error)")
}
}
func playSound() {
soundPlayer?.play()
}
}
prepareSound
做了这里的大部分工作——它首先检查 ding.mp3 文件是否在 app bundle 中。如果文件存在,它尝试用声音文件 URL 初始化 AVAudioPlayer
并准备播放。会预先缓冲声音文件,以便在需要时立即播放。
playSound
只是发送一个播放消息给可能存在的播放器,但如果prepareSound
失败了,soundPlayer
将是 nil 所以不会有任何事发生。
声音只需要在点击开始按钮后准备就绪就可以了,因此在 startButtonClicked
末尾插入此行:
prepareSound()
并在 eggTimerProtocol 扩展中的 timerHasFinished 中添加:
playSound()
构建和运行 app,为你的蛋选择一个短一点的时间,启动计时器。当定时器结束时,你听到叮了吗?
下一步?
你可以在这里下载 完整项目 。
本 macOS 系列开发教程为你介绍了基本的知识以开始开发 macOS app,但还有很多要学习!
苹果有一些特别棒的 文档 ,涵盖了 macOS 开发的所有方面。
我还强烈建议看看其他在 raywenderlich.com 的 macOS 教程。
如果您有任何问题或意见,请在下面评论!
网友评论
2017-03-19 15:54:32.146126 EggTimer[15781:391492] [User Defaults] Failed to read values in CFPrefsPlistSource<0x6100000e3c80> (Domain: com.apple.PowerManagement, User: kCFPreferencesAnyUser, ByHost: Yes, Container: (null)): accessing preferences outside an application's container requires user-preference-read or file-read-data sandbox access, detaching from cfprefsd