本文翻譯自 raywenderlich.com 的 macOS 開發(fā)經典入門教程 ,已咨詢對方網站,可至多翻譯 10 篇文章。
希望各位有英語閱讀能力的話,還是 先打賞 然后去閱讀英文原吧,畢竟無論是 Xcode,抑或是官方的文檔,還是各種最前沿的資訊都只有英文版本。
綜上,此翻譯版本僅供參考,謝絕轉載。
相關鏈接
零基礎 macOS 應用開發(fā)(一): 原文 / 譯文
零基礎 macOS 應用開發(fā)(二): 原文 / 譯文
零基礎 macOS 應用開發(fā)(三): 原文 / 譯文(本文)
歡迎回到我們的零基礎 macOS 應用開發(fā)教程的最后一部分(共三部分)!
在第一部分中,你已經學會了如何安裝 Xcode 和如何創(chuàng)建一個示例 app;在第二部分中你為一個更加復雜的 app 創(chuàng)建了 UI,但因為你還沒有編寫任何代碼,所以它還不能工作。在這個部分中,你將會編寫所有 Swift 代碼并讓你的 app 真正活起來!
開始
如果你還沒有完成第二部分,或你希望從一個更加純凈的情況繼續(xù)學習,你可以下載第二部分中已經完成了 UI 布局的工程文件。打開你下載的或你跟著第二部分完成的工程文件,并運行一下它,確認一下是否所有的 UI 都能正確顯示,打開偏好設置窗口看看它是否能正常顯示。

沙盒機制
在你開始編寫代碼之前,請花一些時間來了解一下 macOS 的沙盒機制。如果你是一個 iOS 開發(fā)者,你已經了解了這個概念,如果你不曾了解過,繼續(xù)往下閱讀。
一個沙盒化了的 app 擁有自己獨立的存儲空間,沙盒會禁止你的 app 訪問另一個 app 創(chuàng)建的文件以及其他的許可和限制。對于 iOS app,使用沙盒是必須的,而對于 macOS app,這只是一個可選項;但如果你希望通過 Mac App Store 進行分發(fā)和銷售,你的 app 必須沙盒化,由于沙盒帶來的諸多限制,你的 app 可能會出現(xiàn)一些問題。
要為你的 app 啟用沙盒,在 Project Navigator(項目導航器)中選擇項目文件,也就是文件列表里最頂上的藍色圖標。在 Targets 列表中選擇 EggTimer(其實 Targets 列表里也只有一個項目可以選擇),然后在上方的標簽中點擊 Capabilities(功能)標簽,點擊 App Sandbox(應用沙盒)那一欄的開關,這個視圖將會展開并顯示你的 app 可以申請的許多權限。這個例子中的 app 不需要任何特殊的權限,因此它們都不需要打開。

管理你的文件
看一眼你的 Project Navigator(項目導航器),所有的文件都堆在一起,缺乏組織,這個 app 不會有很多文件,但把文件整理的井井有條始終都會是個好習慣,也能幫助我們更快速地定位到你需要的文件,這一點對于大型項目尤其有用。

按住 Shift 的同時分別點擊兩個 View Controller 文件,把他們同時選中,右鍵點擊并選擇 New Group from selection(用所選項目創(chuàng)建新的分組),給新建的分組起名為 View Controllers。
這個項目將會包含一些 Model 文件,所以右鍵點擊 EggTimer 分組,選擇 New Group(新建分組),把這個分組命名為 Model。
最后,選中 Info.plist 和 EggTimer.entitlements,把它們扔掉一個叫 Supporting Files 的文件夾里。
拖動分組和文件調整他們的順序,直到你的項目看起來像這樣:

