歡迎回到我們的新手 macOS 開(kāi)發(fā)系列教程,這是我第三部分,也是最后一部分。
在第一部分你學(xué)習(xí)了怎樣安裝 Xcode 和創(chuàng)建簡(jiǎn)單的 APP。在第二部分你為一個(gè)更復(fù)雜的 APP 創(chuàng)建接口,但是它還不能正常工作,因?yàn)榫帉?xiě)代碼。在這一部分,你將添加 Swift 代碼讓你的 APP 活起來(lái)。
準(zhǔn)備開(kāi)始
如果你還沒(méi)有完成第二部分,或者你想從頭開(kāi)始,你可以從這里下載項(xiàng)目文件。它包含了第二部分結(jié)束時(shí)的 UI 布局。打開(kāi)這個(gè)項(xiàng)目或你自己的項(xiàng)目,運(yùn)行它確認(rèn)所有 UI 都在適當(dāng)?shù)奈恢?。打開(kāi)偏好窗口確認(rèn)它也沒(méi)問(wèn)題。

沙盒化
在你深入代碼之前,花一分鐘考慮一下沙盒。如果你是一個(gè) iOS 開(kāi)發(fā)人員,你已經(jīng)對(duì)這個(gè)概念熟悉了,如果不是,繼續(xù)閱讀。
一個(gè)沙盒 APP 有它自己的工作空間和單獨(dú)的文件存儲(chǔ)區(qū)域,不能訪問(wèn)其他 APP 創(chuàng)建的文件和受限的入口及許可。對(duì)于 iOS APP 這是操作的唯一方式。對(duì)于 macOS 這是可選的。但是,如果你想通過(guò) App Store 分發(fā) APP,它必須是沙盒化的。作為一個(gè)通用的規(guī)則,你應(yīng)該沙盒化你的 APP,這將降低 APP 出問(wèn)題的可能性。
要沙盒化 Egg Timer APP,在項(xiàng)目導(dǎo)航器里選擇項(xiàng)目——最上面帶有攬收?qǐng)D標(biāo)的條目。選擇 Targets 清單里的 EggTimer(只有一個(gè) target 列出來(lái)),然后在頂部選項(xiàng)卡中點(diǎn)擊 Capabilities 。點(diǎn)擊開(kāi)關(guān)打開(kāi) App Sandbox。顯示器將展開(kāi)各種權(quán)限,可以通過(guò)它們來(lái)設(shè)置 APP。這個(gè) APP 不需要它們中的任何一個(gè),所以讓他們保持 uncheck 狀態(tài)。

組織你的文件
看一下項(xiàng)目導(dǎo)航器,所有文件被沒(méi)有特別組織的列出來(lái)。這個(gè) APP 沒(méi)有太多文件,但是將相似的文件組織在一起是一個(gè)好的實(shí)踐,給予更高效的導(dǎo)航,特別是大項(xiàng)目。

選擇兩個(gè) view controller 文件,先選中一個(gè),按著 Shift 見(jiàn)點(diǎn)擊下一個(gè)。點(diǎn)擊右鍵,在彈出菜單中選擇 New Group from Selection。將組命名為 View Controllers。
這個(gè)項(xiàng)目即將有幾個(gè) Model 文件,所有選擇頂層的 EggTimer 組,點(diǎn)擊右鍵,選擇 New Group,將組命名為 Model。
最后選擇 Info.plist 和 EggTimer.entitlements,將它們放到新的組 Supporting Files。
拖動(dòng)組和文件,知道項(xiàng)目導(dǎo)航看起來(lái)像這樣:

