【譯】初學(xué)者的 MacOS 開(kāi)發(fā)教程:第三部分

原文

歡迎回到我們的新手 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)題。

image.png

沙盒化

在你深入代碼之前,花一分鐘考慮一下沙盒。如果你是一個(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)。

image.png

組織你的文件

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

image.png

選擇兩個(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)像這樣:

image.png

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ā)生了什么?

  1. startTime 是一個(gè) Optional Date,如果它是 nil,timer 不會(huì)運(yùn)行,什么也不會(huì)發(fā)生。
  2. 重新計(jì)算 elapsedTime 屬性。startTime 是比現(xiàn)在早的時(shí)間,所以 timeIntervalSinceNow 產(chǎn)生一個(gè)負(fù)值。負(fù)號(hào)將 elapsedTime 變成一個(gè)正值。
  3. 計(jì)算 timer 的剩余時(shí)間,四舍五入為整數(shù)秒。
  4. 如果 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ù)在做什么?

  1. startTimer 用 Date() 來(lái)設(shè)置開(kāi)始時(shí)間,建立一個(gè)循環(huán)計(jì)時(shí)器 Timer。
  2. resumeTimer 當(dāng)計(jì)時(shí)器被暫停又恢復(fù)時(shí)被調(diào)用。開(kāi)始時(shí)間通過(guò) elapsed time 從新計(jì)算。
  3. stopTimer 停止循環(huán)計(jì)時(shí)器。
  4. 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)。

image.png

預(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")
    }
  }

}

這些代碼做什么?

  1. 一個(gè)計(jì)算屬性 selectedTime 定義為 TimeInterval。
  2. 當(dāng)這個(gè)變量被請(qǐng)求時(shí),UserDefaults 單件被要求將一個(gè) Double 值賦給關(guān)鍵字 selectedTime。如果這個(gè)值沒(méi)有定義,將返回 0。如果這個(gè)值大于 0,則將其作為 selectedTime 的值返回。
  3. 如果 selectedTime 沒(méi)有定義,則使用默認(rèn)值 360 (6 分鐘)。
  4. 任何時(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ò)一下它:

  1. 向 prefs 對(duì)象請(qǐng)求 selectedTime,并將它從秒轉(zhuǎn)換成整數(shù)分鐘。
  2. 將默認(rèn)值設(shè)為 Custom,如果沒(méi)有找到匹配的預(yù)設(shè)置類型。
  3. 遍歷 presetsPopup 中的菜單項(xiàng)檢查它們的 tag。想一下在第2部分你是怎樣將每個(gè)選項(xiàng)的 tag 設(shè)為分鐘數(shù)的?如果找到一個(gè)匹配的項(xiàng),啟用這一項(xiàng),然后退出循環(huán)。
  4. 設(shè)置滑塊的值然后調(diào)用 showSliderValueAsText。
  5. 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()
  }
  1. 當(dāng)一個(gè)新的菜單項(xiàng)被選中時(shí),檢查它是不是 Custom 選項(xiàng)。如果是則啟用滑塊,然后退出。如果不是,通過(guò) tag 獲取分鐘數(shù),用它來(lái)設(shè)置滑塊和標(biāo)簽,然后禁用滑塊。
  2. 無(wú)論什么時(shí)候滑塊改變了,更新文本。
  3. 點(diǎn)擊 Cancel 關(guān)閉窗口,不保存改變。
  4. 點(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è)置。

image.png

實(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è)原因:

  1. Preferences 是一個(gè)結(jié)構(gòu),它是基于值的,不是基于引用。每個(gè)視圖控制器都有一個(gè)自己的拷貝。
  2. 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í)間將立即顯示。

image.png

啟動(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ā)生了什么?

  1. 如果計(jì)時(shí)器是暫停或停止的,直接重置,不用詢問(wèn)。
  2. 創(chuàng)建一個(gè) NSAlert,這是一個(gè)顯示對(duì)話框的類。配置它的文本和樣式。
  3. 添加兩個(gè)按鈕:Reset & Cancel。它們將按你添加的順序從右到左顯示,第一個(gè)按鈕時(shí)默認(rèn)的。
  4. 顯示模態(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)擊完成。

image.png

要播放聲音你需要 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)到“叮~~~”的聲音嗎?

image.png

你能從這里下載完整的項(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。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 原文 歡迎回到 macOS 開(kāi)發(fā)系列教程的第二部分! 在第一部分,你學(xué)了怎樣安裝 Xcode,怎樣創(chuàng)建一個(gè)新的 A...
    z_k閱讀 1,118評(píng)論 0 1
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,383評(píng)論 4 61
  • 在中國(guó),成人涂色書(shū)自2014年逐步興起,2015年中旬甚至一度登上熱搜熱購(gòu)書(shū)籍的榜首,以至成人涂畫(huà)到今年還風(fēng)韻猶存...
    岫驀閱讀 941評(píng)論 10 1
  • 稍縱即逝的鏡頭后,是無(wú)數(shù)次愛(ài)不覺(jué)苦的觀察和揣摩,是埋頭練習(xí)的勤謹(jǐn),是熟生巧巧生慧的專業(yè)技能。 一朵花開(kāi)在人們的眼前...
    靈山閱讀 242評(píng)論 0 0

友情鏈接更多精彩內(nèi)容