MVC
這個 app 將會應用 MVC 模式:Model View Controller(模型 - 視圖 - 控制器)。
譯者注:請參見 MVC 設計模式的維基百科詞條,以及這篇簡書文章。
以及下文會經常出現(xiàn)的名詞,下文就不再翻譯啦~
Model:模型
View:視圖
Controller:控制器
Delegate and Protocol:代理與協(xié)議
我們要給 app 創(chuàng)建的第一個 Model 對象名叫 EggTimer。這個類將會擁有一些關于計時器的開始時間、倒計時的時長和以及過去的時間的屬性。還有一個叫做 Timer 的對象,每過一秒它都會被激活,并更新自己的狀態(tài),并用自己的方法來開始、暫停、恢復或把 EggTimer 歸零。
EggTimer Model 類還會保存數(shù)據(jù)并執(zhí)行動作,但它不能用來顯示數(shù)據(jù)。Controller(在這個項目中就是 ViewController)則能與 EggTimer(也就是 Model)通信,它擁有一個 View 并用它來顯示數(shù)據(jù)。
為了能和 ViewController 通信,EggTimer 使用一個代理協(xié)議(Delegate Protocol),每當某些數(shù)據(jù)發(fā)生改變時,EggTimer 向它的 delegate 發(fā)送一條消息,ViewController 則讓自己去擔任 EggTimer 的這個所謂的 delegate,所以它能接收到這條消息,并把新的數(shù)據(jù)顯示在界面上。
編寫 EggTimer 類
在項目導航器中選中 Model 分組,并點擊 Xcode 菜單欄上的 File → New → File…,選擇 macOS → Swift File,并點擊 Next,給這個文件起名為 EggTimer.swift 并點擊 Create 來創(chuàng)建它。
在這個文件中加入以下代碼:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // 默認的計時時間是 6 分鐘
var elapsedTime: TimeInterval = 0
}
這樣 EggTimer 類和它的屬性們就設置好了。TimeInterval 其實就是 Double 類型,但一般我們在表示秒數(shù)時都會使用它而不是 Double。
第二件事是在類中添加兩個計算屬性(Computed Properties),這兩個屬性是用來決定 EggTimer 屬性的捷徑。將以下代碼寫在剛剛添加的屬性之后:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
在 EggTimer.swift 文件 EggTimer 類以外的地方添加代理協(xié)議的定義 —— 我更喜歡把代理協(xié)議寫在文件頂部 import 部分的后邊。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
你可以理解為:這個協(xié)議制定了一份合同,任何宣布遵守 EggTimerProtocol 協(xié)議(也就是簽訂了這份合同)的對象都需要實現(xiàn)這兩個方法。
現(xiàn)在你定義了一個協(xié)議,EggTimer 可以通過定義一個 delegate(代理)屬性來履行這份協(xié)議,這個屬性的類型可以是任何類型(Any)。EggTimer 并不知道也不關心代理的類型是什么,因為很明顯既然這個代理源自 EggTimerProtocol 協(xié)議,它擁有這兩個方法。
將這些代碼屬性添加到 EggTimer 類:
var delegate: EggTimerProtocol?
讓 EggTimer 的 timer 對象開始運行會導致一個方法每秒鐘被調用一次,繼續(xù)添加以下代碼來定義這個方法,dynamic 關鍵字是讓 Timer 能發(fā)現(xiàn)它的關鍵。
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是個可選的Date,當它是nil時,timer 將無法運行,所以這時什么都不會發(fā)生; - 重新計算
elapsedTime屬性,startTime比當前的時間還要早,所以 timeIntervalSinceNow 會產生一個負值,這個負值會使得elapsedTime成為一個正值; - 計算 timer 的剩余時間,并進行取整;
- 如果 timer 已經結束,就把它重設,并告知
delegate計時結束了;否則,告訴delegate計時器還剩多少秒。另外,由于delegate是一個可選值,所以需要用?來進行解包,也就是說,如果delegate還沒有被賦值,除了那些方法不會被調用,沒有別的壞事會發(fā)生。
你會看到 Xcode 提示我們出現(xiàn)了一些錯誤,不過當我們完成了 EggTimer 類的代碼之后,它們就會消失了,這是因為我們還沒有添加用于開始計時、暫停計時、恢復計時和重啟計時器的方法。
// 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() {
// 停止計時器 & 重設所有屬性
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
這些代碼是做什么的?
- 通過調用
Date()方法startTimer設置開始時間為當前時間,然后它會設置一個一直重復運行的Timer; -
resumeTimer是計時器已經暫停并需要繼續(xù)時會被調用的方法,它還會根據(jù)已經過去的時間重新設置開始時間; -
stopTimer會停止重復運行的 timer; -
resetTimer會停止 timer,并把相關屬性恢復原始設置。
以上的這些方法都會調用 timerAction,所以一旦它們被調用,界面上顯示的內容都會被更新。
ViewController
現(xiàn)在 EggTimer 對象已經業(yè)已正常運轉了,我們該回到 ViewController.swift 中讓數(shù)據(jù)的變化能及時反映到界面上了。
ViewController 已經擁有了 @IBOutlet 屬性,但現(xiàn)在你需要讓它擁有一個類型為 EggTimer 的屬性:
var eggTimer = EggTimer()
將 viewDidLoad 方法中的注釋行替換成這一行:
eggTimer.delegate = self
寫完上面的代碼以后會出現(xiàn)一個錯誤,因為 ViewController 還沒有遵從 EggTimerProtocol 協(xié)議。當我們要讓一個類遵從某個協(xié)議時,如果我們單獨創(chuàng)建一個 Extension(擴展)來盛放協(xié)議需要的方法,你的代碼將會看起來整潔許多。在 ViewController 類以外的地方輸入以下代碼:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
因此我們還需要為 ViewController 添加另一個 Extension,用來盛放關于屏幕顯示的方法。
extension ViewController {
// MARK: - 顯示
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 使用一個 Private 方法來根據(jù)剩余的時間來獲取文本和圖像,并將它們顯示在界面上的 Text Field 和 Image View 中。
textToDisplay 把剩余的時間格式化成「分:秒」的格式。imageToDisplay 計算出雞蛋有多熟的百分比,然后選擇合適的圖片來顯示在界面上。
所以 ViewController 用一個 EggTimer 對象的方法來接收 EggTimer 傳來的數(shù)據(jù)并顯示在屏幕上,但是界面上的按鈕還沒有任何實質性的代碼。在第二部分中,你已經為按鈕設置了 @IBAction。
這里是這些 IBAction 的方法,你可以用它們來替代之前的 IBAction。
@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)
}
這里的三個 IBAction 將會調用你之前添加的 EggTimer 方法。
現(xiàn)在編譯并運行你的 app,并點擊 Start 按鈕。你還可以用 Timer 菜單來控制這個 app,試著去用鍵盤快捷鍵來操作你的 app。
現(xiàn)在我們還需要完善一些功能:Stop 和 Reset 按鈕始終是被禁用的,而且你只可以定 6 分鐘的時。
如果你有足夠的耐心,你將會看到雞蛋的顏色隨著時間漸漸改變,并在完成時顯示一個「DONE!」。