MVC
這個(gè) APP 使用 MVC 模式:Model View Controller。
這個(gè) APP 的主要 model 對(duì)象是一個(gè)叫做 EggTimer 的類。這個(gè)類包含這些屬性:計(jì)時(shí)器開(kāi)始時(shí)間,請(qǐng)求時(shí)長(zhǎng)和剩余時(shí)間。還有一個(gè) Timer 對(duì)象,用來(lái)每秒鐘更新自己。方法 start,stop,resume 或 reset 用來(lái)處理 EggTimer 對(duì)象。
EggTimer 模型掌控?cái)?shù)據(jù),執(zhí)行方法,但它不知道怎么顯示它們??刂破鳎ㄟ@里是 ViewController)知道 EggTimer 類(模型),并且有一個(gè)用來(lái)顯示數(shù)據(jù)的 View。
EggTimer 使用委托協(xié)議與 ViewController 通信。當(dāng)發(fā)生什么改變時(shí),EggTimer 發(fā)送消息給它的代理 delegate。ViewController 將自己作為委托對(duì)象賦值給 EggTimer 的 delegate,所以它會(huì)收到消息,然后在自己的視圖里顯示數(shù)據(jù)。
編碼 EggTimer
選擇項(xiàng)目導(dǎo)航中的 Model,然后選擇 File/New/File,選擇 macOS/Swift File,點(diǎn)擊下一步。將文件命名為 EggTimer.swift,點(diǎn)擊 Create 創(chuàng)建并保存。
添加以下代碼:
class EggTimer {
var timer: Timer? = nil
var startTime: Date?
var duration: TimeInterval = 360 // default = 6 minutes
var elapsedTime: TimeInterval = 0
}
這就建立了 EggTimer 和它的屬性。TimeInterval 真實(shí)的含義是 Double,但是被用來(lái)代表秒。
下一步是在類里添加兩個(gè)計(jì)算屬性,跟在在前面的屬性后面:
var isStopped: Bool {
return timer == nil && elapsedTime == 0
}
var isPaused: Bool {
return timer == nil && elapsedTime > 0
}
這是用來(lái)確認(rèn) EggTimer 狀態(tài)的快捷方式。
在 EggTimer.swift 文件里插入委托協(xié)議的定義,但是要放在 EggTimer 類外面。我喜歡將委托協(xié)議的定義放在文件的頂部,import 的下面。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
一個(gè)協(xié)議設(shè)置了一個(gè)合約,任何遵循這個(gè)協(xié)議的對(duì)象都必須提供這兩個(gè)方法。
現(xiàn)在你已經(jīng)定義了一個(gè)協(xié)議,EggTimer 可以有一個(gè)可選的 delegate,用來(lái)設(shè)置為任何遵循這個(gè)協(xié)議的對(duì)象。EggTimer 不知道,也不在乎這個(gè)對(duì)象的類型,因?yàn)檫@個(gè)委托對(duì)象必然擁有這兩個(gè)方法。
添加這行到 EggTimer 類的現(xiàn)有屬性中:
var delegate: EggTimerProtocol?
啟動(dòng) EggTimer 的計(jì)時(shí)器每秒鐘將觸發(fā)一個(gè)函數(shù)調(diào)用。插入下面這行函數(shù)定義的代碼,它將被計(jì)時(shí)器調(diào)用。為了讓 Timer 找到它,dynamic 關(guā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)
}
}
這里發(fā)生了什么?
- startTime 是一個(gè) Optional Date,如果它是 nil,timer 不會(huì)運(yùn)行,什么也不會(huì)發(fā)生。
- 重新計(jì)算 elapsedTime 屬性。startTime 是比現(xiàn)在早的時(shí)間,所以 timeIntervalSinceNow 產(chǎn)生一個(gè)負(fù)值。負(fù)號(hào)將 elapsedTime 變成一個(gè)正值。
- 計(jì)算 timer 的剩余時(shí)間,四舍五入為整數(shù)秒。
- 如果 timer 結(jié)束,重置它,然后告訴委托對(duì)象它已經(jīng)結(jié)束。否則告訴委托對(duì)象剩余秒數(shù)。因?yàn)槭且粋€(gè)可選屬性,所以用 ? 號(hào)執(zhí)行可選鏈操作。如果 delegate 沒(méi)有設(shè)置,方法不會(huì)被調(diào)用,不會(huì)發(fā)生什么不好的事情。
你會(huì)看到一個(gè)錯(cuò)誤,直到你添加了 EggTimer 需要的最后一點(diǎn)代碼:用來(lái)啟動(dòng)、停止、恢復(fù)、重置的方法。
// 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()
}
這些函數(shù)在做什么?
- startTimer 用 Date() 來(lái)設(shè)置開(kāi)始時(shí)間,建立一個(gè)循環(huán)計(jì)時(shí)器 Timer。
- resumeTimer 當(dāng)計(jì)時(shí)器被暫停又恢復(fù)時(shí)被調(diào)用。開(kāi)始時(shí)間通過(guò) elapsed time 從新計(jì)算。
- stopTimer 停止循環(huán)計(jì)時(shí)器。
- resetTimer 停止循環(huán)計(jì)時(shí)器,然后把所有屬性設(shè)置為默認(rèn)值。
所有方法都調(diào)用 timerAction,用來(lái)立即顯示更新。
ViewController
現(xiàn)在 EggTimer 對(duì)象已經(jīng)工作,是時(shí)候回到 ViewController.swift 改變顯示內(nèi)容來(lái)反映它了。
ViewController 已經(jīng)有 @IBOutlet 屬性,現(xiàn)在給它一個(gè) EggTimer 屬性:
var eggTimer = EggTimer()
添加這行到 viewDidLoad,替換注釋行:
eggTimer.delegate = self
這會(huì)引起一個(gè)錯(cuò)誤,因?yàn)?ViewController 沒(méi)有遵循 EggTimerProtocol 協(xié)議。在遵循一個(gè)協(xié)議時(shí),如果你為協(xié)議方法創(chuàng)建一個(gè)單獨(dú)的擴(kuò)展,會(huì)使代碼更加整潔。
在 ViewController 類定義下面添加以下代碼:
extension ViewController: EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval) {
updateDisplay(for: timeRemaining)
}
func timerHasFinished(_ timer: EggTimer) {
updateDisplay(for: 0)
}
}
現(xiàn)在錯(cuò)誤消失了,因?yàn)?ViewController 擁有兩個(gè)符合 EggTimerProtocol 協(xié)議的方法了。但是這兩個(gè)方法都調(diào)用的 updateDisplay 方法還不存在。
這是另一個(gè) ViewController 類的擴(kuò)展,它包含顯示方法:
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 使用私有方法按照提供的剩余時(shí)間來(lái)獲得相應(yīng)的文本和圖片,然后在標(biāo)簽和圖形視圖里顯示。
textToDisplay 將剩余時(shí)間轉(zhuǎn)換成 M:SS 格式。imageToDisplay 按百分比計(jì)算雞蛋煮了多久,然后選擇匹配的圖片。
現(xiàn)在 ViewController 有了一個(gè) EggTimer 對(duì)象,和接收從 EggTimer 來(lái)的數(shù)據(jù)的方法,以及顯示結(jié)果的方法,但是按鈕還沒(méi)有編程。在第二部分你為按鈕設(shè)置了 @IBActions。
這是那些動(dòng)作方法的代碼,你可以用它替換它們:
@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)
}
這三個(gè)方法都調(diào)用你早些時(shí)候創(chuàng)建的 EggTimer 的方法。
編譯運(yùn)行 APP 點(diǎn)擊開(kāi)始按鈕。
仍然有幾個(gè)缺失的特征:Stop 和 Reset 一直是失效的,你只能有 6 分鐘的雞蛋。你可以用 Timer 菜單控制 APP。嘗試使用快捷鍵停止、開(kāi)始、重置 APP。
如果你有足夠的耐心等待,你會(huì)看到在煮的過(guò)程中雞蛋的顏色會(huì)改變,最終顯示 “DONE!”。
按鈕和菜單
按鈕應(yīng)該根據(jù)計(jì)時(shí)器的狀態(tài)變成啟用和禁用,Timer 菜單項(xiàng)也應(yīng)該和這個(gè)匹配。
在 ViewController 類的顯示函數(shù)擴(kuò)展里添加以下代碼:
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)
}
}
這個(gè)方法用 EggTimer 的狀態(tài)(記得你添加到 EggTimer 的計(jì)算屬性)算出哪些按鈕應(yīng)該被啟用。
在第二部分,你將 Timer 菜單項(xiàng)設(shè)置為 AppDelegate 的屬性,所以 AppDelegate 是它們被配置的地方。
切換到 AppDelegate.swift,添加以下代碼:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
為了讓你的菜單在第一次啟動(dòng)時(shí)正確配置,添加這行到 applicationDidFinishLaunching 方法里:
enableMenus(start: true, stop: false, reset: false)
無(wú)論什么時(shí)候按鈕和菜單項(xiàng)動(dòng)作改變了 EggTimer 的狀態(tài),按鈕和菜單都要被改變。
切換到 ViewController.swift,在每個(gè)按鈕的方法了添加這一行:
configureButtonsAndMenus()
編譯運(yùn)行 APP,你能看到按鈕按照期望的啟用和禁用了。檢查菜單項(xiàng),它應(yīng)該反映按鈕狀態(tài)。

