macOS 新手开发:第 3 部分

欢迎回到 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.plistEggTimer.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 将自身分配为 EggTimerdelegate,所以由它来接收消息,然后它可以在自己的 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)
    }
  }

会发生什么?

  1. startTime 是一个 Optional Date——如果是 nil,timer 就无法运行,所以什么都不会发生。
  2. 重新计算 elapsedTime 属性。 startTime 早于当前,因此 timeIntervalSinceNow 会生成负数。用减号使得 elapsedTime 是正数。
  3. 计算 timer 的剩余秒数,四舍五入以给出整数秒。
  4. 如果 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()
  }

这些函数做了什么?

  1. startTimer 使用 Date() 将启动时间设置为现在、设置了重复的 Timer
  2. resumeTimer 是 timer 已暂停并正在重新启动时调用的内容。基于已过去的时间重新计算开始时间。
  3. stopTimer 停止了重复的 timer。
  4. 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")
    }
  }
 
}

这段代码可以做什么?

  1. 叫做 selectedTime 的计算变量定义为 TimeInterval
  2. 请求变量的值时,UserDefaults 单例取出分配给键 “selectedTime” 的Double 值。如果值未定义,UserDefaults 将返回零,但如果值大于 0,则将其作为 selectedTime 的值返回。
  3. 如果 selectedTime 没有被定义,使用默认值 360(6 分钟)。
  4. 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)"
}

看上去有很多代码,一步步看一遍:

  1. 请求 prefs 对象的 selectedTime,并将其从秒数转换为整数分钟。
  2. 如果找不到匹配的预设值,请将默认值设置为 “Custom”。
  3. 遍历循环 presetsPopup 中的菜单项检查他们的 tag。还记得在第 2 部分中如何将 tag 设置为每个选项的分钟数吗?如果找到匹配,启用该项目并退出循环。
  4. 设置滑块的值并调用 showSliderValueAsText
  5. 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()
  }
  1. 从弹出窗口中选择一个新项目时,检查它是否是自定义菜单项。如果是,启用滑块并退出。如果没有,使用 tag 获取分钟数,使用它们来设置滑块值和文本,并禁用滑块。
  2. 滑块变动时更新文字。
  3. 点击 Cancel 会关闭窗口,且不保存改变。
  4. 点击 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也有一个——这是一个错误吗?不,有几个原因。

  1. Preferences 是一个结构体,因此它是基于值的,不是基于引用的。每个 View Controller 都有自己的副本。
  2. 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()
      }
    }
  }

上面发生了什么?

  1. 如果 timer 停止或暂停了,不用询问直接弄。
  2. 创建一个 NSAlert,它是显示对话框的类。配置其文本和样式。
  3. 添加2个按钮:Reset 和 Cancel。它们将按照从右到左的顺序显示,第一个将是默认选项。
  4. 将 alert 显示为模态对话框,然后等待答复。检查用户是否点击第一个按钮(复位),如果是这样,重置定时器。

setupPrefs 方法中,将 self.updateFromPrefs() 行更改为:

self.checkForResetAfterPrefsChange()

构建并运行 app,启动计时器,打开 Preferences,更改时间,然后单击确定。你会看到对话框,询问是否重置。

声音

这个 app 目前为止唯一尚未涉及的就是声音了。煮蛋器如果不能叮叮叮叮叮叮就不是煮蛋器了!

在第 2 部分中,已下载了 app 的资源文件夹。大多数是图像,已经用上了,但也有一个声音文件:ding.mp3。如果你需要再次下载,这里是一个只有 声音文件 的链接。

ding.mp3 文件拖动到 Project NavigatorEggTimer 组内——就在 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 教程。

如果您有任何问题或意见,请在下面评论!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容

  • 原文 欢迎回到我们的新手 macOS 开发系列教程,这是我第三部分,也是最后一部分。 在第一部分你学习了怎样安装 ...
    z_k阅读 688评论 0 1
  • 本文翻译自 raywenderlich.com 的 macOS 开发经典入门教程 ,已咨询对方网站,可至多翻译 1...
    SR2k阅读 2,138评论 0 4
  • 你想学习如何开发自己的 macOS app 吗? 好消息!苹果让 macOS 开发变得相当简单,在本教程中你会学到...
    张嘉夫阅读 20,315评论 10 51
  • 现在这个高科技和信息发达的时代,你若不改变思路,投入状态,时代抛弃你,不会打招呼。因为有了银行卡和手机,...
    亚涓阅读 1,564评论 27 58
  • 近日,网上新闻,厦大一附属医院男研究员陆某,已婚出轨厦大女博士,并且该女博士还同时与四个博士交往,知道真相...
    大鱼与驴阅读 1,142评论 7 8