按鈕和菜單
界面上的按鈕以及菜單里的菜單項應該隨著 timer 的狀態(tài)自動啟用或禁用。
把這個方法添加到 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 的狀態(tài)(還記得你添加到 EggTimer 里的計算屬性嗎)來計算出哪個按鈕應該啟用。
在第二部分中,你創(chuàng)立了一個 Timer menu item 作為 AppDelegate 的屬性,所以我們應該在 AppDelegate 中來編輯這些代碼。
切換到 AppDelegate.swift,在其中添加這個方法:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
為了讓你的你的 app 能在初次啟動時自動配置按鈕的啟用狀態(tài),在 applicationDidFinishLaunching 方法中添加這些代碼:
enableMenus(start: true, stop: false, reset: false)
每當用戶按下了任何一個按鈕或菜單項的時候,EggTimer 的狀態(tài)會發(fā)生改變,按鈕或菜單項的狀態(tài)也需要隨之更新。返回到 ViewController.swift 中并把這一行添加到三個按鈕的 IBAction 方法中:
configureButtonsAndMenus()
再次編譯并運行你的 app,你可以看到按鈕們如預期地啟用和禁用了。點擊菜單里的菜單項試試,它們應該擁有和按鈕一樣的功能。

偏好設置窗口
這個 app 還有一個很重要的問題:如果你希望煮雞蛋的時間不是 6 分鐘呢?
在第二部分中,你已經設計好了一個偏好設置窗口來允許用戶來選擇需要的倒計時時間,這個窗口是由 PrefsViewController 控制的,但它還需要一個 Model 對象來處理和查詢數(shù)據(jù)。
用戶的設置可以通過一個叫 UserDefaults 的東西來存儲,它會在你 app 的沙盒容器中的 Preferences 文件夾中用鍵值對來存儲零碎的小數(shù)據(jù)。
在 Project Navigator(項目導航器) 中,右鍵點擊 Model 分組,并選擇 Xcode 菜單上的 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;但如果存在這個值,且它大于 0,就將這個值返回,并設置為selectedTime; - 如果
selectedTime還沒有被定義過,就使用默認值 360(6 分鐘); - 只要
selectedTime的值發(fā)生了改變,把新的值用鍵「selectedTime」存入UserDefaults。
通過使用 getter 和 setter,UserDefaults 的數(shù)據(jù)存儲將能夠自動進行。
現(xiàn)在切換回 PrefsViewController.swift,我們需要把用戶修改的設置內容在界面上顯示出來。
第一步,在 IBOutlet 之下添加這些代碼:
var prefs = Preferences()
這一步中你創(chuàng)建了一個 Preferences 的實例,所以你現(xiàn)在可以自由訪問 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屬性,并把它轉化成整數(shù)的分鐘數(shù); - 把默認的計時時間設置為「Custom」,以防止沒有找到人寰預設的數(shù)據(jù);
- 遍歷
presetsPopup里的菜單項并檢查他們的 tag,還記得在第二部分中你把每個項目的 tag 都設置成了各自選項的分鐘數(shù)了嗎?如果找到了用戶選擇的菜單項,就把這個菜單項啟用,并跳出這個循環(huán); - 設置滑動條的數(shù)值,并調用
showSliderValueAsText方法; -
showSliderValueAsText把數(shù)字加上「minute」或「minutes」并將它顯示在界面上的 Text Field 中。
現(xiàn)在,把這行代碼添加到 viewDidLoad 中:
showExistingPrefs()
在 View 加載的時候,會調用這個方法,把用戶的設置加載到界面上,在 MVC 模式中,Preferences Model 完全不知道它佇立的數(shù)據(jù)會怎樣被顯示出來 —— 界面顯示是 PrefsViewController 的事兒。
所以,盡管現(xiàn)在你的 app 已經可以顯示用戶設置的時間了,然而偏好設置里的下拉框還是不能工作,你需要為它編寫一個方法來讓它能存儲新的的設置,并告訴所有相關對象數(shù)據(jù)發(fā)生了改變。
在 EggTimer 對象中,你使用了 delegate 模式來把數(shù)據(jù)傳遞到需要它的地方,這一次,你需要通過發(fā)送一個 Notification(通知)來告訴大家數(shù)據(jù)改變了(其實用 delegate 還是可以的,這里只是為了演示 Notification 的用法)。任何對象在表明自己對這個通知感興趣之后,都可以接收到這個通知,并在接收時采取行動。
在 PrefsViewController 中添加以下方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
這個方法將會獲取 customSlider 滑動條的數(shù)值,并轉化成分鐘數(shù),賦值予 selectedTime,因為我們之前編寫的 setter,它會自動使用 UserDefaults 來存儲新的數(shù)據(jù)。然后 NotificationCenter(通知中心)會將一個名叫「PrefsChanged」通知發(fā)送出去。
接下來,我們來讓 ViewController 能夠接收到這個 Notification,并采取行動:
在 PrefsViewController 中要編寫的最后一部分代碼是為第二部分中你添加的 @IBAction 們添加真正的代碼:
// 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()
}
- 當用戶在下拉框中選擇了一個新的菜單項,這段代碼會檢測這個項是不是 Custom:
- 如果是的,就啟用滑動條,并直接終止這個方法;
- 如果不是,就通過這個項的 tag 來獲取用戶選擇的計時時間;
- 每當滑動條的數(shù)據(jù)更新時,更新界面上的文本;
- 點擊 Cancel 按鈕會把窗口關閉,且不會存儲數(shù)據(jù);
- 點擊 OK 按鈕會先調用
saveNewPrefs,然后關閉這個窗口。
編譯并運行你的 app,前往 Preferences,試著在下拉框中選擇不同的選項,觀察一下滑動條和文本有沒有根據(jù)你的選擇而正確顯示。選擇 Custom 選項,然后自己選擇一個時間,點擊 OK,然后再次前往 Preferences,看看你剛剛選擇的時間是不是還能正常顯示。
現(xiàn)在試著退出你的 app 并重新打開它,返回 Preferences,看看你的 app 是否保存了你的設置。

