原文:macOS Development for Beginners: Part 3
作者:Sarah Reichelt
译者:kmyhy
欢迎回到 macOS 开发入门教程三部曲的最后一部,也就是第三部!
在第一部分,你学习了如何安装Xcode,如何创建一个简单的 App。在第二部分,你为一个有点小复杂的 App 创建了 UI,但它仍然不能工作,因为你还没编写任何代码。在这一部分,你将编写一些 Swift 代码,让你的 App 焕发生机!
开始
如果你没有完成第二部分或者想有一个干净的开始,你可以从这里下载第二部分已完成的项目,它的 UI 已经布局好了。打开这个项目或你在第二部分中完成的项目,运行项目确认 UI 部分显示正常。打开 Preferences 窗口查看是否正常。
沙盒
在开始编写代码之前,我们花点时间讨论一下沙盒。如果你是一个 iOS 开发者,你可能知道这个概念——否则的话,请继续。
一个沙盒 App 拥有自己工作空间,它会分成几个独立的文件存储区域,并且无法访问其他 Ap 所创建的文件,同时访问和权限会有一定的限制。对于 iOS App,除了沙盒我们别无他法。对于 macOS App,情况稍好一点;但是如果你想将App 发布到 App 商店,那么也只能访问沙盒。作为一个通用原则,你应该让你的 App 成为“沙盒的”,因为这会让你的 App 出现问题的几率更小。
要打开 Egg Timer App 的沙盒选项,从项目导航器中选中项目——也就是最上面的蓝色图标。在 Targets 列表中(其实列表中也只有那么一个 target)选择 EggTimer,然后点击顶部标签栏的 Capabilities。将 App SandBox 的开关打开。这会展开一个列表显示你能够为 App 声明的各种权限。本 App 不需要这些权限,因此全部都不用选。
组织你的文件
回到项目导航器。所有的文件都是没有任何层次列在一起。这个 App 的文件还不算多,但将它们分门别类地组织起来有助于查找它们,对于大项目来说尤其如此。
选中两个 view controller 文件,你可以先选中一个,在按住shift 键选中另一个。右键,选择 New Group from Selection。group命名为 View Controllers。项目中还有一些模型文件,因此选择最顶层的 EggTimer 目录,右键,选择 New Group,命名为 Model。
最后,选中 Info.plist 和 EggTimer.entitlements,将它们拖进 Supporting Files 文件夹。
MVC
这个 App 使用了 MVC 模式:模型视图控制器。
App 中的模型对象主要是一个叫做 EggTimer 的类。这个类拥有几个属性:定时器的开始时间,时长和已经用掉的时间。还有一个 Timer 对象,用于每秒触发一次更新。方法包括了 EggTimer 对象的启动、停止、恢复 和 重置。
EggTimer 模型类负责保存数据和执行动作,但它不知道如何显示。Controller(在这里就是 ViewController),知道 EggTimer 类(模型),也知道用于显示这些数据的 View。
要和 ViewController 进行通讯,EggTimer 使用了一个委托协议。当它发生改变时,EggTimer 会向委托对象发送消息。ViewController 将自己设置为 EggTimer 的委托,因此它会接收到这个消息,然后将新的数据更新到它的视图。
实现 EggTimer
在项目导航器中选中 Model 文件夹,然后选择 File/New/File… Select 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 的状态。
在 EggTimer.swift 中,但是在 EggTimer 类之外添加一个委托协议——我喜欢将协议定义在文件的顶部,就在 import 语句后面。
protocol EggTimerProtocol {
func timeRemainingOnTimer(_ timer: EggTimer,timeRemaining: TimeInterval)
func timerHasFinished(_ timer: EggTimer)
}
协议声明了一种义务,任何声明要采用 EggTimerProtocol 的对象必须实现这两个方法。
现在你已经定义了一个协议,EggTimer 可以指定一个可空的 delegate 属性,用于保存一个实现该协议的对象。EggTimer 不知道也不关心对象的类型是什么,因为只需要知道 delegate 有这两个方法即可。
var delegate: EggTimerProtocol?
EggTimer 的 timer 对象启动之后会以一秒钟一次的频率调用一个方法。添加这个方法用于给 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)
}
}
这是什么意思?
- startTime 是一个可空类型的 Date – 当它为空时,定时器不应该运行,因此什么也不做。
- 重新计算 elapsedTime 属性。startTime 是比当前时间还要早的时间,因此 timeIntervalSinceNow 会产生一个负值。因此将一个负号放在前面,以便 elapsedTime 会是一个正数。
- 计算定时器还剩下的时间,rounded() 方法会对秒数取整。
- 如果定时器结束,重置它并通知委托对象它已经运行结束。因为 delegate 是可空类型,所以要用 ?进行解包。如果 delegate 未设置,这些方法不会调用,因此不会产生任何错误。
你会看到一个错误,因为我们还没有编写最后的一点代码: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,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(),然后创建一个循环定时器。
- resumeTimer 方法会在定时器被暂停,然后被重新启动的时候调用。这个开始时间会根据已经用去的时间来重新进行计算。
- stopTimer 停止定时器。
- resetTimert 停止定时器并将属性设置为默认值。
所有方法都会调用 timerAction,以便界面会立即刷新。
ViewController
EggTimer 已经准备好了,让我们转到 ViewController.swift,让 UI 的显示和它保持一致。
ViewController 已经创建了 @IBOutlet 属性,但现在需要添加一个属性:
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 方法调用了私有方法根据剩余时间来获取要显示的文本和图片,然后将之分别显示在文本框和 image view 中。
textToDisplay 方法将剩余时间转换成 M:SS 格式。imageToDisplay 方法计算煮鸡蛋的时间用去了整个时间的百分之几,然后返回对应的图片。
ViewController 已经有了一个 EggTimer 对象,它也能够从EggTimer 获得数据并显示结果,但按钮代码还没有写。在第二部分,我们其实已经为按钮创建了 @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)
}
这个 3 个 action 调用了我们早先添加的 EggTimer 方法。
运行 App,然后点击 Start 按钮。
有几个功能暂时是不全的:Stop 和 Reset 按钮一直不可以用,你只能煮出 6 分钟的蛋了。你可以用 Timer 菜单操作这个 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 中配置它们。
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 在三个按钮的 Action 方法的最后加入这句:
configureButtonsAndMenus()
运行 App,你可以看到按钮的可用/不可用状态终于正常了。查看菜单项,它们和按钮的状态保持一致。
偏好设置
这个 App 还剩一个很大的问题——如果你不想让鸡蛋只能煮 6 分钟呢?
在第二部中,你设计了一个偏好设置窗口,允许选择不同的时间。这个窗口由 PrefsViewController 负责,但它还需要一个模型对象负责数据的存储和查找。
偏好设置将使用 UserDefaults 存储,它一种在 App 容器的 Preferences 目录中以键值形式存储小数据的方式。
在项目导航器中,右键 Model 文件夹,选择 New File… Select 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 值。如果这个值还没定义,UserDefautls 会返回 0,如果这个值大于 0,则返回 UserDefaults 中的值。
- 如果 selectedTime 值未定义,使用 360(6分钟)作为默认值。
- 当 selectedTime 被修改时,将新值保存到 UserDefaults 的 selectedTime 键中。
通过一个计算属性的 getter 方法和 setter 方法,UserDefaults 数据存储机制会自动替我们处理。
回到 PrefsViewController.swift,首先需要让界面显示出 defaults 中存储的现有值。
var prefs = Preferences()
这里创建了一个 Preference 实例以便访问 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 属性。还记得你在第二部中将每个选项的 tag 设置为它们的分钟数吗?如果找到匹配的情况,勾选这个选项,退出循环。
- 设置 slider 的值,调用 showSliderValueAsText。
- showSliderValueAsText 将 “minute” 或者 “minutes” 添加到数字后面,然后显示在 text field 中。
现在,在 viewDidLoad 中加入:
showExistingPrefs()
当视图加载,调用这个方法,显示偏好设置。注意,根据 MVC 模型,Preferences 模型不应该知道如何以及如何显示它——那是 PrefsViewController 的事情。
现在你已经能够显示设置的时间了,但改变时间的时候这个 popup 按钮什么也不会做。你需要实现一个方法,保存新数据,通知对此感兴趣的人数据已经改变。
在 EggTimer 对象中,你用委托模型传递数据给需要的对象。这次(有点不同),你在数据被修改时广播一个通知。任何监听了这个通知的对象会收到通知并进行处理。
在 PrefsViewController 中新增方法:
func saveNewPrefs() {
prefs.selectedTime = customSlider.doubleValue * 60
NotificationCenter.default.post(name: Notification.Name(rawValue: "PrefsChanged"),object: nil)
}
这里从 slider 获取数据(任何改变都会在这里体现)。对 selectedTime 属性进行赋值会导致新数据自动保存到 UserDefaults。然后通过通知中心发送 PrefsChanged 通知。
接下来,你会看到 ViewController 如何监听这个通知并进行处理。
最后一步是你在第二部中在 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()
}
- 当 popup 按钮中有新的选项被选中时,判断它是否是 Custom 选项。如果是,使 slider 可用并退出。如果不是,用 tag 属性去获取分钟数,然后设置 slider 的值和文本,并禁用 slider。
- 当 slider 值发生改变,修改文字显示。
- 点击取消按钮,关闭窗口,不保存改变。
- 点击 OK 按钮,调用 saveNewPrefs 然后关闭窗口。
运行 App,进入偏好设置。在 popup 按钮中选择不同的选项——注意 slider 和 text field 会做相应变化。选择 Custom,然后挑选一个时间。点击 OK,回到偏好设置窗口,确认你的选定的时间是否依然显示。
现在退出 App 然后重新打开,进入偏好设置窗口,查看已保存的设置。
实现所选的设置
偏好设置窗口看起来不错——它能够保存和恢复你选定的时间。但回到主窗口,你仍然只能煮 6 分钟的鸡蛋!
因此你需要修改 ViewController.swift,将保存好的值应用到计时中去,监听定时器的时间变化通知和重置通知。
为 ViewController 新增一个扩展——这个扩展会将所有的设置功能干净地包装到一起:
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 是结构,因此它不是引用类型,而是值类型。每个 View Controller 都会拥有单独的拷贝。
Preferences 结构使用了 UserDefaults 的单例,因此两个拷贝都使用了同一个 UserDefaults 对象,并获取了同一个数据。
在 ViewController 的 viewDidLoad 函数最后,添加这句,创建 Preferences 对象:
setupPrefs()
这是最后一处需要修改的地方。前面,我们硬编码了计时时间——360 秒或 6 分钟。现在,ViewCotnroller 访问 Preference,你可以将硬编码的 360 秒修改为 prefs.selectedTime。
在 ViewController.swift 中搜索 360,将每一处都修改为 prefs.selectedTime – 你可能找到 3 个 360。
运行 App。如果你先前已经改变过煮蛋的时间,不管你怎么选择剩余时间都会显示。进入偏好设置窗口,选择不同的时间点击 OK——你的新时间立即在 ViewController 收到通知后显示。
开始计时,回到 Preferences 窗口。倒计时在后面的窗口中进行。改变煮蛋时间点击 OK。定时器会使用你的新时间,但停止计时并充值计数。这也是可以的,但如果让 App 稍微提示一下用户会更好。添加一个对话框,提示用户接下来会发生什么吧!
在和偏好设置有关的 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,这个类用于显示对话框。设置 alert 的文字和风格。
- 添加两个按钮:Reset 和 Cancel。它们按照从右到左顺序排列,第一个按钮是默认按钮。
- 以模式对话框方式显示 alert,等待用户操作。如果用户点击第一个按钮(Reset),重置定时器。
在 setupPrefs 方法中,将这句 self.updateFromPrefs() 修改为:
self.checkForResetAfterPrefsChange()
运行 App,开启定时器,进入 Preferences 窗口,修改时间,点击 OK。你会看到对话框弹出,你可以在这时选择重置或者取消。
声音
这个 App 中还有一个部分没有实现,那就是声效。一个不会”叮”的煮蛋器不是真正的煮蛋器。
在第二部中,你下载了 App 的 assets 文件夹。其中包含了你用到的图片,还包含了一个声音文件:ding.mp3。如果你需要再次下载这个声音文件,下载链接在这里。
将 ding.mp3 文件拖入项目导航器的 EggTimer 文件夹——一个理想的地方是放在 Main.storyboard 后面。确保选中 Copy items if needed is checked 同时勾选 EggTimer target。然后点击 Finish。
要播放声音,你必须使用 AVFoundation 框架。ViewController 会在收到 EggTimer 的定时器时间结束通知时播放声音,因此打开 ViewController.swift 文件。在文件顶部,找到导入 Cocoa 框架的地方。
在这行下面,添加:
import AVFoundation
ViewController 需要用播放器来播放声音,因此添加一个属性:
var soundPlayer: AVAudioPlayer?
为 ViewController 增加一个新扩展用于放声音相关逻辑是个不错的注意,因此在已有的扩展之后重新定义一个 ViewController 扩展。
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 方法负责完成大部分工作——首先检查 bundle 中是否存在 ding.mp3 文件。如果文件存在,使用这个文件的 URL 初始化一个 AVAudioPlayer,然后准备播放。这会提前缓存声音文件以便在某个时候立即开始播放。
如果 soundPlayer 对象不为空,playSound 方法向播放器发送 play 消息,但如果 prepareSound 失败,soudPlayer 会初始化为 nil,因此什么也不做。
当 Start 按钮被点击时,只需要准备一次声频文件,因此在 startButtonClicked 方法最后加入:
prepareSound()
然后在 EggTimerProtocol 协议扩展的 timerHasFinished 方法中加入:
playSound()
运行 App,选择一个煮蛋时间,然后开始计时。当计时结束,你应当听到一声”叮“。
结束
这个 macOS 开发系列入门教程让你具备 macOS App 开发的基本知识——但你要学的东西仍然还有许多。
苹果文档中有大量优秀的文档,介绍了 macOS 开发中的方方面面。
我隆重推荐 raywenderlich.com 的其它 macOS 教程。
有任何问题和建议,请在下面留言!