預(yù)置參數(shù)
現(xiàn)在這個(gè) APP 真的只剩下一個(gè)較大的問(wèn)題了——如果你不喜歡煮6分鐘的雞蛋怎么辦?
在第2部分,你設(shè)計(jì)了一個(gè)偏好窗口來(lái)選擇不同的時(shí)間。這個(gè)窗口被 PrefsViewController 控制,但是它需要模型來(lái)處理數(shù)據(jù)存儲(chǔ)和恢復(fù)。
偏好設(shè)置將通過(guò) UserDefaults 來(lái)存儲(chǔ),這是一種用來(lái)存儲(chǔ)小數(shù)據(jù)塊的 key-value 結(jié)構(gòu)的方式,數(shù)據(jù)放在 APP 容器的 Preferences 文件夾里。
點(diǎn)擊項(xiàng)目導(dǎo)航里的 Model 組,選擇 New File,選擇 macOS/Swift File,點(diǎn)擊下一步。文件命名為 Preferences.swift,點(diǎn)擊 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")
}
}
}
這些代碼做什么?
- 一個(gè)計(jì)算屬性 selectedTime 定義為 TimeInterval。
- 當(dāng)這個(gè)變量被請(qǐng)求時(shí),UserDefaults 單件被要求將一個(gè) Double 值賦給關(guān)鍵字 selectedTime。如果這個(gè)值沒(méi)有定義,將返回 0。如果這個(gè)值大于 0,則將其作為 selectedTime 的值返回。
- 如果 selectedTime 沒(méi)有定義,則使用默認(rèn)值 360 (6 分鐘)。
- 任何時(shí)候 selectedTime 值改變了,將新的值以 selectedTime 關(guān)鍵字寫(xiě)到 UserDefaults 。
通過(guò)使用 帶有 getter 和 setter 的計(jì)算屬性,UserDefaults 數(shù)據(jù)存儲(chǔ)將被自動(dòng)處理。
現(xiàn)在切到 PrefsViewController.swift,它的首要任務(wù)是更新顯示以反映現(xiàn)有偏好設(shè)置或者默認(rèn)值。
首先,在 outlets 下面添加一樣:
var prefs = Preferences()
這里你創(chuàng)建了一個(gè) Preferences 的實(shí)例,用來(lái)訪問(wè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)"
}
看起來(lái)有很多代碼,讓我們一步一步過(guò)一下它:
- 向 prefs 對(duì)象請(qǐng)求 selectedTime,并將它從秒轉(zhuǎn)換成整數(shù)分鐘。
- 將默認(rèn)值設(shè)為 Custom,如果沒(méi)有找到匹配的預(yù)設(shè)置類型。
- 遍歷 presetsPopup 中的菜單項(xiàng)檢查它們的 tag。想一下在第2部分你是怎樣將每個(gè)選項(xiàng)的 tag 設(shè)為分鐘數(shù)的?如果找到一個(gè)匹配的項(xiàng),啟用這一項(xiàng),然后退出循環(huán)。
- 設(shè)置滑塊的值然后調(diào)用 showSliderValueAsText。
- showSliderValueAsText 為數(shù)字添加 "minute" 或 "minutes",然后在標(biāo)簽里顯示它。
現(xiàn)在添加這行到 viewDidLoad:
showExistingPrefs()
當(dāng)視圖被加載時(shí),調(diào)用這個(gè)方法顯示偏好設(shè)置。記住,用 MVC 模式時(shí),Preferences 對(duì)象不知道什么時(shí)候也不知道這樣顯示——這些是交給 PrefsViewController 管理的。
現(xiàn)在你有能力去顯示設(shè)置的時(shí)間了,但是改變菜單中的時(shí)間還沒(méi)有做任何事。你需要一個(gè)方法來(lái)保存新的數(shù)據(jù),然后告訴對(duì)數(shù)據(jù)改變感興趣的人。
在 EggTimer 中,你使用委托對(duì)象來(lái)傳遞任何它感興趣的數(shù)據(jù)。這一次(僅僅是為了不同)當(dāng)數(shù)據(jù)改變時(shí)你將廣播一個(gè)消息(Notification)。任何選擇的對(duì)象都可以監(jiān)聽(tīng)這個(gè)消息,在收到時(shí)對(duì)它進(jìn)行處理。
在 PrefsViewController 里插入方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
這個(gè)方法從滑塊獲得數(shù)據(jù)(一會(huì)你將看到任何改變都在這里反映)。設(shè)置 selectedTime 屬性將自動(dòng)保存新數(shù)據(jù)到 UserDefaults。然后名為 PrefsChanged 的消息被發(fā)到 NotificationCenter。
一會(huì)你將看到 ViewController 如何設(shè)置監(jiān)聽(tīng) Notification 消息,并作出相應(yīng)。
PrefsViewController 編碼的最后一步是設(shè)置你在第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()
}
- 當(dāng)一個(gè)新的菜單項(xiàng)被選中時(shí),檢查它是不是 Custom 選項(xiàng)。如果是則啟用滑塊,然后退出。如果不是,通過(guò) tag 獲取分鐘數(shù),用它來(lái)設(shè)置滑塊和標(biāo)簽,然后禁用滑塊。
- 無(wú)論什么時(shí)候滑塊改變了,更新文本。
- 點(diǎn)擊 Cancel 關(guān)閉窗口,不保存改變。
- 點(diǎn)擊 Ok 時(shí)先調(diào)用 saveNewPrefs,然后關(guān)閉窗口。
編譯運(yùn)行 APP,然后打開(kāi) Preferences 窗口。嘗試選擇菜單里不同的選項(xiàng)——注意滑塊和文本標(biāo)簽怎么跟著變化。選擇 Custom,然后選擇你自己的時(shí)間。點(diǎn)擊 OK,回到 Preferences 窗口,確認(rèn)你選擇的時(shí)間仍然顯示。
現(xiàn)在退出 APP,然后重新啟動(dòng)。回到 Preferences,看到它已經(jīng)保存了你的設(shè)置。

