原文
欢迎回到我们的新手 macOS 开发系列教程,这是我第三部分,也是最后一部分。
在第一部分你学习了怎样安装 Xcode 和创建简单的 APP。在第二部分你为一个更复杂的 APP 创建接口,但是它还不能正常工作,因为编写代码。在这一部分,你将添加 Swift 代码让你的 APP 活起来。
准备开始
如果你还没有完成第二部分,或者你想从头开始,你可以从这里下载项目文件。它包含了第二部分结束时的 UI 布局。打开这个项目或你自己的项目,运行它确认所有 UI 都在适当的位置。打开偏好窗口确认它也没问题。
沙盒化
在你深入代码之前,花一分钟考虑一下沙盒。如果你是一个 iOS 开发人员,你已经对这个概念熟悉了,如果不是,继续阅读。
一个沙盒 APP 有它自己的工作空间和单独的文件存储区域,不能访问其他 APP 创建的文件和受限的入口及许可。对于 iOS APP 这是操作的唯一方式。对于 macOS 这是可选的。但是,如果你想通过 App Store 分发 APP,它必须是沙盒化的。作为一个通用的规则,你应该沙盒化你的 APP,这将降低 APP 出问题的可能性。
要沙盒化 Egg Timer APP,在项目导航器里选择项目——最上面带有揽收图标的条目。选择 Targets 清单里的 EggTimer(只有一个 target 列出来),然后在顶部选项卡中点击 Capabilities 。点击开关打开 App Sandbox。显示器将展开各种权限,可以通过它们来设置 APP。这个 APP 不需要它们中的任何一个,所以让他们保持 uncheck 状态。
组织你的文件
看一下项目导航器,所有文件被没有特别组织的列出来。这个 APP 没有太多文件,但是将相似的文件组织在一起是一个好的实践,给予更高效的导航,特别是大项目。
选择两个 view controller 文件,先选中一个,按着 Shift 见点击下一个。点击右键,在弹出菜单中选择 New Group from Selection。将组命名为 View Controllers。
这个项目即将有几个 Model 文件,所有选择顶层的 EggTimer 组,点击右键,选择 New Group,将组命名为 Model。
最后选择 Info.plist 和 EggTimer.entitlements,将它们放到新的组 Supporting Files。
拖动组和文件,知道项目导航看起来像这样:
MVC
这个 APP 使用 MVC 模式:Model View Controller。
这个 APP 的主要 model 对象是一个叫做 EggTimer 的类。这个类包含这些属性:计时器开始时间,请求时长和剩余时间。还有一个 Timer 对象,用来每秒钟更新自己。方法 start,stop,resume 或 reset 用来处理 EggTimer 对象。
EggTimer 模型掌控数据,执行方法,但它不知道怎么显示它们。控制器(这里是 ViewController)知道 EggTimer 类(模型),并且有一个用来显示数据的 View。
EggTimer 使用委托协议与 ViewController 通信。当发生什么改变时,EggTimer 发送消息给它的代理 delegate。ViewController 将自己作为委托对象赋值给 EggTimer 的 delegate,所以它会收到消息,然后在自己的视图里显示数据。
编码 EggTimer
选择项目导航中的 Model,然后选择 File/New/File,选择 macOS/Swift File,点击下一步。将文件命名为 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 状态的快捷方式。
在 EggTimer.swift 文件里插入委托协议的定义,但是要放在 EggTimer 类外面。我喜欢将委托协议的定义放在文件的顶部,import 的下面。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer, timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
一个协议设置了一个合约,任何遵循这个协议的对象都必须提供这两个方法。
现在你已经定义了一个协议,EggTimer 可以有一个可选的 delegate,用来设置为任何遵循这个协议的对象。EggTimer 不知道,也不在乎这个对象的类型,因为这个委托对象必然拥有这两个方法。
添加这行到 EggTimer 类的现有属性中:
var delegate: EggTimerProtocol?
启动 EggTimer 的计时器每秒钟将触发一个函数调用。插入下面这行函数定义的代码,它将被计时器调用。为了让 Timer 找到它,dynamic 关键字是必须的。
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 没有设置,方法不会被调用,不会发生什么不好的事情。
你会看到一个错误,直到你添加了 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() {
// stop the timer & reset back to start
timer?.invalidate()
timer = nil
startTime = nil
duration = 360
elapsedTime = 0
timerAction()
}
这些函数在做什么?
- startTimer 用 Date() 来设置开始时间,建立一个循环计时器 Timer。
- resumeTimer 当计时器被暂停又恢复时被调用。开始时间通过 elapsed time 从新计算。
- stopTimer 停止循环计时器。
- resetTimer 停止循环计时器,然后把所有属性设置为默认值。
所有方法都调用 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 使用私有方法按照提供的剩余时间来获得相应的文本和图片,然后在标签和图形视图里显示。
textToDisplay 将剩余时间转换成 M:SS 格式。imageToDisplay 按百分比计算鸡蛋煮了多久,然后选择匹配的图片。
现在 ViewController 有了一个 EggTimer 对象,和接收从 EggTimer 来的数据的方法,以及显示结果的方法,但是按钮还没有编程。在第二部分你为按钮设置了 @IBActions。
这是那些动作方法的代码,你可以用它替换它们:
@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)
}
这三个方法都调用你早些时候创建的 EggTimer 的方法。
编译运行 APP 点击开始按钮。
仍然有几个缺失的特征:Stop 和 Reset 一直是失效的,你只能有 6 分钟的鸡蛋。你可以用 Timer 菜单控制 APP。尝试使用快捷键停止、开始、重置 APP。
如果你有足够的耐心等待,你会看到在煮的过程中鸡蛋的颜色会改变,最终显示 “DONE!”。
按钮和菜单
按钮应该根据计时器的状态变成启用和禁用,Timer 菜单项也应该和这个匹配。
在 ViewController 类的显示函数扩展里添加以下代码:
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 的计算属性)算出哪些按钮应该被启用。
在第二部分,你将 Timer 菜单项设置为 AppDelegate 的属性,所以 AppDelegate 是它们被配置的地方。
切换到 AppDelegate.swift,添加以下代码:
func enableMenus(start: Bool, stop: Bool, reset: Bool) {
startTimerMenuItem.isEnabled = start
stopTimerMenuItem.isEnabled = stop
resetTimerMenuItem.isEnabled = reset
}
为了让你的菜单在第一次启动时正确配置,添加这行到 applicationDidFinishLaunching 方法里:
enableMenus(start: true, stop: false, reset: false)
无论什么时候按钮和菜单项动作改变了 EggTimer 的状态,按钮和菜单都要被改变。
切换到 ViewController.swift,在每个按钮的方法了添加这一行:
configureButtonsAndMenus()
编译运行 APP,你能看到按钮按照期望的启用和禁用了。检查菜单项,它应该反映按钮状态。
预置参数
现在这个 APP 真的只剩下一个较大的问题了——如果你不喜欢煮6分钟的鸡蛋怎么办?
在第2部分,你设计了一个偏好窗口来选择不同的时间。这个窗口被 PrefsViewController 控制,但是它需要模型来处理数据存储和恢复。
偏好设置将通过 UserDefaults 来存储,这是一种用来存储小数据块的 key-value 结构的方式,数据放在 APP 容器的 Preferences 文件夹里。
点击项目导航里的 Model 组,选择 New File,选择 macOS/Swift File,点击下一步。文件命名为 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 单件被要求将一个 Double 值赋给关键字 selectedTime。如果这个值没有定义,将返回 0。如果这个值大于 0,则将其作为 selectedTime 的值返回。
- 如果 selectedTime 没有定义,则使用默认值 360 (6 分钟)。
- 任何时候 selectedTime 值改变了,将新的值以 selectedTime 关键字写到 UserDefaults 。
通过使用 带有 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",然后在标签里显示它。
现在添加这行到 viewDidLoad:
showExistingPrefs()
当视图被加载时,调用这个方法显示偏好设置。记住,用 MVC 模式时,Preferences 对象不知道什么时候也不知道这样显示——这些是交给 PrefsViewController 管理的。
现在你有能力去显示设置的时间了,但是改变菜单中的时间还没有做任何事。你需要一个方法来保存新的数据,然后告诉对数据改变感兴趣的人。
在 EggTimer 中,你使用委托对象来传递任何它感兴趣的数据。这一次(仅仅是为了不同)当数据改变时你将广播一个消息(Notification)。任何选择的对象都可以监听这个消息,在收到时对它进行处理。
在 PrefsViewController 里插入方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),
object: nil)
}
这个方法从滑块获得数据(一会你将看到任何改变都在这里反映)。设置 selectedTime 属性将自动保存新数据到 UserDefaults。然后名为 PrefsChanged 的消息被发到 NotificationCenter。
一会你将看到 ViewController 如何设置监听 Notification 消息,并作出相应。
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()
}
- 当一个新的菜单项被选中时,检查它是不是 Custom 选项。如果是则启用滑块,然后退出。如果不是,通过 tag 获取分钟数,用它来设置滑块和标签,然后禁用滑块。
- 无论什么时候滑块改变了,更新文本。
- 点击 Cancel 关闭窗口,不保存改变。
- 点击 Ok 时先调用 saveNewPrefs,然后关闭窗口。
编译运行 APP,然后打开 Preferences 窗口。尝试选择菜单里不同的选项——注意滑块和文本标签怎么跟着变化。选择 Custom,然后选择你自己的时间。点击 OK,回到 Preferences 窗口,确认你选择的时间仍然显示。
现在退出 APP,然后重新启动。回到 Preferences,看到它已经保存了你的设置。
实现选择的偏好
偏好窗口看起来没问题了——能按期望的保存和恢复你选择的实际。但是当你回到主窗口,你仍然得到的是 6 分钟的鸡蛋!
所以,你需要编辑 ViewController.swift 让它使用存储的时间,然后监听变化的通知,并对 timer 计时器作出改变和重置。
在任何类定义和扩展外面添加一个到 ViewController.swift 的扩展——为了代码简洁,它把有关偏好的所有功能组织到一个单独的包里:
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 是一个结构,它是基于值的,不是基于引用。每个视图控制器都有一个自己的拷贝。
- Preferences 结构通过一个弹件对象和 UserDefaults 交互,所有两份拷贝都使用相同的 UserDefaults 获取相同的数据。
在 viewDidLoad 函数的最后添加这个函数调用,用来设置 Preferences 连接:
setupPrefs()
这是最后一组需要的编辑。早些时候,你使用了时间的硬编码值——360秒,6分钟。现在 ViewController 能访问 Preferences,你要将硬编码 360 改为 prefs.selectedTime。
在 ViewController.swift 里搜索 360,将它改为 prefs.selectedTime——你应该会找到 3 处。
编译运行 APP,如果你开始选择了你偏爱的煮蛋时间,剩余时间将会显示你选择的。到 Preferences 窗口选择一个不同的时间,点击 OK。当 ViewController 收到通知 Notification 时,新的时间将立即显示。
启动计时器,然后转到 Preferences。倒数计时仍在后面的窗口继续。改变煮蛋时间,点击 OK。计时器会运用新的时间,但是会停止和重置计时器。我认为这很好,但是如果 APP 发出警告将会发生改变会更好。添加一个对话框提示你是否真的想做这个操作怎么样?
在处理 Preferences 的 ViewController 的扩展中,添加这个函数:
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()
}
}
}
这里发生了什么?
- 如果计时器是暂停或停止的,直接重置,不用询问。
- 创建一个 NSAlert,这是一个显示对话框的类。配置它的文本和样式。
- 添加两个按钮:Reset & Cancel。它们将按你添加的顺序从右到左显示,第一个按钮时默认的。
- 显示模态对话框,等待回答。确认用户是否点击了第一个按钮(reset),如果是,重置计时器。
在 setupPrefs 方法里将 self.updateFromPrefs() 改为:
self.checkForResetAfterPrefsChange()
编译运行 APP,启动计时器,转到 Preferences,修改时间,点击 OK。你将看到对话框,选择 reset 或者 cancel。
声音
到现在,这个 APP 只有一个部分我们还没有涉及了,这就是声音。
一个煮蛋计时器如果不会发出“叮~~~”的声音就不是一个煮蛋器。
在第二部分,你为 APP 下载了一个资源文件夹。它们中大部分是图片,你已经使用了,但是有一个是声音文件:ding.mp3。如果你需要再下载一次,这里是它自己的连接 sound file。
将 ding.mp3 文件拖到项目导航中的 EggTimer 组——就放在 Main.storyboard 下面似乎比较合逻辑。确保 Copy items if needed 和 EggTimer target 是选择的,点击完成。
要播放声音你需要 AVFoundation 库。当 EggTimer 告诉委托对象计时器结束时,ViewController 需要播放声音。所以转到 ViewController.swift,在顶部,你将看到 Cocoa 库引入的地方。
在那行下面添加:
import AVFoundation
ViewController 需要一个播放器来播放声音文件,所以为它添加一个属性:
var soundPlayer: AVAudioPlayer?
为 ViewController 单独添加一个扩展似乎是一个好主意,用它处理声音相关的函数。在所有函数和扩展外面添加以下代码到 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 函数做了大部分工作——它首先确认 APP bundle 里的 ding.mp3 文件是否可用。如果文件这那里,它尝试声音文件的路径来初始化 AVAudioPlayer,然后为播放做好准备。这为声音文件准备好缓冲,需要的时候可以立即播放。
playSound 函数仅仅是向播放器发送一个播放消息。如果 prepareSound 失败,soundPlayer 的值是 nil,那么什么也不做。
声音只需要在 Start 按钮被点击是预处理一次,所以将这行代码插到 startButtonClicked 的最后:
prepareSound()
在 EggTimerProtocol 协议扩展的 timerHasFinished 函数里添加:
playSound()
编译运行 APP,随便选择一个短的时间,然后启动计时器。当计时器停止时,你能听到“叮~~~”的声音吗?
你能从这里下载完整的项目。
这个 macOS 开发的系列教程已经给了你一个基本的知识,通过它你可以开始做 macOS app 开发了,但是还有很多东西要学。
苹果有很好的文档,涵盖了 macOS 开发的方方面面。
我也非常建议看一下其他的教程 raywenderlich.com。