讓用戶的設置生效
現(xiàn)在偏好設置窗口看起來還不錯了 —— 它可以存儲并讀取用戶的設置,但當你回到主窗口,你看到的時間會還是 6 分鐘! ??
所以你需要編輯 ViewController.swift,讓它能使用存儲了的數(shù)據(jù),并偵聽關于數(shù)據(jù)變化了的通知,從而及時更新或重設 Timer。
把這個 Extension 添加到 ViewController.swift 中類定義以外的部分 —— 這樣一來我們的代碼會被分成若干個承擔不同職能的部分,看起來會更整潔。
extension ViewController {
// MARK: - 設置
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()
現(xiàn)在 PrefsViewController 和 ViewController 內部都有了一個 prefs 屬性 —— 這是個問題嗎?不!原因如下:
-
Preferences是一個 struct(結構體),所以它是一個數(shù)據(jù)型的對象而非一個關系型的對象。每一個 View Controller 都可以擁有一份它的副本; -
Preferences結構體是使用了UserDefaults的單例,所以這倆副本其實是在調用同一個UserDefaults,因此拿到的數(shù)據(jù)也是完全一樣的。
在 ViewController 最后的 viewDidLoad 方法中,添加這一行代碼,它會設置好自己和 Preferences 的連接:
setupPrefs()
現(xiàn)在還有最后的一系列步驟需要做。之前我們把默認的時間,也就是 360 秒,直接寫進了代碼里(也就是硬編碼,hard-coded),現(xiàn)在因為 ViewController 已經可以訪問 Preferences 了,你需要修改一下這種寫法。
在 ViewController.swift 中找到「360」(你應該能找到 3 個 360),并把它們修改成 prefs.selectedTime。
編譯并運行你的 app,如果你之前修改過設置里的計時時間,你選擇的時間現(xiàn)在應該能正常顯示在界面上了。前往 Preferences,選擇另一時間,點擊 OK —— 因為 ViewController 接收到了通知,你新選擇的時間應該馬上就能顯示出來了。

啟動計時器,然后前往 Preferences,在主窗口中,倒計時還在繼續(xù),修改一個時間然后點擊 OK,計時器應用了新的時間,但是也停止并重設了倒計時。我覺得這沒什么問題,但是如果能添加一個提示,詢問用戶是否真的希望停止計時,這樣會不會更好呢?
在 ViewController 中負責處理設置的 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()
}
}
}
所以這些代碼是干啥的?
- 如果計時器已經停止或暫停了,不做任何操作直接修改時間;
- 創(chuàng)建一個
NSAlert,它是一個用來顯示一個對話框的類,并設置它的文字和樣子; - 添加兩個按鈕:Reset 和 Cancel,它們將會根據(jù)你添加的順序從右往左顯示在對話框中,且右邊的將會是默認選項;
- 把警告以一個模態(tài)的窗口顯示出來,并等待用戶的選擇,如果用戶點擊了第一個按鈕(Reset),就重設計時器。
在 setupPrefs 方法中,把 self.updateFromPrefs() 這一行改成:
self.checkForResetAfterPrefsChange()
編譯并運行你的 app,開始計時,前往 Preferences,修改一下時間,然后點擊 OK,你將會看見一個對話框詢問你是否要重設時間。