實(shí)現(xiàn)選擇的偏好
偏好窗口看起來(lái)沒(méi)問(wèn)題了——能按期望的保存和恢復(fù)你選擇的實(shí)際。但是當(dāng)你回到主窗口,你仍然得到的是 6 分鐘的雞蛋!
所以,你需要編輯 ViewController.swift 讓它使用存儲(chǔ)的時(shí)間,然后監(jiān)聽(tīng)變化的通知,并對(duì) timer 計(jì)時(shí)器作出改變和重置。
在任何類定義和擴(kuò)展外面添加一個(gè)到 ViewController.swift 的擴(kuò)展——為了代碼簡(jiǎn)潔,它把有關(guān)偏好的所有功能組織到一個(gè)單獨(dú)的包里:
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)
}
}
這會(huì)提示錯(cuò)誤,因?yàn)?ViewController 沒(méi)有 prefs 對(duì)象。在 ViewController 類定義中,你定義 eggTimer 屬性的地方加一行:
var prefs = Preferences()
現(xiàn)在 PrefsViewController 有 prefs 對(duì)象,ViewController 也有一個(gè)同樣的對(duì)象——這有問(wèn)題嗎?沒(méi)有,有幾個(gè)原因:
- Preferences 是一個(gè)結(jié)構(gòu),它是基于值的,不是基于引用。每個(gè)視圖控制器都有一個(gè)自己的拷貝。
- Preferences 結(jié)構(gòu)通過(guò)一個(gè)彈件對(duì)象和 UserDefaults 交互,所有兩份拷貝都使用相同的 UserDefaults 獲取相同的數(shù)據(jù)。
在 viewDidLoad 函數(shù)的最后添加這個(gè)函數(shù)調(diào)用,用來(lái)設(shè)置 Preferences 連接:
setupPrefs()
這是最后一組需要的編輯。早些時(shí)候,你使用了時(shí)間的硬編碼值——360秒,6分鐘?,F(xiàn)在 ViewController 能訪問(wèn) Preferences,你要將硬編碼 360 改為 prefs.selectedTime。
在 ViewController.swift 里搜索 360,將它改為 prefs.selectedTime——你應(yīng)該會(huì)找到 3 處。
編譯運(yùn)行 APP,如果你開(kāi)始選擇了你偏愛(ài)的煮蛋時(shí)間,剩余時(shí)間將會(huì)顯示你選擇的。到 Preferences 窗口選擇一個(gè)不同的時(shí)間,點(diǎn)擊 OK。當(dāng) ViewController 收到通知 Notification 時(shí),新的時(shí)間將立即顯示。

啟動(dòng)計(jì)時(shí)器,然后轉(zhuǎn)到 Preferences。倒數(shù)計(jì)時(shí)仍在后面的窗口繼續(xù)。改變煮蛋時(shí)間,點(diǎn)擊 OK。計(jì)時(shí)器會(huì)運(yùn)用新的時(shí)間,但是會(huì)停止和重置計(jì)時(shí)器。我認(rèn)為這很好,但是如果 APP 發(fā)出警告將會(huì)發(fā)生改變會(huì)更好。添加一個(gè)對(duì)話框提示你是否真的想做這個(gè)操作怎么樣?
在處理 Preferences 的 ViewController 的擴(kuò)展中,添加這個(gè)函數(shù):
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()
}
}
}
這里發(fā)生了什么?
- 如果計(jì)時(shí)器是暫停或停止的,直接重置,不用詢問(wèn)。
- 創(chuàng)建一個(gè) NSAlert,這是一個(gè)顯示對(duì)話框的類。配置它的文本和樣式。
- 添加兩個(gè)按鈕:Reset & Cancel。它們將按你添加的順序從右到左顯示,第一個(gè)按鈕時(shí)默認(rèn)的。
- 顯示模態(tài)對(duì)話框,等待回答。確認(rèn)用戶是否點(diǎn)擊了第一個(gè)按鈕(reset),如果是,重置計(jì)時(shí)器。
在 setupPrefs 方法里將 self.updateFromPrefs() 改為:
self.checkForResetAfterPrefsChange()
編譯運(yùn)行 APP,啟動(dòng)計(jì)時(shí)器,轉(zhuǎn)到 Preferences,修改時(shí)間,點(diǎn)擊 OK。你將看到對(duì)話框,選擇 reset 或者 cancel。
聲音
到現(xiàn)在,這個(gè) APP 只有一個(gè)部分我們還沒(méi)有涉及了,這就是聲音。
一個(gè)煮蛋計(jì)時(shí)器如果不會(huì)發(fā)出“叮~~~”的聲音就不是一個(gè)煮蛋器。
在第二部分,你為 APP 下載了一個(gè)資源文件夾。它們中大部分是圖片,你已經(jīng)使用了,但是有一個(gè)是聲音文件:ding.mp3。如果你需要再下載一次,這里是它自己的連接 sound file。
將 ding.mp3 文件拖到項(xiàng)目導(dǎo)航中的 EggTimer 組——就放在 Main.storyboard 下面似乎比較合邏輯。確保 Copy items if needed 和 EggTimer target 是選擇的,點(diǎn)擊完成。