音效
現(xiàn)在這個 app 中唯一未完成的功能就是音效了。如果沒有「?!沟囊宦暤脑?,煮蛋計時器還能叫做煮蛋計時器嗎?
在第二部分中,你已經下載了一個包含了所有資產的文件夾,其中的內容絕大多數(shù)都是圖片,你也已經用過它們了,但是其實這里面還有一個音效文件:ding.mp3。如果你找不到這個文件了,你可以單獨下載這個音效文件。
把 ding.mp3 拖動到 Project Navigator(項目導航器)中的 EggTimer 分組下方 —— 看起來就放在 Main.storyboard 下邊是一個不錯的想法。勾選 Copy items if needed(如果需要的話把文件拷貝到項目中),在 Add to targets(添加到目標中) 中勾選 EggTimer,然后點擊 Finish。

你需要一個叫 AVFoundation 的庫來播放聲音。當代理告訴 ViewController 計時器結束了的時候,ViewController 就會負責播放這個音效,所以我們切換到 ViewController.swift 中,在最頂部你會看到這個文件引用了 Cocoa 庫(import Cocoa)。
在那一行引用的下方,添加:
import AVFoundation
ViewController 需用一個 AVAudioPlayer 來播放聲音,所以我們?yōu)樗砑右粋€屬性:
var soundPlayer: AVAudioPlayer?
我們應該為 ViewController 新建一個單獨的 Extension 來處理和聲音相關的方法,所以在 ViewController.swift 類定義以外的地方添加:
extension ViewController {
// MARK: - 聲音
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 方法會負責處理絕大多數(shù)的事情 —— 它會先檢查 ding.mp3 是否存在于 app 的包中,如果這個文件存在,它就會試圖去用這個文件的 URL 來實例化一個 AVAudioPlayer,并準備好它以備播放。這將會預先加載這個音頻文件,所以一旦需要,就可以立即播放。
如果 soundPlayer 存在,playSound 會調用它的 play() 方法;但如果 prepareSound 運行失敗了,soundPlayer 將會為空(nil),因此它什么也不會做。
聲音文件只在 Start 按鈕被點擊時需要被準備,所以把這行代碼插入到 startButtonClicked 方法的最后:
prepareSound()
在 EggTimerProtocol Extension 的 timerHasFinished 方法中,追加這行代碼:
playSound()
編譯并運行之,選擇一個短一點的時間并開始計時,一聲清脆的「叮??」會在計時結束的時候響起。

現(xiàn)在該做些什么?
你可以下載這個項目的源代碼。
在這個 macOS 開發(fā)教程中,你已經掌握了開發(fā) macOS app 的基本技能,但真正要學習的還有很多!
Apple 編寫了許多很棒的文檔,他們覆蓋了 macOS 開發(fā)的方方面面。
我同時強烈建議你去看看我們(原作者)的網站 raywenderlich.com 上的其他 macOS 教程。
如果你還有任何問題,歡迎在原文下方參與討論!