要播放聲音你需要 AVFoundation 庫(kù)。當(dāng) EggTimer 告訴委托對(duì)象計(jì)時(shí)器結(jié)束時(shí),ViewController 需要播放聲音。所以轉(zhuǎn)到 ViewController.swift,在頂部,你將看到 Cocoa 庫(kù)引入的地方。
在那行下面添加:
import AVFoundation
ViewController 需要一個(gè)播放器來(lái)播放聲音文件,所以為它添加一個(gè)屬性:
var soundPlayer: AVAudioPlayer?
為 ViewController 單獨(dú)添加一個(gè)擴(kuò)展似乎是一個(gè)好主意,用它處理聲音相關(guān)的函數(shù)。在所有函數(shù)和擴(kuò)展外面添加以下代碼到 ViewController.swift。
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 函數(shù)做了大部分工作——它首先確認(rèn) APP bundle 里的 ding.mp3 文件是否可用。如果文件這那里,它嘗試聲音文件的路徑來(lái)初始化 AVAudioPlayer,然后為播放做好準(zhǔn)備。這為聲音文件準(zhǔn)備好緩沖,需要的時(shí)候可以立即播放。
playSound 函數(shù)僅僅是向播放器發(fā)送一個(gè)播放消息。如果 prepareSound 失敗,soundPlayer 的值是 nil,那么什么也不做。
聲音只需要在 Start 按鈕被點(diǎn)擊是預(yù)處理一次,所以將這行代碼插到 startButtonClicked 的最后:
prepareSound()
在 EggTimerProtocol 協(xié)議擴(kuò)展的 timerHasFinished 函數(shù)里添加:
playSound()
編譯運(yùn)行 APP,隨便選擇一個(gè)短的時(shí)間,然后啟動(dòng)計(jì)時(shí)器。當(dāng)計(jì)時(shí)器停止時(shí),你能聽(tīng)到“叮~~~”的聲音嗎?

你能從這里下載完整的項(xiàng)目。
這個(gè) macOS 開(kāi)發(fā)的系列教程已經(jīng)給了你一個(gè)基本的知識(shí),通過(guò)它你可以開(kāi)始做 macOS app 開(kāi)發(fā)了,但是還有很多東西要學(xué)。
蘋(píng)果有很好的文檔,涵蓋了 macOS 開(kāi)發(fā)的方方面面。
我也非常建議看一下其他的教程 raywenderlich.com。